Skip to content

Support TCP for protocol messages#3636

Draft
softins wants to merge 3 commits into
jamulussoftware:mainfrom
softins:tcp-protocol
Draft

Support TCP for protocol messages#3636
softins wants to merge 3 commits into
jamulussoftware:mainfrom
softins:tcp-protocol

Conversation

@softins

@softins softins commented Mar 11, 2026

Copy link
Copy Markdown
Member

Short description of changes

Support fallback to TCP for protocol messages, in order to overcome potential loss of large messages due to UDP fragmentation. Currently an incomplete draft, for comment as development continues.

CHANGELOG: Client/Server: Support TCP fallback for protocol messages.

Context: Fixes an issue?

Discussed in issue #3242.

Does this change need documentation? What needs to be documented and how?

It will need documentation once design and development are complete. Particularly need to explain the firewall requirements for a server or directory.

Status of this Pull Request

Incomplete, still under development. Main server side complete and working. Client side development in progress. Complete and ready for review and testing. Still marked draft as it needs some of the debug messages to be commented out before merging.

What is missing until this pull request can be merged?

A lot of testing of both server and client. Intended for Jamulus 4.0.0.

Checklist

  • I've verified that this Pull Request follows the general code principles
  • I tested my code and it does what I want
  • My code follows the style guide
  • I waited some time after this Pull Request was opened and all GitHub checks completed without errors.
  • I've filled all the content above

@softins softins added this to the Release 4.0.0 milestone Mar 11, 2026
@softins softins self-assigned this Mar 11, 2026
@softins

softins commented Mar 11, 2026

Copy link
Copy Markdown
Member Author

So far, this implements the server side of the design described here and here

@softins
softins force-pushed the tcp-protocol branch 4 times, most recently from 5e1a658 to 0ae51e2 Compare March 16, 2026 13:05
@softins softins linked an issue Mar 16, 2026 that may be closed by this pull request
@softins softins added the feature request Feature request label Mar 16, 2026
@softins
softins force-pushed the tcp-protocol branch 3 times, most recently from 7ad1d1f to d939e5b Compare March 26, 2026 17:38
@softins

softins commented Mar 28, 2026

Copy link
Copy Markdown
Member Author

So the next stage of implementation has been achieved: client-side support in the Connect dialog.

  1. If the server list has not been received via UDP when the associated message indicating TCP support has arrived, the client will retry fetching the server list over TCP.
  2. If the client list for a server has not been received via UDP when the associated message indicating TCP support has arrived, the client will retry fetching the client list over TCP, and will continue to use TCP for that server while the Connect dialog is open.
  3. A directory or server that does not have TCP support will not send the TCP supported message, and will continue to be handled as in current versions.
  4. If the server list or client list is successfully received over UDP, there is no need for the client to try TCP.

It has been tested by using nft to drop outbound Jamulus UDP messages with a specific message ID, to simulate loss due to fragmentation.

Examples for a directory-enabled server running on port 22120:

  • drop UDP server list: nft add rule inet filter output udp sport 22120 @ih,16,16 0xee03 drop
  • drop UDP client list: nft add rule inet filter output udp sport 22120 @ih,16,16 0xf503 drop
  • drop UDP "TCP supported" msg: nft add rule inet filter output udp sport 22120 @ih,16,16 0xfb03 drop

Note that nft rules require network byte order (big-endian), but Jamulus IDs are little-endian:

  • CLM_SERVER_LIST = 1006 = 0x03ee => 0xee03 (LE byte order)
  • CLM_RED_SERVER_LIST = 1018 = 0x03fa => 0xfa03 (LE byte order)
  • CLM_CONN_CLIENTS_LIST = 1013 = 0x03f5 => 0xf503 (LE byte order)
  • CLM_TCP_SUPPORTED = 1019 = 0x03fb => 0xfb03 (LE byte order)

@softins

softins commented Mar 28, 2026

Copy link
Copy Markdown
Member Author

The next step is to try implementing the connected-mode TCP described here

@ann0see
ann0see self-requested a review April 7, 2026 14:51
Comment thread src/tcpserver.h
Comment thread src/main.cpp
bool bUseTranslation = true;
bool bCustomPortNumberGiven = false;
bool bEnableIPv6 = false;
bool bEnableTcp = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we'll have a long time for the 4.0 release, I'd enable it by default soon (of course once we've tested that the basics work)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I disagree. It's a server-only option, and most servers operators will not need to enable TCP support. Only those running large directories or large servers will need to, and they also need to understand and configure their firewall requirements.

TCP support in the client will indeed be enabled by default, but will only take effect when talking to a directory or server that has enabled it.

If a server operator enables TCP without having configured their firewall correctly, client users could have problems as the server would advertise TCP support to the client, but the client could be unable to connect.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not give an error message or fallback procedure in case the TCP connection timed out?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm sure we can. I haven't yet tested that scenario.

But it doesn't negate my view that server-side TCP support needs to be an explicit option.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since when do we have a long time?

There's absolutely no benefit to anyone involved in the project - developers or users - in having long release cycles.

A version cut takes very little time - apart from the translation process. Even then, getting used to more, small changes is likely to speed things up generally.

Comment thread src/connectdlg.cpp Outdated
Comment thread src/connectdlg.cpp Outdated
@ann0see ann0see added the bug Something isn't working label Apr 9, 2026
@github-project-automation github-project-automation Bot moved this to Triage in Tracking Apr 9, 2026
@ann0see ann0see moved this from Triage to In Progress in Tracking Apr 9, 2026
@softins

softins commented Apr 9, 2026

Copy link
Copy Markdown
Member Author

Well I've finished implementing everything I intended to, for directory, server and client, so it's ready for reviewing and trying out, as and when time permits (post 3.12.0).

I have a private directory and server built and running with TCP support, at newjam.softins.co.uk on the standard port 22124.

In order to demonstrate the use of TCP in a new client's connect dialog, it will be necessary to use custom firewall filters on the client end to temporarily drop incoming UDP Jamulus protocol messages containing a server list or connected clients list.

There is full forward and backward compatibility between clients and servers built with TCP support and older versions.

@softins
softins marked this pull request as ready for review April 9, 2026 22:48
@softins
softins marked this pull request as draft April 10, 2026 06:30
@softins

softins commented Apr 10, 2026

Copy link
Copy Markdown
Member Author

Keeping as draft, because it will need quite a few debug messages removed before merging.

Comment thread src/protocol.cpp
- PROTMESSID_CLM_CLIENT_ID


- PROTMESSID_CLM_CLIENT_ID: Sends the client's channel ID back to the server

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any security implications if the client sends a wrong id?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I can think of, because I made sure the server will only accept it if it comes from the correct IP address. Otherwise it's ignored.

Comment thread src/protocol.cpp
}

bool CProtocol::EvaluateCLReqConnClientsListMes ( const CHostAddress& InetAddr )
bool CProtocol::EvaluateCLReqConnClientsListMes ( const CHostAddress& InetAddr, CTcpConnection* pTcpConnection )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why we need to pass CTcpConnection that often and cannot just pass it once to the class?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we have a separate TCP connection with each different peer.

Comment thread src/serverlist.cpp
{
// send empty message to keep NAT port open at registered server
pConnLessProtocol->CreateCLEmptyMes ( ServerList[iIdx].HostAddr );
pConnLessProtocol->CreateCLEmptyMes ( ServerList[iIdx].HostAddr, nullptr );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateCLEmptyMes could also have a default argument == nullptr I suppose.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly, I'll check.

@ann0see ann0see left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

@ann0see

ann0see commented Jul 2, 2026

Copy link
Copy Markdown
Member

I disagree. The CLM_TCP_SUPPORTED message is not just to let the client know we support TCP. It also enables the client to know that it didn't receive the expected message via UDP that was sent immediately before it.

Then we should probably rename it to express that it is not just TCP support but rather that it request a TCP connection due to network problems.

@softins

softins commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

I disagree. The CLM_TCP_SUPPORTED message is not just to let the client know we support TCP. It also enables the client to know that it didn't receive the expected message via UDP that was sent immediately before it.

Then we should probably rename it to express that it is not just TCP support but rather that it request a TCP connection due to network problems.

But it doesn't mean that. The message is still sent and received if there are no network problems. It is the client that decides what to do with it, by inferring network problems only if CLM_TCP_SUPPORTED is received without being preceded by the expected server or client list. It is only telling the client that the server can support TCP, not that TCP must be used.

@pljones

pljones commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

It's more "TCP is available as an alternative to the UDP response you were expecting (<this one>) - ask and you'll get it reliably".

@pljones pljones moved this from Waiting on Team to In Progress in Tracking Jul 4, 2026
@softins

softins commented Jul 18, 2026

Copy link
Copy Markdown
Member Author

Just rebased to latest main. Now need to work out how to support the newly-introduced --serverbindip6.

Comment thread src/protocol.cpp
Comment on lines +2791 to +2795
// check size
if ( vecData.Size() != 1 )
{
return true; // return error code
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote null-pointer crash: CLM_CLIENT_ID is reachable over UDP

PROTMESSID_CLM_CLIENT_ID (1024) is a connectionless ID, so ParseConnectionLessMessageBody dispatches it regardless of transport. When it arrives over UDP, CSocket emits ProtocolCLMessageReceived(...) with the new pTcpConnection argument defaulting to nullptr, which flows through EvaluateCLClientIDMes(..., nullptr)CServer::OnCLClientIDReceived(..., nullptr). That handler dereferences pTcpConnection on both the reject path (pTcpConnection->disconnectFromHost()) and the success path (pTcpConnection->SetChannel(...)).

So a single unsolicited UDP datagram — a valid frame with ID 1024 and a 1-byte body containing any channel number ≥ iMaxNumChannels — crashes any --enabletcp server. No auth is required, the source IP is spoofable, and no session is needed. Simplest to reject the message at the protocol layer when it did not arrive over TCP:

Suggested change
// check size
if ( vecData.Size() != 1 )
{
return true; // return error code
}
// check size
if ( vecData.Size() != 1 )
{
return true; // return error code
}
// CLM_CLIENT_ID is only meaningful when received over a TCP connection.
// If it arrives over UDP, pTcpConnection is nullptr; ignore it, since
// passing a null connection to the server handler would dereference it.
if ( pTcpConnection == nullptr )
{
return true; // return error code
}

(Optional defense-in-depth: also early-return in CServer::OnCLClientIDReceived when pTcpConnection == nullptr, so any future caller is protected too.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    if ( pTcpConnection == nullptr )

Why not do this before checking the size? We should only be expecting this message over TCP so size is irrelevant until that's established.

Comment thread src/tcpconnection.cpp Outdated
// now have a complete header
iPayloadRemain = CProtocol::GetBodyLength ( vecbyRecBuf );

Q_ASSERT ( iPayloadRemain <= MAX_SIZE_BYTES_NETW_BUF - MESS_HEADER_LENGTH_BYTE );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heap overflow: the TCP frame length is only bounds-checked by Q_ASSERT, which is compiled out in release builds

GetBodyLength returns the on-the-wire 2-byte length field (+2 for the CRC), i.e. up to 65537, and stores it in iPayloadRemain. vecbyRecBuf is fixed at MAX_SIZE_BYTES_NETW_BUF (20000). In a release build (QT_NO_DEBUG) this Q_ASSERT is a no-op, and the body branch just below then calls pTcpSocket->read( (char*) &vecbyRecBuf[iPos], iPayloadRemain ) with iPos climbing past the end of the buffer. ParseMessageFrame would reject the bad length/CRC, but only after the payload has already been read into the buffer — i.e. after the overflow.

A peer that sends a 7-byte header declaring length 0xFFFF and then streams ~25 KB overflows the buffer (a write primitive); debug builds instead hit the assert and abort(). The same applies in both directions (a malicious server can do it to a client that opened a fallback connection). A runtime guard is needed here:

Suggested change
Q_ASSERT ( iPayloadRemain <= MAX_SIZE_BYTES_NETW_BUF - MESS_HEADER_LENGTH_BYTE );
// Guard against a malformed or malicious length field. The body
// plus CRC must fit in the fixed-size receive buffer. Q_ASSERT is
// compiled out in release builds, so a runtime check is required
// here to stop an oversized frame from overflowing vecbyRecBuf.
if ( iPayloadRemain < 0 || iPayloadRemain > MAX_SIZE_BYTES_NETW_BUF - MESS_HEADER_LENGTH_BYTE )
{
qWarning() << "- Jamulus-TCP: oversized/invalid frame length" << iPayloadRemain << "- dropping connection";
disconnectFromHost();
return;
}

Comment thread src/tcpserver.cpp

qDebug() << "- Jamulus-TCP: received connection from:" << peerAddress.toString();

new CTcpConnection ( pSocket, peerAddress, pServer ); // will auto-delete on disconnect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No limit on concurrent TCP connections → memory / fd exhaustion

Every accepted connection constructs a CTcpConnection, whose constructor does vecbyRecBuf.Init ( MAX_SIZE_BYTES_NETW_BUF ) — a 20 KB allocation per connection — and there is no per-IP or global cap on how many may be open at once. QTcpServer::maxPendingConnections only bounds the accept backlog, not established connections.

Unlike the UDP path (which holds no per-peer state), a --enabletcp server now exposes an unauthenticated endpoint where an attacker can open thousands of connections and sit idle up to the idle timeout (5 s initially, 20 s after any byte), each costing ~20 KB plus a file descriptor. That is a cheap resource-exhaustion vector against exactly the large public directories this feature targets.

Worth considering a global and/or per-IP connection cap (refuse/close beyond it), and possibly allocating vecbyRecBuf lazily once a full header shows a real message is arriving, rather than 20 KB up front for every idle connection.

Comment thread src/tcpconnection.cpp
pTcpSocket->deleteLater();
if ( pChannel && pChannel->GetTcpConnection() == this )
{
pChannel->SetTcpConnection ( nullptr ); // unlink from channel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing pChannel->pTcpConnection here is not serialised against the channel-table readers (matters under -T)

OnDisconnected runs in the event thread and clears the channel's connection pointer, then deleteLater()s this object — but it does not hold CServer::Mutex. Every other server access to vecChannels[] / pTcpConnection is taken under that mutex, including the send path CChannel::CreateConClientListMesif ( pTcpConnection ) ConnLessProtocol.CreateCLConnClientsListMes ( ..., pTcpConnection )pTcpConnection->write(...).

In multithreading mode (-T) a client disconnecting concurrently with a client-list send can therefore produce a torn read of the pointer, or a synchronous write() on a CTcpConnection that is mid-teardown. Serialising the link/unlink of pChannel->pTcpConnection under the same mutex that guards the channel table would close the window. (In single-thread mode the event loop serialises it, so this is -T-only.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, slightly confused here whether it is concerned with CServer::Mutex or CChannel::Mutex or both.

@softins softins Jul 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is an issue. A CTcpConnection either belongs to a CServer, for inbound connections when the application is a server, in which case pChannel is nullptr, or to a CChannel for outbound connections when the application is a client, in which case pServer is nullptr. Consequently, the scenario indicated above by AI can never happen.

Comment thread src/client.cpp
ConnLessProtocol.CreateCLReqServerListMes ( InetAddr, PROTO_UDP );
break;
case CFM_TCP_REQUEST:
qWarning() << "Unsatisfied Server List request via TCP for" << InetAddr.toString() << "(switching back to UDP)";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 2.5 s connect-dialog re-request timer can pre-empt an in-flight TCP fallback (3 s connect timeout)

While a server list has not arrived, CConnectDlg's re-request timer keeps calling back into CreateCLReqServerListMes. Once a request is in CFM_TCP_REQUEST state, the next tick lands on this branch and reverts it to UDP ("switching back to UDP"), re-inserting CFM_UDP_REQUEST. But TCP_CONNECT_TIMEOUT_MS is 3000 ms while the re-request cadence is ~2.5 s, so on a slow path the timer can abandon a TCP attempt that was about to succeed, fall back to UDP, then re-enter TCP on the next CLM_TCP_SUPPORTED — a UDP/TCP flip-flop that also opens a fresh TCP connection each cycle.

It converges eventually (TCP wins and stops the timer), but potentially only after several cycles, on exactly the marginal links where TCP is needed most. Consider not reverting to UDP while a TCP connect/request is genuinely still outstanding, or making the re-request interval longer than the TCP connect timeout. (The client-list equivalent is at the matching branch of CreateCLServerListReqConnClientsListMes.)

Comment thread src/client.h
Comment on lines +458 to +467
enum EFetchMode
{
CFM_UDP_REQUEST, // set when sending request by UDP
CFM_UDP_RESULT, // set when received a client list by UDP
CFM_TCP_REQUEST, // set when "TCP Supported" message arrives but client list has not arrived - re-request using TCP and remain in TCP mode
CFM_TCP_RESULT // set when requested message received by TCP
};

QHash<CHostAddress, enum EFetchMode> pendingServerList;
QHash<CHostAddress, enum EFetchMode> pendingClientList;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingServerList / pendingClientList are never cleared, and CFM_UDP_RESULT is unused

These hashes gain an entry per directory/server address and are only ever removed on UDP success; CFM_TCP_RESULT / CFM_TCP_REQUEST entries persist. Nothing clears them when the Connect dialog closes or the directory changes — Start() resets iClientID / bTcpSupported but not these. Two consequences:

  1. A server that needed TCP once is remembered as CFM_TCP_RESULT and skips straight to TCP on the next dialog session, even after the fragmentation condition is gone — and if TCP is then blocked, that is a worse first experience than UDP. This also seems to contradict the design note in this thread that the per-server TCP status should be "forgotten when changing directories or on closing the dialog."
  2. Slow unbounded growth over the client's lifetime (one entry per distinct address ever queried).

Clearing both hashes in Start() (or on dialog close / directory change) would fix it. Separately, CFM_UDP_RESULT is declared but never assigned or read — either dead code to remove, or the UDP-success paths should set it instead of calling .remove().

@softins

softins commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

A lot of useful things to go through - thank you!

@mcfnord

mcfnord commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

While testing this branch I noticed the server-side TCP idle timer uses two different values depending on connection state.

On accept, CTcpConnection starts TimerIdleTimeout with TCP_IDLE_TIMEOUT_MS (5000 ms):

TimerIdleTimeout.start ( TCP_IDLE_TIMEOUT_MS );

After the first message is received, OnReadyRead resets it to TCP_KEEPALIVE_INTERVAL_MS + TCP_IDLE_TIMEOUT_MS (20000 ms):

TimerIdleTimeout.start ( TCP_KEEPALIVE_INTERVAL_MS + TCP_IDLE_TIMEOUT_MS );

So a freshly accepted TCP connection has only 5 seconds to send its first message (CLM_CLIENT_ID) before the server closes it, while an established connection tolerates 20 seconds of silence. I reproduced this directly: a bare TCP connect that sent no data was closed by the server at 5.32 seconds, logged as Jamulus-TCP: idle timeout - disconnecting.

This could disconnect a legitimate client on a slow or high-latency link, if the UDP CLIENT_ID exchange that triggers the TCP connection (or the TCP handshake itself) takes longer than 5 seconds. Is the shorter initial window intentional, for example to bound resource use from connections that never send anything, or should it use the same 20-second value as the steady state?

@mcfnord

mcfnord commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

Ran the F1 (IPv6 directory) and B1 (IPv4 forced-fragmentation) rows of our test matrix against this branch, using a 2-namespace Linux netns harness (real built server + crafted protocol frames, not simulated). Two results, one confirming the design and one flagging a separate gap.

Confirmed: the TCP fallback transport claim holds for IPv6. Forced a directory list to 61 entries (well past the ~35-server fragmentation threshold), then blocked all IPv6 fragments on the client side (ip6tables -m frag -j DROP, a blanket-fragment-intolerant policy matching real hardened firewalls). Result: CLM_SERVER_LIST/CLM_RED_SERVER_LIST were fully lost over UDP as expected, CLM_TCP_SUPPORTED still arrived, and the TCP re-request delivered all 61 entries intact with zero corruption. Repeated the same test over IPv4 with fragments genuinely dropped (iptables -t raw -A PREROUTING -f -j DROP — worth noting a plain filter-table -f rule silently no-ops on IPv4 due to conntrack pre-defragmentation; the raw-table form is the one that actually drops fragments) — same clean result, 61/61 entries recovered via TCP.

Found: a directory entry for a genuinely IPv6-only-registered server comes back with IP 0.0.0.0, independent of TCP vs UDP. CreateCLServerListMes and CreateCLRedServerListMes (protocol.cpp:2154 and :2277) encode each entry's address with HostAddr.InetAddr.toIPv4Address() — a 4-byte, IPv4-only wire field. Reproduced by registering a server from a real (non-v4-mapped) IPv6 socket and querying the list from an IPv4 client: name, city, and port all arrived correctly, but the IP field was 0.0.0.0.

This means TCP fallback is a necessary but not sufficient condition for IPv6 directories — even with fully reliable delivery, the list format itself can't describe an IPv6-only server's address. I believe this is exactly the gap softins's own #3809 ("Add IPv6 support for Directories", explicitly marked still-incomplete) is meant to close, rather than something this PR needs to take on — flagging here mainly so docs/TCP.md's framing ("resolving fragmentation is a prerequisite to IPv6 support in directories") can be read as a prerequisite rather than the whole story, and so the two PRs' scope boundary is explicit for reviewers.

Full harness, scripts, and the F2/B3 analysis (code-verified, not requiring a separate live run) are logged locally; happy to share more detail on the netns setup if useful for anyone else's testing.

@softins

softins commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

So a freshly accepted TCP connection has only 5 seconds to send its first message (CLM_CLIENT_ID) before the server closes it, while an established connection tolerates 20 seconds of silence. I reproduced this directly: a bare TCP connect that sent no data was closed by the server at 5.32 seconds, logged as Jamulus-TCP: idle timeout - disconnecting.

This could disconnect a legitimate client on a slow or high-latency link, if the UDP CLIENT_ID exchange that triggers the TCP connection (or the TCP handshake itself) takes longer than 5 seconds. Is the shorter initial window intentional, for example to bound resource use from connections that never send anything, or should it use the same 20-second value as the steady state?

This is intentional. 5 seconds should be plenty for the initial timeout. But the 20 seconds is needed to allow the 15-second keepalives to keep a NAT or firewall session open on a long per-session connection.

@softins

softins commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Found: a directory entry for a genuinely IPv6-only-registered server comes back with IP 0.0.0.0, independent of TCP vs UDP. CreateCLServerListMes and CreateCLRedServerListMes (protocol.cpp:2154 and :2277) encode each entry's address with HostAddr.InetAddr.toIPv4Address() — a 4-byte, IPv4-only wire field. Reproduced by registering a server from a real (non-v4-mapped) IPv6 socket and querying the list from an IPv4 client: name, city, and port all arrived correctly, but the IP field was 0.0.0.0.

This means TCP fallback is a necessary but not sufficient condition for IPv6 directories — even with fully reliable delivery, the list format itself can't describe an IPv6-only server's address. I believe this is exactly the gap softins's own #3809 ("Add IPv6 support for Directories", explicitly marked still-incomplete) is meant to close, rather than something this PR needs to take on — flagging here mainly so docs/TCP.md's framing ("resolving fragmentation is a prerequisite to IPv6 support in directories") can be read as a prerequisite rather than the whole story, and so the two PRs' scope boundary is explicit for reviewers.

Yes, as deduced by AI, IPv6 support is still incomplete with #3809 under development.

Also, TCP fallback is not needed for the server registering with a directory, as the messages are so small they will never be subject to fragmentation.

@mcfnord

mcfnord commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

Hi @softins — while testing the TCP fallback on this branch (Linux netns harness, real built client + server, server --enabletcp), I hit a reproducible client-side crash (SIGSEGV) on TCP session teardown and traced it to one spot. A one-line fix removes it; details below.

Symptom. With a live long-lived TCP session (client connected to an --enabletcp server, CLM_CLIENT_ID exchanged, client list flowing over TCP), ending the TCP session from the peer side crashes the client. The easiest trigger is the server's own idle timeout: drop the client→server direction so the keepalives stop arriving, and ~20s later the server calls disconnectFromHost(). The client receives the FIN and dies. dmesg shows general protection fault ... in libQt5Core.so.5.

Backtrace (main thread):

QObject::thread() const
QObject::killTimer(int)
QTimer::stop()
CClient::OnSendCLProtMessage(...)::{lambda(QAbstractSocket::SocketError)#1}::operator()
QAbstractSocket::errorOccurred(QAbstractSocket::SocketError)
...
main

Root cause (CClient::OnSendCLProtMessage, client.cpp ~280–341). When the client opens the outbound TCP connection, three handlers are wired on the socket: a connect-timeout timer, an errorOccurred handler, and a connected handler — the error and timeout handlers capture pTimer by raw pointer. On success, the connected handler runs pTimer->stop(); pTimer->deleteLater(); and hands pSocket to a new CTcpConnection, which keeps that same socket for the whole session. But the errorOccurred handler is never disconnected — it stays wired to the now-session socket, still holding the deleted pTimer. The next time that socket reports an error — which is exactly what an ordinary teardown does, since the peer closing makes QAbstractSocket emit errorOccurred(RemoteHostClosedError) — the stale handler runs pTimer->stop() on freed memory and crashes. (It would also pSocket->deleteLater() a socket now owned by CTcpConnection, a second latent hazard on the same path.)

Since CTcpConnection already handles teardown itself via disconnectedOnDisconnected, the transient error handler simply should not survive the handoff.

Fix — capture the error handler's QMetaObject::Connection and disconnect it once the socket becomes a session socket:

@@ void CClient::OnSendCLProtMessage ( ... )
-        connect ( pSocket, ERRORSIGNAL, this, [pSocket, pTimer] ( QAbstractSocket::SocketError err ) {
+        QMetaObject::Connection errConn = connect ( pSocket, ERRORSIGNAL, this, [pSocket, pTimer] ( QAbstractSocket::SocketError err ) {
             Q_UNUSED ( err );

             pTimer->stop();
@@
             pSocket->deleteLater();
         } );

-        connect ( pSocket, &QTcpSocket::connected, this, [this, pSocket, pTimer, InetAddr, vecMessage, eProtoMode]() {
+        connect ( pSocket, &QTcpSocket::connected, this, [this, pSocket, pTimer, errConn, InetAddr, vecMessage, eProtoMode]() {
             pTimer->stop();
             pTimer->deleteLater();

+            // The socket now becomes a long-lived session socket owned by the
+            // CTcpConnection below. Disconnect the transient connect-error
+            // handler: it captured pTimer (just deleted) by raw pointer, so if
+            // it fired later on this socket (e.g. normal session teardown, when
+            // the peer closes and QAbstractSocket emits errorOccurred) it would
+            // dereference the freed timer and crash.
+            disconnect ( errConn );
+
             // connection succeeded, give it to a CTcpConnection
             CTcpConnection* pTcpConnection = new CTcpConnection ( pSocket,

Verified. Rebuilt the branch with this change and re-ran the identical repro: no SIGSEGV, no dmesg GPF trap, the client stays alive, and teardown now runs cleanly through CTcpConnection::OnDisconnected (- Jamulus-TCP: disconnected from: ...). The same root cause underlies the clean-disconnect and mid-session-TCP-drop cases, so this fix covers those too.

I considered a QPointer<QTimer> capture instead, but that would only silence the timer dereference while still letting the handler wrongly run (and deleteLater() the session socket) after connect — disconnecting the handler addresses both. I kept it to one added line plus one capture so it's easy to review.

Happy to share the netns reproduction (two namespaces + veth, real binaries, scripted trigger) if that's useful. I'm not opening a PR — flagging it here for you to take or adapt as you see fit.

@mcfnord

mcfnord commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

Quick note for whenever you're next back on this, no reply needed: beyond the individual findings above, I've put together a small reproducible harness that exercises the TCP-fallback paths under injected network faults (forced fragmentation → fallback, connect-refused, mid-session TCP loss, multi-segment reassembly), plus a consolidated results summary. Glad to share either whenever it's useful to you — no rush.

@softins

softins commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

I asked ChatGPT to review this PR. Pasting its response here for reference. I'll go through the points made in due course.


That is exactly the sort of substantial change where a second independent review should be useful. I have begun by examining the current softins:tcp-protocol branch behind upstream PR #3636. The PR currently changes 20 files, with roughly 1,384 additions, and is marked complete but still requiring broader testing. ([GitHub]1)

I cannot continue working on it in the background overnight, so here are my initial findings now. This is not yet a complete line-by-line review.

Initial assessment

The overall architecture looks sensible:

  • Existing UDP behaviour remains the compatibility baseline.
  • TCP is used selectively for messages vulnerable to fragmentation.
  • Connectionless queries use short-lived TCP connections.
  • A connected client establishes a persistent TCP connection after receiving its channel ID.
  • TCP support is advertised separately, allowing old peers to continue unchanged.
  • The TCP receive path implements explicit framing around the existing Jamulus header, payload length and CRC rather than assuming one readyRead() per message.

That is a pragmatic extension of the existing protocol rather than a wholesale redesign. The issue description also makes a convincing case that neither reduced lists nor compression reliably solves fragmentation, particularly as IPv6 routers do not fragment packets in transit. ([GitHub]2)

The main point I would investigate before merging

Persistent TCP-session association may need stronger authentication

The client sends its one-byte channel ID over the newly established TCP connection, and the server uses that to associate the TCP socket with an existing UDP channel. The design description explicitly identifies this as the bridge between the independently NAT-mapped UDP and TCP connections. ([GitHub]2)

I would verify that the server requires the TCP peer’s IP address to match the IP portion of the existing channel’s UDP address before accepting that association.

Without that check, another host could potentially guess a channel ID—there are at most 256 values—and attach its TCP connection to someone else’s channel. Even with an IP check, two clients behind the same NAT share a public address, so this is not strong authentication.

The consequences depend on exactly what is carried over the persistent connection. At minimum, an attacker might:

  • replace or disrupt the legitimate TCP association;
  • receive connected-client lists or welcome messages intended for that channel;
  • keep the association alive using empty messages;
  • potentially exploit any additional connected messages moved to TCP later.

A robust design would use an unpredictable session token generated by the server and sent to the client through the established UDP protocol. The client would return that token on TCP. It need not be cryptographic authentication of the whole session; it merely needs enough entropy to prevent blind channel-ID guessing.

For the first version, an IP-address match plus refusal to replace an already-associated healthy TCP connection may be an acceptable minimum, but I would document the limitation explicitly.

Receive framing

The state machine in CTcpConnection::OnReadyRead() is broadly good:

  • it reads exactly the fixed header first;
  • extracts and validates the remaining frame length;
  • limits the frame to MAX_SIZE_BYTES_NETW_BUF;
  • handles partial header and body reads;
  • handles multiple frames available in one invocation;
  • disconnects on an invalid advertised length.

The length validation added in today’s 33d59c8 commit is particularly important. The branch now rejects negative or oversized frame lengths before reading into the fixed buffer. ([GitHub]3)

A few details deserve tests:

  1. Header split into every possible division, including one byte at a time.
  2. Body and CRC split into one-byte reads.
  3. Two or more complete frames in one TCP delivery.
  4. A complete frame followed by a partial second frame.
  5. Zero-length Jamulus message body.
  6. Maximum permitted frame length.
  7. Declared lengths just above the maximum.
  8. Socket closure halfway through a header or body.
  9. Malformed CRC followed by another valid frame.

I would also defensively handle read() returning zero. It ought not normally happen while bytesAvailable() reports positive data, but the current loop makes no progress in that case. A zero return could therefore spin indefinitely. Something like this after each read would make the invariant explicit:

if ( iNumBytesRead <= 0 )
{
    return;
}

For -1, logging the socket error may also help diagnose failures.

TCP connection lifecycle

The short-lived client connection setup has a thoughtful three-second timeout and transfers ownership of normal receive processing to CTcpConnection after connection. ([GitHub]4)

I would examine these lifecycle cases carefully:

  • timeout and socket-error callbacks occurring close together;
  • stopping the client while a TCP connection attempt is pending;
  • destruction of CClient while lambdas still capture this;
  • a successful connection occurring at approximately the same moment as the timeout;
  • a persistent connection dropping and reconnect behaviour;
  • the server restarting while the UDP session remains active.

The timeout and error lambdas both schedule the timer for deletion, and both can schedule socket deletion in related paths. Multiple deleteLater() calls are normally harmless, but the state transition is difficult to reason about. A small connection-attempt helper object, or parenting the timer directly to the socket and centralising cleanup, could simplify this considerably.

There is also no apparent automatic recreation of a dropped persistent session connection. That may be intentional, but it means a momentary TCP interruption apparently returns the session to UDP for the rest of its lifetime. Given that the feature exists to avoid messages that may be unusable over UDP, one bounded reconnect attempt might be worthwhile.

Fallback signalling

The current approach sends the ordinary UDP response and a small CLM_TCP_SUPPORTED indication. If the large response has not arrived when the support indication arrives, the client retries using TCP. This preserves fast UDP operation when fragmentation works and avoids trying TCP against old servers. ([GitHub]5)

There are some interesting ordering cases:

  • Support indication arrives first: TCP may be attempted even though the UDP response is still in flight. Harmless duplication, but worth confirming.
  • UDP response arrives first: no TCP connection is needed.
  • Large response is lost and support indication arrives: intended fallback.
  • Both are lost: no fallback.
  • Support indication is delayed until after the request state has been removed or replaced.
  • Two overlapping list requests to the same address.
  • A stale support indication from an earlier request arrives during a newer request.

Because CLM_TCP_SUPPORTED contains only the related message ID, not a request identifier, it cannot distinguish generations of the same request. That is probably acceptable for idempotent list fetching, but the state-machine behaviour should be explicitly tested.

It may also be worth rate-limiting TCP attempts initiated by unsolicited or repeated CLM_TCP_SUPPORTED messages. A malicious UDP sender who can spoof the server address might otherwise induce connection attempts.

Server resource limits

The TCP listener currently accepts connections and creates a CTcpConnection for each, with an idle timeout on the server side. That is a reasonable starting point. ([GitHub]6)

Before exposing this on every public directory and server, I would consider:

  • a global maximum number of unaffiliated TCP connections;
  • a per-source-IP limit;
  • a short timeout for a new connection to send its first complete valid frame;
  • limiting how many malformed frames are logged;
  • avoiding an attacker holding many connections open using periodic empty messages;
  • backlog sizing and behaviour when the limit is reached.

The current keepalive/idle arrangement means a peer able to send valid empty messages can apparently retain a connection indefinitely. That is correct for an authenticated session, but less desirable before the connection has been associated with a channel.

I would therefore distinguish at least:

  1. new/unassociated connection — very short timeout and no keepalive privilege;
  2. one-shot query connection — close after request/response;
  3. associated session connection — normal keepalive interval.

Cross-platform listening behaviour

QTcpServer chooses QHostAddress::Any when IPv6 is available and AnyIPv4 otherwise. ([GitHub]6)

That needs explicit testing on every supported OS because an IPv6 wildcard listener is not guaranteed to accept IPv4-mapped connections identically everywhere. In particular, verify:

  • Linux;
  • Windows;
  • macOS;
  • FreeBSD;
  • systems with IPv6 enabled but no useful IPv6 route;
  • --serverbindip or its equivalent;
  • an explicitly IPv4 bind address while IPv6 is available;
  • another process already occupying the TCP port but not the UDP port.

I noticed that the configured bind address is only applied when IsIPv6Available() is false. That looks suspicious. If the server is IPv6-capable and the operator supplied a particular bind address, Start() appears to retain QHostAddress::Any and ignore the requested address. There may be surrounding command-line semantics that justify this, but I would check it closely.

Also, UDP can start successfully while TCP fails because another service owns the TCP port. The resulting behaviour and user-visible warning should be documented: does the server continue as UDP-only, and does it avoid advertising TCP support?

Scope and real-time behaviour

The new TCP parsing and CVector copies do not appear to run in the audio callback itself, which is good. The source still contains TODO comments warning about copies and allocation in a real-time routine, however. ([GitHub]3)

Those comments should either be resolved or clarified before merging. In particular, confirm that TCP-delivered connected-client lists enter the same event-thread path as the former UDP protocol processing and cannot block the real-time networking/audio thread on:

  • TCP writes;
  • heap allocations;
  • signal delivery;
  • DNS or connection setup;
  • logging.

My provisional priority list

Potentially merge-blocking

  1. Validate how channel IDs are authenticated when attaching persistent TCP sessions.
  2. Check whether a configured bind IP is ignored when IPv6 is available.
  3. Add resource limits and a short unaffiliated-connection timeout.
  4. Exercise socket destruction and pending-connect races.
  5. Test dual-stack listener behaviour on all supported systems.

Strongly recommended

  1. Add parser/framing unit tests with arbitrarily segmented TCP input.
  2. Handle zero-byte read() returns defensively.
  3. Decide whether a dropped session TCP connection should reconnect.
  4. Test overlapping and stale fallback notifications.
  5. Remove or disable the remaining verbose debug messages.

Documentation

The new docs/TCP.md is substantial—215 lines—and the PR correctly identifies firewall documentation as necessary. The public-facing material should make especially clear that the same numeric port must be permitted for both UDP and TCP, and that failing to open TCP does not prevent ordinary Jamulus operation but may leave large lists vulnerable to loss. ([GitHub]1)

Overall, my first impression is favourable: this is a carefully considered compatibility-preserving solution to a real protocol limitation. The most important area is not the basic TCP framing, which looks sound, but the security and lifecycle semantics of turning an unauthenticated TCP connection into the persistent companion of an existing UDP channel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feature request Feature request needs documentation PRs requiring documentation changes or additions

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Support TCP for protocol messages

4 participants