fix(queue) use-after-free in abort() path#118
Conversation
e278973 to
ad7667b
Compare
|
I tested successfully that it solves the issue seen when abort() is called but lwip events are in the queue. See PR ESP32Async/ESPAsyncWebServer#454 |
|
IIRC this was intentionally omitted to avoid a different crash case. I'll review my notes and get back to you tonight. |
Note: this is easy to reproduce with WebSocket but I think any call to abort() also in the HTTP code of espasyncws is susceptible to cause this crash. |
|
There is a bug here, but I don't think this is the correct fix. What I forgot was that the LwIP semantic for I tested this with the current AWS main using However, as you've discovered, there is a bug with this call sequence: The We can potentially improve safety for this sequence by calling I don't think it's possible to close that hole without coming up with a stronger interlock for the destructor (and the attendant performance cost of taking and releasing the lock to indicate client-object-in-use for every event being processed). I had looked through this before -- I'd thought of a few different implementations (reference counting? condvars?) but in the end it seemed that "just don't do that" was the least evil course of action. What do you think? |
I think we need to fix it as much as we can because this issue is pretty easy to reproduce in ESP32Async/ESPAsyncWebServer#454 with the WebSocket example: we just need to wait a little time / or get some torn frames. So I guess any other path were abort() is called is also susceptible to cause this crash. If I understand, the real issue is that an event is queued. If an abort() occurs, this is not an error: this is either an expected user interaction, or an expected library interaction following a wrong request / frame, leading to an RST. _error would be more suited for unexpected situations like network disruption. If abort is called, I would rather see the queue cleared, callbacks cleared, basically everything cleared ASAP to reclaim memory and avoid use after free. abort is a terminal operation. |
|
That's a major and breaking change in the API contract. Currently, anywhere abort() is called, the error and dispose callbacks are executed on the AsyncTCP thread. This patch removes that, so those callbacks are no longer run. |
|
The fix for ESP32Async/ESPAsyncWebServer#454 is to remove the explicit |
|
Sorry to keep spamming you..
The event that's being queued is the cleanup event! It's not caused by any external source. |
|
@willmmiles : why not taking another approach: the problem is that there is a use after free caused by the event happening after. So in that case why not completing this PR and call the
Aligning abort with close makes also sense because abort is a sort of abrupt close driven by a user call. => Test with AsyncTCP (main) with ESPAsyncWebServer (main) AsyncTCP ( Yes this is a change in behaviour, I agree, but to me it is more a fix than anything else. The fact that one action is calling 2 callbacks is a mistake because there is no way to determine from the error callback if the error is unexpected or not because it comes from a wanted user action (abort). |
f37b73a to
65f894b
Compare
Right - for context: #79, #80, #81, #82 Broadly, my thoughts are:
I'm OK with moving the callback execution to the I do think we should run the whole Lastly: there'll still be a race in the case where a user task calls |
I am OK with that if we account for the other libs and backward compatible reasons, indeed this is a bigger design change. I updated the PR to add the call to the error callback just before the disconnect: AsyncTCP (
|
The error callback should only be run if we actually executed the abort or consumed the error event (same as the dispose), so we can leverage the existing
Right, the previous iterations were manually calling the |
53743b2 to
69862ab
Compare
Yes I agree... The expectation form where the code is run is different. But we already have different sources for teh discard_cb: it could be run already from the async_tcp task (poll) or user code (close). So anyway currently the user could not rely on only 1 thread for discard_cb. The only change is for _error_cb, which, now, might be run from caller context. And I don't think this one is problematic. As an error handler, there are 80% chances that if implemented it has to deal with something out of scope of the async_tcp task in order to report the error or do something. So for such use cases, the code has better to be run from outside the async_tcp task anyway.
Updated. Thanks! This part was not clear to me...
Yes I have updated ESP32Async/ESPAsyncWebServer#454 and tested it with and without this PR. It runs fine now. |
If aborting in WebSocket (torm frame), we must also purge the queue and reset callbacks AsyncClient::_error(-13) execures with a LWIP_TCP_ERROR event still present in the queue but client was dereferenced before
Well, until this PR, one could guarantee that the discard would run on the AsyncTCP task ... if-and-only-if one never called I'm maybe overly sensitive to this kind of detail because too often I am guilty of writing code that leverages that kind of "guarantee", sadly. I'm reminded that I should review my request queue middleware WIP... I'd had the AI sketch it with an eye on moving WLED to the upstream AWS instead of our archaic fork, but haven't looked at it in some months. There were some tricky bits to the orchestration IIRC. Maybe I'll get to it in the fall... |
And Is there a reason why the close() cannot raise an event and have the discard callback called through the async task also for close ? For now there is no guarantee from where the discard cb is called. I understand also that such guarantee has some value… I would be opened (in fact I would prefer) if we could provide such guarantee that any callback is called from the async task, error, abort, close included. No exception when we call close. but right now there is no guarantee at all for the discard cb that can be called from anywhere already and this pr also applies the same rules for the error cb for abort. also the problem of relying on the queue is that it can introduce a delay and a potential loss of the event. Close and abort are linked to resource cleaning so they must be executed fast and their execution must be guaranteed I think. so if we have their event moved onto a queue to guarantee the execution of the callbacks from the async tcp task, then, how we can guarantee that they are executed with higher priority but also how we can guarantee that they can be queued… ? I don’t have the answers. |
I think you understand the problem well -- particularly for resource cleanup, there's a big tradeoff to be made for immediate vs deferred execution. I don't think there's a one-size-fits-all answer. From an API perspective, doing immediate teardown feels like the more natural semantic to me. That said, I think deferral should be allowed -- the usual approach is to provide a way to queue an arbitrary function for later execution on the task, so users could schedule those teardowns how they like (or any other work, really, that needs to share a task to avoid excessive locking). The best possible answer IMO is to abstract over the AsyncTCP handler task, giving users maximum possible control. Make the event queue an explicitly instantiable object, so client objects can potentially be bound to different tasks and serviced when the user likes. On each queue, support an API that allows both queuing an arbitrary user Still it's a bunch of work for a hard-to-predict reward -- I don't really know how wide the user base of this library is (AsyncWebServer notwithstanding). |
If aborting in WebSocket (torm frame), we must also purge the queue and reset callbacks
AsyncClient::_error(-13) execures with a LWIP_TCP_ERROR event still present in the queue but client was dereferenced before
Crash that was occurring:
First one seen:
Second one: