fix(ws): Prevent torn frames when add() fails after the frame header (closes #453)#454
fix(ws): Prevent torn frames when add() fails after the frame header (closes #453)#454inF1704 wants to merge 2 commits into
Conversation
|
I am approving the changes: they make sense to me, although not tested yet (I will be able to test this weekend).
@willmmiles @me-no-dev could you please have a look ? I am thinking that we could merge this fix sonner than later in main, but wait a week before releasing (I would like to test before). Having it merged sooner than later in main will allow people pointing to main to grab the fix and report any issue also. For this kind if fix that is harder to test, this is an advantage. Thanks! |
|
I don't think I'd qualify this approach as "fix" so much as a "stopgap". I'm familiar with the underlying problem; it was pretty easy to replicate on ESP8266. The key insight is that On a review basis: I don't think it's safe to call I've got an unfinished attempt at WebSocket cleanup in my workspace from back in January, but I never got around to finishing it -- in fact it was this very issue that was the point where I stopped. If we want to take a "failed send closes the connection" approach, the error state will need to be propagated upwards on the stack until it can be handled safely. This in practice means a function signature change: the various Upon review, I think we don't really have much choice but to adopt a real state machine to track partially sent messages. At the time I was working on it, I couldn't shake the feeling it was a lot of extra bookkeeping to handle an edge case. I don't think there's any safe way around it though -- in practice there is no way to validate in advance if an |
I never noticed we had this issue indeed!
I agree, but this is a major rework. This PR uses abort() and like you said abort() is already used so this does not change the effect (missed cleanup). So what about this plan:
Note: the |
|
@willmmiles could AsyncTCP simply be fixed as such to call the discard_cb on abort() ? that would be similar to what's done for error: void AsyncClient::_error(int8_t err) {
if (_error_cb) {
_error_cb(_error_cb_arg, this, err);
}
if (_discard_cb) {
_discard_cb(_discard_cb_arg, this);
}
}edit just saw that on ESP8266, abort() does not call the callback also... So fixing AsyncTCP is not a complete solution. |
|
@willmmiles : I ended up you way and I This is an untested draft PR just to discuss the idea, and also to again discuss the plan above: why not merging this and fix the abort() calls in another PR, since this is another issue. |
IIRC a lot of the WebRequest error paths used to call
Well it's a good thing it's already half finished then ... ;)
Yes. That's been the standard semantic since the beginning - I don't think we should change it.
Glad to see there there weren't too many Regarding websockets, I think it's mostly a matter of timeline. My process is still largely interrupt driven - I'm happy to dust off the WIP and finish it up over the next few days, but I can't commit to much faster than that. Let me know. |
|
@willmmiles : ok let's do that. I will be able to resume this week-end + next week. I will PR the non websocket part separately => this will be a quick merge. And you'll be able to advance in your refactoring. At that point, we will see whether we can merge your refactoring or if it is better to merge |
|
@inF1704 : I added a commit to correctly handle the abort() of the WS channel and call onDisconnect after => would you be able to test again ? @willmmiles : I have extracted the code related to the HTTP part in #458, which can be merged independently. @willmmiles : For the next step... Since a complete WebSocket refactoring is more exhausting in terms of testing, and would probably sit in main for a longer time before being released, what about rather getting this fix merged and released before ? What do you think ? |
|
I tested this PR and was able to trigger some torn fames and abort() calls by sending bursts. |
|
Thanks for picking this up! I can't run a dedicated test setup right now, but some field data that may help: we've been running the functional equivalent of this PR (your #455 draft ported into our vendored copy, incl. the #458 HTTP part) on ESP32-S3 in the field since July 15. No torn frames, no crashes, reconnects are clean. However, this is with stock AsyncTCP 3.4.10, i.e. without your #118 fix. So far the abort path simply hasn't been hit in normal operation (torn frames became rare for us after we reduced WS queue pressure app-side). I'll switch back to the registry versions and retest once this and #118 are released, and will report back if anything comes up. |
9c63590 to
a4eeccb
Compare
|
360d73b to
353cb4b
Compare
|
@willmmiles : I updated last commit to correctly abort the websocket connection. Only c->abort() is necessary, onDisconnect cb is called then client freed. I tested with AsyncTCP 3.4.10 and AsyncTCP (ESP32Async/AsyncTCP#118). Both does not crash so I think we can merge this PR until a later refactoring happens in the WS layer. @inF1704 also successfully tested... |
353cb4b to
f9a82bc
Compare
f9a82bc to
0e4cd08
Compare
| // flush a FIN, which we don't want on an allocation failure). | ||
| AsyncClient *c = _client; | ||
| if (c) { | ||
| c->abort(); // fires onDisconnect → _onDisconnect() + delete c |
There was a problem hiding this comment.
This will cause a use-after-free with AsyncTCP 3.5.0. The onDisconnect callback will be run here, so the AsyncClient object will be deallocated; when we return from _handleDataEvent to _onData, the this pointer is invalid; but _onData continues to loop attempting to process more frames. The error state needs to be propagated out so _onData knows to exit early too.
I fixed this in the other branch,
https://github.com/ESP32Async/ESPAsyncWebServer/pull/462/changes#diff-09cab7c8017f6e1b622bbeccebb83bbbd7191e272f10f4330b4f4a4137d875f8L732
| // (partial add() after header committed, or header add() / malloc failure). | ||
| // Distinct from 0, which means "busy, try again later". The caller must not | ||
| // roll back its send offset and must schedule a connection close. | ||
| constexpr size_t WS_FRAME_TORN = static_cast<size_t>(-1); |
There was a problem hiding this comment.
This name is incorrect: it's used in cases where there is no torn frame. Perhaps WS_SEND_FAILURE?
| // Header could not be fully committed; nothing usable is on the wire. | ||
| // Signal hard error so the caller closes via the safe onDisconnect path | ||
| // (abort() leaks: no onDisconnect fires on ESP32 or ESP8266). | ||
| return WS_FRAME_TORN; |
There was a problem hiding this comment.
This should return 0 if the add returned 0 -- there's no need to drop the connection if we didn't actually manage to queue anything.
|
|
||
| size_t sent = webSocketSendFrame(client, final, opCode, _mask, dPtr, toSend); | ||
| _status = WS_MSG_SENDING; | ||
| if (sent == WS_FRAME_TORN) { |
There was a problem hiding this comment.
This test should be performed prior to line 240.
| // inject a new frame header mid-payload and corrupt the stream. Mark the | ||
| // message errored so _runQueue schedules a safe connection close. | ||
| _status = WS_MSG_ERROR; | ||
| return 0; |
There was a problem hiding this comment.
Is there a reason not to simply return the failure code? Packing the value in the status byte only to read it back at the call site is a roundabout solution to a simple problem. Control frames already use that protocol (see line 438), so there's no reason for message frames to differ.
| if (sent == WS_FRAME_TORN) { | ||
| async_ws_log_e("[%s][%" PRIu32 "] TORN CTRL FRAME: scheduling abort", _server->url(), _clientId); | ||
| _abortPending = true; | ||
| _status = WS_DISCONNECTING; |
There was a problem hiding this comment.
Is it OK to set this status here? What happens if we receive frames after we set this but prior to the abort in the next _onPoll()? Do we risk confusing the disconnect state machine if we receive a disconnect request from the client?
|
As discussed in #462 with @willmmiles , the current code makes it hard to fix the torn frame issue by aborting without going through more code refactoring. So I will close this PR in favor of #462 which fixes #453 and refactors the code to be able to abort the WS connection correctly. |
Fixes #453
Makes WebSocket frames all-or-nothing in
webSocketSendFrame()as discussed in the issue:add()after the header was committed →client->abort()(RST isrecoverable, a torn frame stream is not)
client->send()is no longer treated as an error — the frame isalready queued in lwIP and flushes on the next poll/ack
Field-tested on ESP32-S3 (2 concurrent clients, large bursts): the
"Received start of new message but previous message is unfinished" /
1006 reconnect storms are gone; under extreme lwIP pressure connections
now reset cleanly and reconnect instead of corrupting.