http: add req.signal to IncomingMessage#62541
http: add req.signal to IncomingMessage#62541akshatsrivastava11 wants to merge 4 commits intonodejs:mainfrom
Conversation
|
Review requested:
|
|
@mcollina |
mcollina
left a comment
There was a problem hiding this comment.
Can you add a test how this would be used in http.request() response?
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #62541 +/- ##
==========================================
- Coverage 89.71% 89.66% -0.06%
==========================================
Files 695 693 -2
Lines 214154 213103 -1051
Branches 41009 40861 -148
==========================================
- Hits 192132 191082 -1050
- Misses 14075 14142 +67
+ Partials 7947 7879 -68
🚀 New features to boost your workflow:
|
|
@mcollina I've added a test for the http.request() response case in the latest commit. It creates a server that sends partial data and keeps the connection open, then destroys the client request mid-response and verifies that res.signal aborts. Let me know if that's what you had in mind or if anything needs adjusting. |
slagiewka
left a comment
There was a problem hiding this comment.
Great to see it happening! WHATWG Request wrappers will be much easier to write 👍
lib/_http_incoming.js
Outdated
| if (this[kAbortController] === undefined) { | ||
| const ac = new AbortController(); | ||
| this[kAbortController] = ac; | ||
| if (this.destroyed) { | ||
| ac.abort(); |
There was a problem hiding this comment.
question: if a user calls message.signal on an already destroyed resources, does it make sense to go via new AbortController() + abort route?
Would it make sense to short-circuit into creating aborted Signal without the controller via AbortSignal.abort()?
This would of course require the signal to be stored on the message's symbol props instead of the controller (or along with the controller?).
This might be premature optimisation for an edge case if this doesn't happen often, though.
node --allow-natives-syntax bench.mjs
AbortController + abort x 321,009 ops/sec (89 runs sampled) min..max=(2.56us...3.34us)
static abort x 355,118 ops/sec (93 runs sampled) min..max=(2.36us...2.84us)Both are extremely slow, but skipping the controller is slightly faster.
bench source:
import { Suite } from 'bench-node';
const suite = new Suite({ minSamples: 100 });
suite.add('AbortController + abort', () => {
const controller = new AbortController();
controller.abort();
});
suite.add('static abort', () => {
AbortSignal.abort();
});
suite.run();There was a problem hiding this comment.
The AbortSignal.abort() approach is slightly faster for the pre-destroyed case and avoids creating an unnecessary controller. I kept the current approach for simplicity ,but happy to optimize this in a follow-up if that's the preferred direction.
lib/_http_incoming.js
Outdated
| this.once('close', function() { | ||
| ac.abort(); | ||
| }); | ||
| this.once('abort', function() { |
There was a problem hiding this comment.
question: I'm not well versed in node.js internals, but when would this happen?
There's the deprecated "aborted" event which I assume the stdlib does not need to care about if using 'close'.
And AbortSignal does indeed use "abort" but I doubt that emitting abort on IncomingMessage is something that's expected. There's something similar on the http.ClientRequest but that's the client side aborting the outgoing request.
There was a problem hiding this comment.
+1, we should check & test to confirm, but my expectation would be that this is never emitted for incoming messages, that's certainly what our documentation says.
lib/_http_incoming.js
Outdated
| // Flag for when we decide that this message cannot possibly be | ||
| // read by the user, so there's no point continuing to handle it. | ||
| this._dumped = false; | ||
| this[kAbortController] = undefined; |
There was a problem hiding this comment.
nit: set to null instead to reflect object
There was a problem hiding this comment.
Thanks for the suggestion, updated in the latest commit
lib/_http_incoming.js
Outdated
| this.once('close', function() { | ||
| ac.abort(); | ||
| }); | ||
| this.once('abort', function() { |
There was a problem hiding this comment.
+1, we should check & test to confirm, but my expectation would be that this is never emitted for incoming messages, that's certainly what our documentation says.
| clientReq.on('error', () => {}); | ||
| clientReq.end(); | ||
| })); | ||
| } |
There was a problem hiding this comment.
I think the most important case here isn't covered: server receives a request & doesn't end the response (stays pending) => client aborts the request while it's incomplete (so the connection closes) => server should see req.signal.aborted == true. That's the primary end goal, allowing us to easily detect on the server side when the client cancelled the requests.
The two full-flow tests here (1 and 5) only cover serverRes.destroy() => serverReq.signal.aborted and clientReq.destroy() => clientRes.signal.aborted.
Looks like it should work, but we need to test to make sure we actually cover that integration.
There was a problem hiding this comment.
Thanks,I have added the test in the latest commit
…borted on client disconnect
Fixes: #62481
Summary
Adds a lazy
signalgetter toIncomingMessagethat returns anAbortSignalwhich aborts when the request is closed or aborted.This mirrors the Web Fetch API's
Request.signaland Deno'srequest.signal.Motivation
Currently, cancelling async work (DB queries, fetch calls) when a
client disconnects requires manual boilerplate in every request handler:
With this change:
Design
Lazy initialization —
AbortControlleris only created whenreq.signalis first accessed. Zero overhead for handlers thatdo not use it.
Single listener — listens on
this.once('close')andthis.once('abort')rather thansocket.once('close')directly,since the request stream's
closeevent fires in all teardown paths:_destroy()to reqclosereq.destroy()fires reqclosedirectlyabortdirectlyRace condition — if
.signalis accessed afterreq.destroy()has already fired,
this.destroyedis checked and the signal isaborted immediately rather than registering a listener that would
never fire.
configurable: true— allows frameworks (Express, Fastify, Koa)to override the property on their own subclasses.
Changes
lib/_http_incoming.js— addssignalgetter toIncomingMessagetest/parallel/test-http-request-signal.js— adds testsTests
req.signalis anAbortSignaland not aborted initially'abort'event fires'close'event fires (client disconnect)req.destroy()(race condition)Fixes: #62481