Skip to content

Websocket incoming frame parsing fixes - #473

Open
willmmiles wants to merge 6 commits into
mainfrom
websocket-parsing
Open

willmmiles wants to merge 6 commits into
mainfrom
websocket-parsing

Conversation

@willmmiles

Copy link
Copy Markdown

Fix handling of incoming frame headers that span multiple packets, a corner case processing very large frames, and an edge case handling disconnect frames that are split between packets.

Filed as draft for these open concerns:

  • I am concerned about the performance of the state machine compared to the original implementation. It should be very robust but won't be anywhere near as fast. It does have the advantage of handling any and all tearing cases, though. (The verbose log message on every header byte is also maybe a bit much, but it was very helpful in validating correct behavior.)
  • Safari missing-mask-on-disconnect bug handling still needs testing. (I don't own any Apple devices.)
  • The very large frame handling fix still needs testing.
  • Torn close reason fix still needs testing.

@willmmiles willmmiles added the Type: Bug Something isn't working label Aug 29, 2026
@willmmiles

Copy link
Copy Markdown
Author

How do you folks feel about the state machine parser? Is the robustness worth the performance cost, or should we instead try buffering the header (spends more RAM and/or we're playing union tricks to overlay things in different parse states), or should we drop the connection on torn headers (safe but unfriendly)?

I don't have a good way to quantify the performance either, all of our benchmarks so far have focused on outgoing frames.

@mathieucarbou

Copy link
Copy Markdown
Member

How do you folks feel about the state machine parser? Is the robustness worth the performance cost, or should we instead try buffering the header (spends more RAM and/or we're playing union tricks to overlay things in different parse states), or should we drop the connection on torn headers (safe but unfriendly)?

I don't have a good way to quantify the performance either, all of our benchmarks so far have focused on outgoing frames.

That's a tricky question!
I would say, let's test that and if the perf drop si reasonable that's fine IMO and brings a lot of improvements and stability.
I definitely prefer a lower heap / stack size (or at least constant).
I know some people having heavy websocket usage but more on the sending side indeed. And we never really tested how fast we parse incoming frames.

I think we could test that with websocat.

@willmmiles

Copy link
Copy Markdown
Author

I'm working on some test cases for the fixes. Claude and I have found that the last fix (close reason handling) isn't really sufficient - we will have to defragment control frames for standards compliant operation. Stand by for more code.

@willmmiles

Copy link
Copy Markdown
Author

I read recently that modern AI is really, really good at finding all the bugs you ask it for. This is definitely turning out to be my experience here! I'm trying to validate the close-on-error semantics and it's turning in to a rabbit hole.

There's a pernicious corner case with AsyncTCP where, should a client wish to destruct the AsyncClient from the onData callback, it'll get itself in to trouble with the ack handling. Calling ackLater() is no help: the object holding _ack_pcb might have been destructed at the point where it would be read back, so it causes a use-after-free adding bytes to _rx_ack_len. Not calling ackLater() still causes a problem where _pcb is used after free.

... and there's more: in most cases where we close as a result of the client asking (like AsyncWebSocket), we must ack the bytes read before closing. Otherwise the TCP stack generates a RST-close instead of a FIN-close, as is required by the TCP protocol, to indicate that the remote client did not in fact consume all bytes. Even if it was safe to call close(), AsyncTCP doesn't provide a way for us to ack a packet prior to the onData() returning, so there's no legal way to indicate how many bytes were accepted so we can generate the correct close. :(

I'm going to think about this one a bit -- wanted to share where it's at, though.

@mathieucarbou

Copy link
Copy Markdown
Member

I'm going to think about this one a bit -- wanted to share where it's at, though.

it's like we need a "deferred" close ?

@willmmiles

Copy link
Copy Markdown
Author

I'm going to think about this one a bit -- wanted to share where it's at, though.

it's like we need a "deferred" close ?

Yup, that's one approach. Basically the solution space breaks down to:

  • Formally guarantee close() in callbacks is safe, and fully specify the ack semantics if that's done in a recv callback
    • Implementing this will require orchestrating some memory that has a different life cycle than the client object itself
  • A deferred close API, which can put the AsyncClient in a "close pending" state where it'll behave as if closed, but the LwIP connection isn't torn down until the callback completes.
    • Lots and lots of bookkeeping and checking in the AsyncTCP layer
  • Changing the onData callback API to allow a return value that says "please close the connection for me"
  • A full on object life API redesign without bare pointers everywhere (ideal in many ways but very very breaking).

Lots to think about.

Use a state machine to process headers byte-by-byte so we can handle
partial reception at any point.
If a control frame spans multiple TCP packets, buffer the data so that
the frame can be processed once fully received. This ensures that the
frame can be correctly handled instead of generating invalid PONG
responses or overrunning the buffer with a disconnect reason.
@willmmiles

Copy link
Copy Markdown
Author
  • Formally guarantee close() in callbacks is safe, and fully specify the ack semantics if that's done in a recv callback
    • Implementing this will require orchestrating some memory that has a different life cycle than the client object itself

ESP32Async/AsyncTCP#124 implents this solution - it adds that guarantee that it's safe to close() in any callback. (The callback std::function objects themselves are even held in scope so we don't hit any UB.) After testing a couple of different options, it ultimately wasn't that difficult to implement and seemed like the best approach. (Of course I found more bugs there along the way.. but that's another story.)

I've pushed one more commit here that reorders things so it's "as safe as possible" with older AsyncTCP. This is as good as it'll get, I think.

@macdylan

macdylan commented Sep 20, 2026 •

Copy link
Copy Markdown

Reporting back with the real-Safari disconnect testing you flagged as untestable — done against websocket-parsing @ ddf5ce9 (ESP32-C3 bench, AsyncTCP 3.5.0), serial diagnostics captured throughout.

Three disconnect paths from Safari (macOS, same machine the WS server sees):

Path Server-side behavior
tab close no immediate close frame from Safari; server reaped the dead connection via pull-idle (30.2 s) at the next connection event, clean close (id=1, pull idle=30207ms) diagnostic, no zombie left behind
Wi-Fi off ~10 s → back on new connection on reconnect, stale one reaped at that moment (pull idle=10147ms), page data resumed
lock screen / background → wake the WS connection survived — same client id kept pulling telemetry after wake, no reconnect needed

Across the whole session: zero panics, zero reboots, zero error lines, no dropped-frame accounting anomalies, heartbeat cadence continuous. Heap floor during the Safari load + reconnect burst was ~7.9 KB with all responses completing.

Honest caveat: this is black-box validation — I did not capture the wire bytes, so I cannot prove the specific "missing mask on close frame" Safari quirk actually fired; what I can say is that repeated real-Safari disconnects (all three styles, several rounds) never upset the parser. If you want wire-level evidence of that exact frame shape, I can arrange a capture.

Combined with the earlier torn-frame injection suite (64 injections across header/mask/payload/close tears + fuzz, all clean, connection survives torn headers and keeps answering — reported in #481), our side has nothing blocking this PR.

@willmmiles

Copy link
Copy Markdown
Author

Honest caveat: this is black-box validation — I did not capture the wire bytes, so I cannot prove the specific "missing mask on close frame" Safari quirk actually fired; what I can say is that repeated real-Safari disconnects (all three styles, several rounds) never upset the parser. If you want wire-level evidence of that exact frame shape, I can arrange a capture.

Thanks, that's still very helpful. As part of the patch development process my harness and I ended up building a byte-by-byte socket test sequence for validating that the code works as designed, but I don't have any Apple devices on hand to see what any particular version of Safari actually sends. Thanks for giving it a try and providing feedback.

@mathieucarbou

Copy link
Copy Markdown
Member

ESP32Async/AsyncTCP#124 implents this solution

@willmmiles : is it better to first complete, review and release the AsyncTCP PR ?

@mathieucarbou mathieucarbou 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.

I did some manual tests and reviewed (helped with AI for spec correctness). I create 3 little comments below.

Thanks 👍

✅ RFC 6455 compliance analysis

Spec reference Requirement PR behavior
§5.2 Base framing: FIN, opcode, mask bit, 7/16/64-bit payload length State machine parses all header forms byte-at-a-time; length assembly correct for all three forms
§5.4 Fragmentation: FIN + continuation opcode 0 message_opcode preserved across fragments; final/num tracking correct
§5.5 Control frames MUST be ≤ 125 bytes New validation in Length state → close with 1002 ✅
§5.5 Reserved control opcodes 0xB–0xF Unknown control opcodes → close with 1002 ✅
§5.5.1 Close frame: 2-byte status code (network order) + UTF-8 reason data[0]<<8 + data[1] ✅; strnlen(reason, datalen-2) fixes old strlen over-read ✅
§5.5.1 Server MUST echo close frame in response _queueControl(WS_DISCONNECT, data, datalen) echoes received payload ✅
§5.5.1 If both sides sent close → close TCP connection _status == WS_DISCONNECTING → _client->close() ✅
§5.5.2 PONG must echo PING payload _queueControl(WS_PONG, data, datalen) ✅
§5.4 Control frames MAY be interleaved in fragmented messages Interleaved control frames don't clobber message_opcode ✅
§7.4.1 Status codes 1000–1011 New AwsCloseCode enum matches; server uses 1002 (protocol error) and 1011 (internal error) correctly ✅
§7.1.7 Fail connection on protocol violation All violations close with 1002 ✅

⚠️ Not validated (pre-existing gaps, same as old code — optional follow-up hardening)

Spec reference Requirement Status
§5.2 RSV1–3 bits MUST be 0 (no extensions negotiated) Not checked — lenient
§5.2 Reserved data opcodes 0x3–0x7 Not checked — silently treated as data
§5.5 Control frames MUST NOT be fragmented (FIN=1) Not checked
§5.2 Minimal-length encoding (e.g. 16-bit form for len < 126) Not checked
§5.1 Client→server frames MUST be masked Not enforced — server accepts unmasked frames (lenient, common for embedded servers)
§7.4.1 1005/1006 MUST NOT appear on wire Received codes not validated (passed to app as-is)

Comment thread src/AsyncWebSocket.cpp
_pinfo.message_opcode = _pinfo.opcode;
}
// init frame number to 0 if only 1 frame or if this is the first frame of a fragmented message
if (_pinfo.final || datalen < _pinfo.len) {

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.

Suggested change
if (_pinfo.final || datalen < _pinfo.len) {
// note: only for data frames; an interleaved control frame (ping/pong/close) must not reset the
// fragment counter of an in-progress fragmented message
if (_pinfo.opcode < WS_DISCONNECT && (_pinfo.final || datalen < _pinfo.len)) {

_pinfo.num is the frame counter within a fragmented message, exposed to applications via AwsFrameInfo in WS_EVT_DATA events. The examples use it to distinguish "MSG START" (num == 0) from subsequent frames (examples/arduino/WebSocket/WebSocket.ino:195).

The problem: this block runs for every frame whose index == 0, including control frames (PING/PONG/close). Control frames always have final == true. So if a PING arrives between fragment 1 and fragment 2 of a fragmented text message, app sees num=0 on the continuation frame and may interpret it as a new message start.

Note: this is a pre-existing bug.

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.

Hm, I'm not sure this is quite right either - I think the code is confusing a fragmented message with a fragmented frame. Clearing based on datalen is probably also wrong because a frame fragmented between two packets in the middle will reset the counter for a message.

I'll have to think about this and come back to it.

@mathieucarbou mathieucarbou Oct 10, 2026 •

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.

The fix is really about fragmented messages only (not paquets) so when num increases and a control frame arrives in between.

This is an edge case because WS message frames can be huge in size so on a MCU we barely can have the use case of a message split into frames: it could happen though for a sort of streaming application i guess ?

Comment thread src/AsyncWebSocket.cpp
}
}
if (_status == WS_DISCONNECTING) {
_status = WS_DISCONNECTED;

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.

Suggested change
_status = WS_DISCONNECTED;
_status = WS_DISCONNECTED;
_pstate = AwsParseState::Error; // terminal state: ignore any further data before the disconnect completes

Parser enters terminal state; any trailing TCP data before disconnect completes is silently ignored instead of re-firing WS_EVT_ERROR

Comment thread src/AsyncWebSocket.cpp
return; // our object is now destroyed, so we must return immediately to avoid accessing any member
return false; // our object is now destroyed, so we must return immediately to avoid accessing any member
} else {
_status = WS_DISCONNECTING;

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.

Suggested change
_status = WS_DISCONNECTING;
_status = WS_DISCONNECTING;
_pstate = AwsParseState::Error; // terminal state: ignore any further data before the disconnect completes

Parser enters terminal state; any trailing TCP data before disconnect completes is silently ignored instead of re-firing WS_EVT_ERROR

@mathieucarbou

Copy link
Copy Markdown
Member

Hi @willmmiles!
FYI I merged the other PR (SSE) and merged main into this branch.
I've put 3 comments and then we can merge this PR once they are resolved.
Thanks :-)

@willmmiles

Copy link
Copy Markdown
Author

Hi @willmmiles! FYI I merged the other PR (SSE) and merged main into this branch. I've put 3 comments and then we can merge this PR once they are resolved. Thanks :-)

Thanks! I'll get to it a soon as I can - sorry I'm a bit spread thin at the moment.

@mathieucarbou

Copy link
Copy Markdown
Member

Hi @willmmiles! FYI I merged the other PR (SSE) and merged main into this branch. I've put 3 comments and then we can merge this PR once they are resolved. Thanks :-)

Thanks! I'll get to it a soon as I can - sorry I'm a bit spread thin at the moment.

No worry!

This branch has not been deployed

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

Labels

Status: Pending Merge Type: Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants