Repository navigation
ServerResponse emits finish before writableEnded is set, because the stub socket runs the write callback synchronously - #314
Merged
dougmoscrop merged 1 commit intoOct 13, 2026
Conversation
ConradHughes
marked this pull request as ready for review
October 13, 2026 11:57
Owner
|
Thanks, makes sense to me. While this a clear fix, I'm inclined to release this as semver-major in case of any accidental dependency on the broken behaviour. |
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.
The work here was pretty much all done by Claude Code (Fable 5.1), with prodding by me — I was seeing lots of failures-that-weren't-really-failures, and the root cause appears to be this tiny timing discrepancy, as documented below.
I've reproduced the bug and the before/after results myself on the stated versions and will answer follow-ups as best I can.
Environment
serverless-http 4.0.0 and master at
ab1c416; Node v24.21.0 on Linux; the AWS provider with an API Gateway v1 event, though the provider is not involved. Seen in production on Lambda with Express 5.2.1 and pino-http 11.0.0, reproduced below with no framework and no logger.Reproduction
The same handler on a real
http.Serverand underserverless-http, listening forfinishandcloseon the response:Expected
finishseeswritableEnded === true, as it does on a real server, and as Node's docs describe (writableEndedis "true afterresponse.end()has been called").Actual
Under serverless-http,
finishfires withwritableEndedstillfalse.writableFinishedis alreadytrueat that point, which is backwards: Node setswritableFinishedimmediately before emittingfinish, andwritableEndedis meant to have been true sinceend()was called.The consequence that found it: pino-http decides between its "request completed" and "request aborted" messages by reading
res.writableEndedat the first offinish/closeto fire (defaultSuccessfulRequestMessageProvider), so under serverless-http it labels successful responses "request aborted". Across a week of one production Lambda that was 2972 of 3269 request lines, every 304 and 404 and most 200s. The unanswered question at the end of #280 ("why pino logsrequest abortedinstead of completed?") is this bug; #286 fixed the double logging that issue was opened for and does not touch this.Mechanism
Node's
OutgoingMessage.prototype.end()hands the final write to the socket with the finish callback attached, and only then setsthis.finished, which is what thewritableEndedgetter returns (lib/_http_outgoing.jsat v24.21.0:this._send('', 'latin1', finishCallback)at line 1207,this.finished = trueat line 1221,outmsg.emit('finish')inonFinishat line 1140, the getter at line 886). That ordering is safe on a real socket becausesocket.write()never invokes its callback synchronously. The stub socket inlib/response.jsdoes:so
onFinishruns, andfinishis emitted, from insideend()before line 1221 has executed. Anything that readswritableEndedonfinishsees the pre-end()value.Fix
Deferring that callback one tick is the obvious half, but on its own it is not enough. With only
process.nextTick(cb)inresponse.js, the flag is right but the events move:lib/finish.jsresolves immediately whenitem.finishedis already set, whichend()now does beforefinishhas been emitted, socloseis emitted and the handler returns beforefinishfires:On Lambda a
finishlistener that runs after the handler has returned may not run at all before the environment is frozen, so that would be a regression for anyone logging onfinish(which #197 recommended).Making
finish()wait forwritableFinishedas well keeps the order intact, so that's what I include here, along with a test that fails before the patch and passes afterwards.Results at
ab1c416: unpatched, 115 passing and the new test failing withexpected [ [ 'finish', false ], … ] to deeply equal [ [ 'finish', true ], … ]; patched, 116 passing. The reproduction above then printsfinish: writableEnded=true writableFinished=trueand thenclose, beforehandler returned, and pino-http logs "request completed".item.writableFinished !== falserather than=== truekeeps the request side (IncomingMessage, which has nowritableFinished) on the existingcompletepath.Whether the wait in
finish.jsshould instead key onwritableFinishedalone for responses, or whether thecomplete/finishedshort-circuit is relied on by a framework that ends synchronously without ever emittingfinish, is I guess another question; this patch is the smallest change we could come up with that passes the existing suite.