Repository navigation
fix: settle vite middleware when the client aborts the request - #54
Merged
Merged
Conversation
The middleware promise only resolved on the response "finish" event. When a client drops the connection while Vite is serving an asset, Node emits "close" but never "finish", so the request stayed in flight in AdonisJS forever and could block process shutdown. Resolve on "close" too, and remove both listeners once settled or before handing the request to the next middleware.
Two races remained after settling the middleware on "close": - The client can abort before Vite calls its "not handled" callback. The "close" listener had already resolved the promise, but the callback still called next(), so the application handled a request that was abandoned and considered finished. - The response can already be closed when the middleware runs. The "close" listener then missed the event and "finish" never came, so the promise stayed pending forever. Return early when the response is destroyed, both at entry and in the "not handled" callback. An abandoned request has nobody to answer, so skipping next() is simpler than keeping the promise pending until it completes.
A Vite middleware can call res.destroy() and then next(). "destroyed" is set right away, but "close" is emitted later. The "not handled" callback had already removed both listeners and returned without settling, so handle() stayed pending forever. Settle the promise in that guard instead of relying on "close".
On a TCP reset, Node destroys the request socket before it marks the response as destroyed. A Vite middleware that passes on the request in between, for example from the socket "error" event, made the application handle a request whose client was already gone. Also check the socket in the "not handled" guard.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When a client drops the connection while Vite serves an asset, the Vite middleware promise never settles, so the request stays in flight in AdonisJS forever. In Tenkai, a browser test suite left 12 asset requests pending this way, and they blocked process shutdown.
Cause
handle()resolves only on thefinishevent of the Node response. When the client aborts, Node emitsclosebut neverfinish. Two related races exist in the same logic:closesettles the promise, the callback still callsnext(), so the application handles a request that is abandoned and already considered finished.closelistener misses the event andfinishnever comes, so the promise stays pending forever (reproduced with/@vite/client).res.destroy()and thennext().destroyedis set at once, butclosecomes later. The "not handled" callback must settle the promise itself, because it has already removed thecloselistener.errorevent) makes the application handle a request whose client is gone. This one already exists on6.x.Reproduction
Each case has one test, plus one for the initial bug, in
tests/backend/middleware.spec.ts:settle when the client aborts the request before vite finishes: a Vite middleware writes the headers and never ends the response. The client destroys its response when the headers arrive. The test waits at most 1 second for thehandle()promise.do not call next when the client aborts before vite passes the request: a Vite middleware callsnext()only after the response closes. The client aborts after the server receives the request. The test checks that the appnextis not called.settle when a vite middleware destroys the response and passes the request: a Vite post-middleware (returned fromconfigureServer) callsres.destroy()and thennext(). The test checks thathandle()settles within 1 second and that the appnextis not called.do not call next when a tcp reset destroys the socket before the response: a Vite post-middleware callsnext()from the socketerrorevent. A raw TCP client callsresetAndDestroy()after the server receives the request. The test checks that the appnextis not called.settle when the client is gone before the middleware runs: the server callshandle()for/@vite/clientonly after the client has aborted. The test waits at most 1 second for thehandle()promise.Fix
closetoo.done()removes both listeners. In the "not handled" branch, both listeners are removed beforenext()runs, so a client abort during the AdonisJS handler does not settle the promise early.response.response.destroyedis true, at entry and in the "not handled" callback. In the callback, also return whenrequest.request.socket.destroyedis true (TCP reset), and settle withdone()before returning, sinceclosemay not have been emitted yet.For race 1, I chose to skip
next()instead of keeping the promise pending untilnext()completes. An abandoned request has no client to answer, so running the application chain only does useless work after the request is considered finished. Skipping it is one check and keeps "settled on close" as the only rule.Proof
Without the fix (tests only, on
6.x):With the first commit only (
9b3cd41, resolve onclose):With the first two commits (
f005264):With the first three commits (
26ccba5):With all four commits, all 12 middleware tests pass (5 runs in a row). Full suite (
npm test, including lint): 125 passed, 1 skipped.npm run typecheckpasses.Not covered
On early Node 24 releases (seen on 24.0.0), Node does not emit
closeon queued pipelined responses when their socket aborts. With three pipelined requests and a client reset, only the firsthandle()settles, and the other two stay pending. Node 24.22 and 26.10 emitclose, and all three settle. This already happens on6.x.We left it out on purpose: it needs both HTTP/1.1 pipelining (browsers do not pipeline) and an early Node 24 release, and most users run the latest Node 24 or 26. The workaround would add a third listener, on the request socket, only to work around a Node bug that later releases fix.