Skip to content

Fix: Control frame processing in the WebSocket client - #411

Open
Yves57 wants to merge 4 commits into
eclipse-threadx:devfrom
Yves57:ymarx/fix-websocket-ping
Open

Fix: Control frame processing in the WebSocket client#411
Yves57 wants to merge 4 commits into
eclipse-threadx:devfrom
Yves57:ymarx/fix-websocket-ping

Conversation

@Yves57

@Yves57 Yves57 commented Jul 29, 2026

Copy link
Copy Markdown

Several fixes:

  • If a control frame payload is empty (the most frequent case), _nx_websocket_client_packet_trim() (line ~1910) trims the whole packet (if there no other pending frame). And so _nx_websocket_client_data_process() was stopping with a status code NX_CONTINUE instead of continuing the processing. The fix has several effets:
    • If a CLOSE message is received, the callback set with nx_websocket_client_connection_status_callback_set() is called now, and NetXDuo returns the expected status code NX_WEBSOCKET_DISCONNECTED to the user code.
    • If a PING or a PONG message is received, we correctly jump to the switch/case.
  • If a PING message is received, according to RFC 6455 Section 5.5.2 / 5.5.3, we return a PONG message with the same payload. Fixes WebSocket: Client does not respond with Pong after receiving Ping #368 .
  • If a PONG message is received with an empty payload, it is not returned anymore to the end-user at the moment of a next message is received by the WebSocket stack.

PS: I think a remaining bug is not fixed yet. In the case of "interleaved" frames (for example a PING frame received in the middle of several fragmented data frames), the next data frames are not correctly processed.

Comment thread addons/websocket/nx_websocket_client.c Outdated
}

/* Release the mutex */
tx_mutex_put(&(client_ptr -> nx_websocket_client_mutex));

@Yves57 Yves57 Jul 29, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We release the mutex just because _nx_websocket_client_send() will unlock/lock it. Do we need to create a separate function _nx_websocket_client_send_internal() to avoid redundant lock/unlocks?
I'm not sure if it can lead or not a to race condition (like line ~2657) if a message is received exactly at this moment (necessarly from a different thread).

Comment thread addons/websocket/nx_websocket_client.c Outdated
ULONG packet_length;
NX_PACKET *data_packet;
UCHAR *data_ptr;
UCHAR ping_payload[NX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_MAXIMUM_LENGTH];

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

To be moved in the NX_WEBSOCKET_CLIENT struct?

@Yves57
Yves57 marked this pull request as ready for review July 30, 2026 19:42
Dispatching control frames for an empty payload reaches the frame switch with
*packet_ptr set to NX_NULL. _nx_websocket_client_packet_trim() returns
NX_NO_PACKET only when it has released the packet and cleared the pointer, so
removing the early return that used to stop short of the switch exposed three of
its four arms to a NULL dereference:

  - PING called _nx_packet_data_extract_offset(), whose first act is to read
    nx_packet_length through the supplied pointer;
  - PONG called _nx_websocket_client_packet_trim(), likewise;
  - the data arms read nx_packet_append_ptr, so a zero-length TEXT or BINARY
    frame faulted too, and RFC 6455 permits those.

Only CLOSE was safe, because it never touches the packet.

Skip the extract and the trim when the frame carries no payload, and return
NX_CONTINUE from the data arms when there is no packet to hand over, which is
what this function did for every empty frame before control frames were
dispatched.

Four further corrections in the same paths:

  - the PONG send moved into a helper that keeps the client mutex held for its
    duration. _nx_websocket_client_send() acquires the mutex itself and ThreadX
    mutexes are recursive, so the nested acquire is harmless; dropping the mutex
    instead opened a window in which another thread could run
    _nx_websocket_client_cleanup() and release the packet still being parsed;
  - every wait in that helper is NX_NO_WAIT. Allocating with NX_WAIT_FOREVER
    while holding the mutex deadlocks on an exhausted pool, because the threads
    that would free packets need the same mutex. A lost PONG costs a keepalive
    round trip, so resource pressure no longer tears the connection down;
  - the byte count from the payload extract is checked. That call reports a short
    copy through bytes_copied rather than through its status, so an unchecked
    result could echo uninitialised stack bytes to the peer;
  - NX_ASSERT no longer stands in for a status check. It expands to nothing in a
    default build, so the trim result was silently discarded, and when enabled it
    hangs the thread rather than reporting anything.

The payload buffer is scoped to the PING case rather than the function, and the
tab characters are replaced with spaces to match the file.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty frame is two bytes on the wire, so trimming its header consumes the
whole packet and the frame switch is entered with a NULL packet pointer. The test
sends an empty PING, PONG, data frame and CLOSE, each alone in its own TCP
segment so that case is actually reached, and checks that the PING is answered
with an empty PONG, that nothing is handed to the application as data, that the
CLOSE reaches the status callback and returns NX_WEBSOCKET_DISCONNECTED, that the
client mutex is released on every path, and that no packet is released twice.

Verified to discriminate: the test segfaults on the parent commit and passes with
the preceding fix.

Note that netx_websocket_one_packet_with_multi_frames_test currently fails on
this branch. It passes on dev. That is a separate regression, described in the
pull request discussion, and it is not addressed here.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fdesbiens
fdesbiens self-requested a review August 5, 2026 15:13
@fdesbiens fdesbiens self-assigned this Aug 5, 2026

@fdesbiens fdesbiens left a comment

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.

Thanks Yves — the analysis behind this is right, and #368 is a real bug worth fixing. Your central observation is correct: when a control frame has no payload, trimming the header consumes the whole packet, _nx_websocket_client_packet_trim() returns NX_NO_PACKET, and the old unconditional return(NX_CONTINUE) meant the frame switch was never reached. CLOSE never reached the status callback and PING was never answered.

I have pushed two commits to your branch. Please read the second half of this note before picking the work back up: the branch is currently red, and not only for the reason I fixed.

What I changed

82477bcGuarded the empty control frame paths in data_process

Removing that early return also removed the only thing keeping *packet_ptr == NX_NULL out of the switch. _nx_websocket_client_packet_trim() returns NX_NO_PACKET only when it has released the packet and cleared the pointer — that is the if (*packet_ptr) { ... return(NX_WEBSOCKET_SUCCESS); } followed by return(NX_NO_PACKET) at the end of it. Your if (*packet_ptr) guard around the unmask block shows you had this in mind, but it needed to extend into the switch, where three of the four arms dereference the pointer:

  • PING called _nx_packet_data_extract_offset(), whose first act is reading nx_packet_length through the pointer. It is the internal entry point, so there is no error-checking wrapper to catch the NULL.
  • PONG called _nx_websocket_client_packet_trim(), whose first statement is the same kind of read.
  • The data arms read nx_packet_append_ptr, so a zero-length TEXT or BINARY frame faulted too — and RFC 6455 permits those.

Only CLOSE survived, because it never touches the packet. That is almost certainly why the CLOSE behaviour you were chasing looked fixed while the rest was not.

A lone empty PING is exactly two bytes on the wire, so this is the ordinary keepalive shape rather than a corner case. It only appears intermittent because a following frame in the same segment leaves the pointer non-NULL and the fault does not occur.

Four other things in the same paths, in decreasing order of how much they mattered:

The PONG send now keeps the mutex. You released it around _nx_websocket_client_send(), which is correct in itself — that function acquires the mutex on entry, so it must be called unlocked. But by that point the trimmed packet had already been stored into nx_websocket_client_processing_packet, so during the window another thread calling nx_websocket_client_disconnect() could run _nx_websocket_client_cleanup() and release it. I moved the send into a helper that holds the mutex throughout instead: ThreadX mutexes are recursive, so _nx_websocket_client_send()'s nested acquire and release leave our ownership intact, and the window disappears rather than being narrowed.

Every wait in that helper is NX_NO_WAIT. Allocating with NX_WAIT_FOREVER while holding the client mutex deadlocks on an exhausted pool: the threads that would free packets need the same mutex. Note the other two allocation sites in the file pass the caller's wait_option rather than hardcoding a wait. Relatedly, failing to send a PONG no longer calls _nx_websocket_client_cleanup() — a lost PONG costs a keepalive round trip, which is not worth tearing down a working connection for.

bytes_copied is now checked. _nx_packet_data_extract_offset() reports a short copy through that parameter rather than through its status, so an unchecked result could echo uninitialised stack bytes from ping_payload to the peer.

NX_ASSERT no longer stands in for a status check. It expands to nothing in a default build (nx_api.h:169), so the trim result was being discarded; and when asserts are enabled, NX_ASSERT_FAIL is for (;;) { tx_thread_sleep(NX_WAIT_FOREVER); }, which hangs the thread rather than reporting anything. Either the condition cannot happen and the status should not be captured, or it can and needs handling — I went with handling it.

Also: the payload buffer is scoped to the PING case rather than the function, so the 125 bytes are not on the stack of every frame received; and the four tab characters are now spaces, since the file has none elsewhere.

e85cf6eAdded a regression test for empty WebSocket frames

test/regression/websocket_test/netx_websocket_empty_control_frame_test.c, registered in the netxduo regression CMake list. It sends an empty PING, PONG, data frame and CLOSE, each alone in its own TCP segment so the NULL case is genuinely reached, and checks the PING is answered with an empty PONG, that nothing is handed to the application as data, that the CLOSE reaches the callback and returns NX_WEBSOCKET_DISCONNECTED, that the mutex is released on every path, and that no packet is released twice.

I confirmed it discriminates rather than assuming it: on the parent commit it segfaults; with the fix it passes.

The part that is still open, and why the branch is red

netx_websocket_one_packet_with_multi_frames_test fails on this branch. It passes on dev. It failed before my commits and it still fails after them — my changes neither caused nor fixed it.

This is the case you flagged in your PR description: "in the case of interleaved frames (for example a PING frame received in the middle of several fragmented data frames), the next data frames are not correctly processed." I would put it more strongly than the description does. That is not a pre-existing limitation left unfixed — it is a regression this PR introduces, and an existing test already catches it, as a segfault rather than a failed assertion.

The test's server_response_frame_packet_1_3 is precisely that shape: the tail of a fragmented binary frame, then a PING with a two-byte payload, then a complete binary frame, all in one segment. The crash surfaces inside the test, at the point where it dereferences a packet after a non-zero status, but the cause is upstream: after the PING is handled, the frame that followed it is no longer delivered where the test expects it.

I instrumented both builds and compared the frame traces. The first ten frames are identical, PING and following binary frame included; dev then goes on to process four more. So the divergence is in sequencing after a control frame, not in the PING handling itself.

I stopped there rather than guessing. Getting it right means reworking how nx_websocket_client_frame_fragmented, nx_websocket_client_frame_opcode and nx_websocket_client_frame_header_found interact when a control frame arrives mid-fragmentation, and I would be inventing the intended semantics rather than restoring known-good behaviour. That is your call to make, and it is the work your PS was pointing at.

So: two of the three things in this PR are now done and covered by a test, and the third has gone from a note in the description to a failing test you can iterate against. If it would help to talk through the interleaving semantics before you change the state machine, I am happy to.

One practical note: I only added commits, so a plain git pull will bring them down — no reset needed.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants