feat: add AbortSignal support to request execution - #1766
feat: add AbortSignal support to request execution#1766arthurschreiber wants to merge 9 commits into
Conversation
Allow passing an `AbortSignal` to `Connection#execSql`, `#execSqlBatch`,
`#execute`, `#callProcedure`, `#prepare`, `#unprepare`, `#execBulkLoad`
and the transaction methods (`#beginTransaction`, `#commitTransaction`,
`#rollbackTransaction`, `#saveTransaction`) via a new trailing options
argument. The signal is scoped to that single execution of the request -
matching `fetch(url, { signal })` - so a `Request` that is executed
multiple times (e.g. the prepare/execute flow) can be given a fresh
signal per execution.
Aborting the signal cancels the request through the existing graceful
cancellation mechanism (terminating the request message with the
`IGNORE` bit, or sending an attention message), but the request
completes with the signal's abort reason instead of a generic `ECANCEL`
error - so a `TimeoutError` from `AbortSignal.timeout()` is
distinguishable from a manual cancellation. A non-`Error` abort reason
is wrapped in a `RequestError` with code `EABORT`. For a bulk load, the
signal covers both the `insert bulk` statement and the bulk load
message itself.
A signal that is already aborted when the request is executed fails the
request immediately, without sending anything to the server. The signal
and its `abort` listener are tracked on the connection alongside the
request and cancel timers, armed per execution and cleaned up at the
same points as those timers - so a long-lived signal shared across many
requests does not accumulate listeners. The connection remains usable
after an aborted request.
This provides a caller-controlled way to express total-time bounds,
deadlines, and linked cancellation (via `AbortSignal.timeout()` and
`AbortSignal.any()`), as discussed in #1765 - instead of introducing a
second driver-level timeout option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
ReviewWent through One correctness concern and a related coverage gap stood out: Potential bug: reusing a
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3225ebb720
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Don't start consuming the row iterable of a bulk load whose signal is already aborted - a resource-owning generator was previously left paused without finalization even though the bulk load had already failed with the abort reason. - Arm the abort listener before any connection state is mutated in `makeRequest`. The duck-typed signal validation matches Node core's `validateAbortSignal` and deliberately doesn't check for the listener methods, so an `addEventListener` call that throws now surfaces to the caller with the connection left untouched. - Ignore an abort that arrives after the request was already canceled (manually or by the request timeout): the earlier cancellation is the failure cause, and attaching a signal must not change the outcome of an earlier `request.cancel()` from `ECANCEL` to the abort reason. - Recognize `Error` abort reasons from other realms via `util.types.isNativeError`, so they are passed through unchanged instead of being wrapped in an `EABORT` error. - Only update the emulated transaction state (`transactionDepth`, `inTransaction`) of the TDS < 7.2 transaction methods when the statement succeeded. Previously any failure - including a request aborted before being sent - still mutated the counters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewI read through the A couple of smaller things worth a look: Potential bug: invalid
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e867340212
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1766 +/- ##
==========================================
+ Coverage 80.84% 80.90% +0.05%
==========================================
Files 90 90
Lines 4887 4922 +35
Branches 924 940 +16
==========================================
+ Hits 3951 3982 +31
- Misses 640 641 +1
- Partials 296 299 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Point the `Request#cancel` and `Connection#cancel` docs at the new per-execution `AbortSignal` support as the preferred cancellation mechanism, and document the `ECANCEL` completion of the imperative methods. `cancel` stays as the convenience for canceling a request without having set up a signal beforehand - no deprecation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reviewed the diff ( Code quality / simplificationIn the ```js Both of the first two branches do exactly the same thing ( This could be simplified (and made more robust against future changes that might set ```js which drops the now-redundant Other observations (no issues found)
Nice work overall — the doc comments (especially the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 84efe94797
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ne on insert failure - Validate `options.signal` at the top of `execBulkLoad`, before the bulk load is marked as started or the row stream is set up - an invalid signal previously started consuming the row iterable and set `executionStarted` before `makeRequest` threw. - Tear down the row pipeline (destroy the row stream and the `rowToPacketTransform`) when the `insert bulk` statement fails - for example when the signal aborts during that phase. An already-started row source previously kept producing rows until backpressure, or remained suspended without generator finalization, even though the bulk load had already completed with an error. - Document that a connection failure during an in-flight cancellation (e.g. an attention not acknowledged within `cancelTimeout`) completes the request with that connection error - the same hierarchy as every other cancellation source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewThis is a well-designed and carefully-iterated feature. The semantics (per-execution signal, "first failure cause wins", listener lifecycle tied to request completion, already-aborted fast path, cross-realm Bug: orphaned/mismatched doc comment (
|
The doc comment for `errorForAbortedSignal` was left stranded above `validateAbortSignal` when the latter was inserted between the comment and its function. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review: I did a close read of Overall This is a very well-executed feature. The design choices are sound and match precedent in comparable drivers (
Minor / nit-level observations (nothing blocking)
Test coverage The 18 tests in Security No concerns - this only affects client-side cancellation plumbing; no new user input is parsed or trusted differently. Nice work - the iterative commit history shows the trickier edge cases (row-pipeline teardown, listener-arming order, cross-realm errors, transaction bookkeeping) were already run down before this review. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0519998d1e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Add a test that negotiates TDS 7.1 and verifies that aborted `beginTransaction`/`commitTransaction` calls on the legacy SQL batch path leave `transactionDepth`/`inTransaction` untouched, while a successful `BEGIN TRAN` still updates them. Also correct the stale comment in `_onRequestAbort` describing how an earlier `ETIMEOUT` is preserved - since the `request.canceled` guard was added, that path returns early instead of relying on the `??=` assignment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
346f1d0 to
870d174
Compare
|
Follow-up on the review comments above (870d174): Actioned:
Declined, with reasons:
Generated by Claude Code |
ReviewI read through the full commit history (6 commits) and the final state of Code quality / correctness
BugsNothing that looks like an actual defect. I traced through the interaction between PerformanceNo concerns — the added work per execution is one duck-typed check and one SecurityNo concerns. This is pure control-flow/cancellation plumbing; no new user input reaches string interpolation, no new I/O surface, and validation of Test coverageThe 19 tests in Overall this looks ready to merge from a correctness/quality standpoint — the two notes above (the collapsible branch and |
Review:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 870d174b17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Record a server error before emitting the `errorMessage` event, so a listener that synchronously aborts the request's `AbortSignal` cannot cause the abort reason to win over a server error whose token arrived first. - Check `request.canceled` before the already-aborted signal in `makeRequest`'s prechecks: an earlier cancellation is the failure cause, and attaching a signal must not change the outcome of an already-canceled request. - Validate `options.signal` at the top of `prepare`, before the request is put into preparation mode - matching `execBulkLoad`, so an invalid signal doesn't leave a half-prepared request behind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd2d511224
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Validate `options.signal` at the top of `execSql`, `execute` and `callProcedure`, so an invalid signal throws the documented synchronous `TypeError` at every entry point - even when parameter validation would otherwise fail the request first with an asynchronous `EPARAM` completion. Matches the up-front validation in `prepare` and `execBulkLoad`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewRe-traced the current head (870d174) independently — the
No new correctness, security, or performance issues found. Test coverage (fake-TDS-server based, not mocked internals) is thorough — already-aborted signals, cross-realm/ponyfilled signals, precedence ordering in both directions, bulk-load teardown at each phase, and the legacy transaction guard are all exercised. The previously-raised nits (collapsing the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1122372fda
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
eb97d22 to
0e4e38e
Compare
Review:
|
ReviewI went through Correctness — looks solidI traced the tricky precedence/race scenarios and they all check out:
Minor suggestions (non-blocking)
Test coverageThe 17 new tests in SecurityNo concerns — this is purely client-side cancellation plumbing; no new user input parsing, no new external I/O, and the Overall: nice piece of work, small nits only. |
- Validate the signal up front in unprepare too, for consistency with the other execution methods. - Tighten the listener-arming comment in makeRequest: the `request.error` reset runs before the listener is armed. - Add a test that signals of sequential executions of the same Request stay isolated: aborting the first execution's signal after completion has no effect on a later execution bound to a different signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
03ee9aa to
f2a7bb9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f2a7bb9ee0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| this.clearRequestTimer(); | ||
|
|
||
| request.cancel(); | ||
| }; |
There was a problem hiding this comment.
Destroy the bulk row source when aborting the bulk message
When execBulkLoad is given an iterable and the signal aborts after the actual bulk-load message has started, this cancellation destroys the payload reader, which closes rowToPacketTransform, but it does not destroy the upstream rowStream. Node unpipes that source and leaves a backpressured generator paused without calling iterator.return(), so the bulk-load callback can complete while the row generator remains unfinalized and retains its resources. Tear down the row stream as well for this phase, as is already done when the preliminary insert bulk request fails.
Useful? React with 👍 / 👎.
Review: AbortSignal support for request executionI read through Code quality & design
Potential issues / things worth a second look
PerformanceNo concerns — one SecurityNothing concerning. Non- Test coverageExcellent — the 24 tests cover already-aborted signals (request/transaction/bulk load, including that the row generator is never consumed), abort ordering against server errors/timeouts/manual cancels in both pre-execution and in-flight positions, all three bulk-load phases, TDS < 7.2 legacy transaction bookkeeping, cross-realm signals/errors, and listener cleanup/isolation. This is thorough enough that I'd trust it to catch regressions in the cancellation machinery going forward, not just in the new signal path. Nice work — nothing here blocks merging from my read; the two items above are worth a comment/decision but not necessarily changes to this PR. |
Summary
Adds
AbortSignalsupport to request execution, as laid out in the plan on #1765 (part 2 of the sequencing there): a caller-controlled way to express total-time bounds (AbortSignal.timeout()), deadlines, and linked cancellation (AbortSignal.any()) — instead of introducing a second driver-level timeout option.All request-execution entry points accept a new trailing
options?: { signal?: AbortSignal }argument:execSql,execSqlBatch,execute,callProcedure,prepare,unprepareexecBulkLoad(the signal covers both theinsert bulkstatement and the bulk load message)beginTransaction,commitTransaction,rollbackTransaction,saveTransactionThe signal is scoped to that single execution — matching
fetch(url, { signal })— so aRequestthat is executed multiple times (the prepare/execute flow) gets a fresh signal per execution.Semantics
IGNOREbit, or sending an attention message). The connection remains usable afterwards, and the worst case is bounded bycancelTimeout(the existing backstop tears the connection down if the server never acknowledges the attention). Connection-fatal errors during the cancellation (socket failure,cancelTimeoutteardown,close()) supersede the request-level outcome, as they do for every other cancellation source.err === signal.reason), so aTimeoutErrorfromAbortSignal.timeout()is distinguishable from a manualECANCELcancellation. A non-Errorabort reason is wrapped in aRequestErrorwith codeEABORT;Errorreasons from other realms are recognized viautil.types.isNativeErrorand passed through unchanged.ETIMEOUT, or a manualrequest.cancel()before or during execution) takes precedence over the abort reason. Aborting also stops the request timer, so a slow attention acknowledgement can no longer replace the abort reason with a spuriousETIMEOUT.validateAbortSignal, so cross-realmAbortSignals and ponyfills are accepted; invalid values throw aTypeErrorsynchronously at every entry point, before any request or connection state is touched.abortlistener is armed per execution and removed on every completion path, so a long-lived signal shared across many requests does not accumulate listeners, and signals of sequential executions of the sameRequeststay isolated.Note for wrappers switching on
err.code: a default abort reason is aDOMException, whose legacycodeproperty is numeric — match abort outcomes onerr.name('AbortError','TimeoutError') or on the signal's state. This is called out in theexecSqlBatchtypedoc, which the other methods reference.Relationship to the existing cancellation API
request.cancel()andconnection.cancel()stay, undeprecated — they remain the imperative convenience for canceling a request without having set up a signal beforehand (the same coexistence asSqlCommand.Cancel()alongsideCancellationTokenin SqlClient, orStatement.cancel()alongside query timeouts in JDBC). The docs now recommendsignalfor new code. As part of the asyncmakeRequestrework (#1656/#1492), the internal layering should invert: cancellation becomes one internal per-requestAbortController(feedingwriteMessage'scancelSignalfrom #1756), withrequest.cancel(),requestTimeout, and the user's signal as its triggers.Implementation
The per-execution state lives on the
Connection, alongside its existing request-scoped state:requestAbortSignalmirrorsrequestTimer/cancelTimer, the pre-bound_onRequestAborthandler mirrors_cancelAfterRequestSent, andclearRequestAbortListener()is called at the same completion sites as the timer clears (cleanupConnection, the end-of-message completion, and the attention-ack delivery).RequestandBulkLoadare untouched apart from docs. This shape is also a rehearsal for the asyncmakeRequestrework, where the field collapses into atry/finallylocal.Testing
24 tests in
test/unit/request-abort-signal-test.ts, using the fake-server pattern from the cancel tests: already-aborted (request, transaction, bulk load — including that the row iterable is never consumed), non-Errorreason, cross-realmErrorreason, abort after send (attention exchange + stream alignment), abort during response streaming, abort from within theerrorMessageevent,AbortSignal.timeout()reason identity, abort/requestTimeout/manual-cancel precedence in all orderings (pre-execution and in-flight), abort during each bulk-load phase (with generator finalization asserted), the TDS < 7.2 transaction bookkeeping guards,close()with an armed listener, listener cleanup, signal isolation across sequential executions of the sameRequest, duck-typed signal acceptance, and synchronousTypeErrorvalidation at every entry point.npm test: 451 passing (427 on master + 24), 0 failing; repeat runs greennpx eslint src test+tsc: cleanIndependent behavior fixes bundled in this PR (release-note worthy on their own)
transactionDepth/inTransactionare now only updated when the transaction statement succeeded. Previously any failure on that path (ECLOSE,ECANCEL,EINVALIDSTATE, server errors) corrupted the client-side counters.insert bulkstatement fails — for any reason, not just an abort (e.g. the existing schema-mismatchUNKNOWNerror) — the row stream androwToPacketTransformare now destroyed, finalizing a suspended row generator instead of leaving it paused indefinitely. This was a pre-existing resource leak on master.errorMessageevent is emitted (token/handler.ts), closing a race where a listener reacting synchronously could observe/act before the error was recorded.Known trade-offs
transactionDepth/inTransactionbookkeeping out of sync with server state if the server already processed the request — the same exposurerequestTimeouthas on those requests today.insert bulkphase does not setbulkLoad.canceled(the bulk load still completes with the abort reason); an abort during the bulk-load message phase does.Request'scanceledflag, so the sameRequestobject cannot be re-executed — matching master's existing behavior for manual cancels and request timeouts; retry with a freshRequest.connect()is internally signal-based already, so a publicconnect(callback, { signal })would be a natural follow-up.Refs #1765
🤖 Generated with Claude Code