Skip to content

fix: settle vite middleware when the client aborts the request - #54

Merged
RomainLanz merged 4 commits into
6.xfrom
fix/abort-pending-request
Oct 21, 2026
Merged

RomainLanz merged 4 commits into
6.xfrom
fix/abort-pending-request

Conversation

@RomainLanz

@RomainLanz RomainLanz commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

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 the finish event of the Node response. When the client aborts, Node emits close but never finish. Two related races exist in the same logic:

  1. The client aborts before Vite calls its "not handled" callback. Once close settles the promise, the callback still calls next(), so the application handles a request that is abandoned and already considered finished.
  2. The response is already closed when the middleware runs. The close listener misses the event and finish never comes, so the promise stays pending forever (reproduced with /@vite/client).
  3. A Vite middleware calls res.destroy() and then next(). destroyed is set at once, but close comes later. The "not handled" callback must settle the promise itself, because it has already removed the close listener.
  4. 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) makes the application handle a request whose client is gone. This one already exists on 6.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 the handle() promise.
  • do not call next when the client aborts before vite passes the request: a Vite middleware calls next() only after the response closes. The client aborts after the server receives the request. The test checks that the app next is not called.
  • settle when a vite middleware destroys the response and passes the request: a Vite post-middleware (returned from configureServer) calls res.destroy() and then next(). The test checks that handle() settles within 1 second and that the app next is not called.
  • do not call next when a tcp reset destroys the socket before the response: a Vite post-middleware calls next() from the socket error event. A raw TCP client calls resetAndDestroy() after the server receives the request. The test checks that the app next is not called.
  • settle when the client is gone before the middleware runs: the server calls handle() for /@vite/client only after the client has aborted. The test waits at most 1 second for the handle() promise.

Fix

  • Resolve on close too. done() removes both listeners. In the "not handled" branch, both listeners are removed before next() runs, so a client abort during the AdonisJS handler does not settle the promise early.
  • Return early when response.response.destroyed is true, at entry and in the "not handled" callback. In the callback, also return when request.request.socket.destroyed is true (TCP reset), and settle with done() before returning, since close may not have been emitted yet.

For race 1, I chose to skip next() instead of keeping the promise pending until next() 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):

✖ settle when the client aborts the request before vite finishes (1s)
AssertionError: expected false to be true

With the first commit only (9b3cd41, resolve on close):

✖ do not call next when the client aborts before vite passes the request
AssertionError: expected true to be false
✖ settle when the client is gone before the middleware runs (1s)
AssertionError: expected false to be true

With the first two commits (f005264):

✖ settle when a vite middleware destroys the response and passes the request (1s)
AssertionError: expected false to be true

With the first three commits (26ccba5):

✖ do not call next when a tcp reset destroys the socket before the response
AssertionError: expected true to be false

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 typecheck passes.

Not covered

On early Node 24 releases (seen on 24.0.0), Node does not emit close on queued pipelined responses when their socket aborts. With three pipelined requests and a client reset, only the first handle() settles, and the other two stay pending. Node 24.22 and 26.10 emit close, and all three settle. This already happens on 6.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.

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.
@RomainLanz
RomainLanz merged commit 6473cf5 into 6.x Oct 21, 2026
10 checks passed
@RomainLanz
RomainLanz deleted the fix/abort-pending-request branch October 21, 2026 14:34
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.

1 participant