Skip to content

feat: experimental support for abort signals when executing requests and bulk loads - #1798

Open
arthurschreiber wants to merge 5 commits into
masterfrom
claude/abort-signal
Open

arthurschreiber wants to merge 5 commits into
masterfrom
claude/abort-signal

Conversation

@arthurschreiber

@arthurschreiber arthurschreiber commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Adds experimental overloads, with an ExecutionOptions argument that holds an AbortSignal, to the methods that execute a request or a bulk load:

connection.execSql(request, { signal: AbortSignal.timeout(5000) });
connection.execSqlBatch(request, { signal });
connection.callProcedure(request, { signal });
connection.prepare(request, { signal });
connection.execute(request, parameters, { signal });
connection.execBulkLoad(bulkLoad, rows, { signal });

Experimental

The new overloads and the ExecutionOptions type are marked @experimental in their JSDoc, so the API docs show them as experimental. They may change in a minor release, e.g. as #1797 (the pull-based response API) takes shape. The existing signatures are separate overloads with their docs unchanged, and stay stable.

Behavior

  • Aborting the signal cancels the execution, the same way request.cancel() / bulkLoad.cancel() does.
  • The execution then completes with an abort error, instead of the generic ECANCEL RequestError: a RequestError whose code is 'ABORT_ERR' and whose name is 'AbortError', with the signal's reason as its cause (e.g. a TimeoutError DOMException for AbortSignal.timeout()). This follows Node.js' own AbortError. Callers can tell an abort (ABORT_ERR) from cancel() (ECANCEL) and from a request timeout (ETIMEOUT), and the error is always an Error, whatever the reason is (abort(null), abort('x'), …).
  • The first cancellation decides the error. If the execution was canceled via request.cancel() or by a request timeout before the signal was aborted, it fails with ECANCEL or ETIMEOUT, not with the abort error.
  • A request whose signal is aborted already is not sent. Its callback is called asynchronously with the abort error. For a bulk load, neither the INSERT BULK statement nor any rows are sent. The first row is already requested when the bulk load starts, as before, and the rows are closed again.
  • The signal applies to a single execution. It is ignored once the execution completed, its listener is removed then, and it does not affect later executions of the same request (e.g. of a prepared statement).
  • A request that is rejected or fails to start is not affected by its signal. If the connection is not in the LoggedIn state, the request fails with EINVALIDSTATE (synchronously, as before), and a request whose parameters fail validation fails with that error. If preparing the request throws, e.g. for an unserializable parameter, the signal is not listened to at all. In particular, a signal that is aborted already does not cancel an execution of the same request that is still in progress.
  • Code that does not pass a signal behaves as before, with one fix: the request timer is no longer able to replace a cancellation's error. Before, if a request was canceled via request.cancel() and its request timeout expired before the server acknowledged the attention message, the request failed with ETIMEOUT instead of ECANCEL. Now the request timeout is ignored for a request that was canceled already, and the request fails with ECANCEL.

Implementation

src/execution-options.ts holds the ExecutionOptions type (exported from tedious) and abortOnSignal(). For one execution, abortOnSignal() listens to the signal and calls cancel() on the request or bulk load. It also wraps the execution's callback until it completes, to remove the listener and, if the signal canceled the execution, to replace the ECANCEL error with the abort error. For a signal that is aborted already, it calls cancel() right away: makeRequest does not send canceled requests, and completes them asynchronously.

For requests, abortOnSignal() is called in makeRequest, after the state check and after the request's payload was created, i.e. only once the request is certain to be executed and to call back. The five request methods pass the signal on to makeRequest. requestTimeout() returns early for a request that was canceled already.

In execBulkLoad, the signal is registered after the bulk load's own cancel listener, so a pre-aborted signal also cancels the INSERT BULK statement.

Each of the six methods has two overload signatures: the existing one, and an @experimental one that adds options. options is optional in the experimental overloads, so code that forwards an ExecutionOptions | undefined value still compiles.

This is the part of #1797 that is useful on its own. The argument positions match #1797, so they won't change when that lands.

Testing

test/integration/abort-signal-test.ts (12 tests):

  • execSql / execSqlBatch: abort during execution fails with the abort error, and the connection is usable right away; a pre-aborted signal does not send the request (checked via a side effect) and calls back asynchronously.
  • AbortSignal.timeout() fails with an abort error caused by its TimeoutError.
  • request.cancel() with an unaborted signal still fails with ECANCEL.
  • A signal aborted after completion affects neither the connection nor later executions of the same request.
  • callProcedure, and prepare + execute (a canceled execution of a prepared statement can be executed again).
  • execBulkLoad: abort while waiting for the next row fails with the abort error and inserts nothing; a pre-aborted signal sends nothing and closes the rows; a signal aborted after completion is ignored.

test/unit/abort-signal-test.ts (18 tests, no SQL Server needed):

  • abortOnSignal(): the abort error for an Error, a string, null, undefined, 0, false and '' as the reason; a pre-aborted signal cancels right away; cancel() before the abort keeps ECANCEL; errors other than ECANCEL (e.g. ETIMEOUT) are kept; the listener is removed and the callback restored on completion.
  • execSql / prepare that throw while preparing the request neither listen to the signal nor cancel the request for a pre-aborted signal; a request rejected with EINVALIDSTATE fails with that error, is not canceled, and its signal is not listened to.
  • Against a mock TDS server: a request that is aborted, and whose request timeout expires before the server acknowledges the attention message, fails with the abort error, not with ETIMEOUT.

I checked that the tests fail without the change: without the reason substitution, 9 of 12 integration tests fail; without the abort listener, 6 fail. Against the code before the fix: harden abort signal handling commit, 14 of the 18 unit tests fail, and the mock server test fails with ETIMEOUT without the requestTimeout() change alone. npm run lint and npm test pass. Before that commit, the integration suite passed locally against SQL Server 2022 (the new tests and bulk-load-test.ts also on TDS 7.1 and 7.2), except should not leave any dangling sockets after connection timeout, which fails the same way on master in my environment. The integration tests were updated for the abort error in that commit, but not yet run against SQL Server since. npm run docs shows the experimental badge on the six new overloads and on ExecutionOptions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
https://claude.ai/code/session_012J9pZFGtq7FuJxgVVmwrwW


Generated by Claude Code

`execSql`, `execSqlBatch`, `callProcedure`, `prepare`, `execute` and
`execBulkLoad` take an optional `ExecutionOptions` argument with an
abort `signal`. Aborting the signal cancels the execution, which then
completes with the signal's reason as its error, instead of a generic
`ECANCEL` error. A request whose signal is aborted already is not sent.

The signal applies to a single execution: it is ignored once the
execution completed, and does not affect later executions of the same
request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
Comment thread test/integration/abort-signal-test.ts Fixed
@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.86%. Comparing base (446c0f9) to head (d324264).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1798      +/-   ##
==========================================
+ Coverage   84.76%   84.86%   +0.10%     
==========================================
  Files          96       97       +1     
  Lines        4720     4745      +25     
  Branches      884      889       +5     
==========================================
+ Hits         4001     4027      +26     
  Misses        455      455              
+ Partials      264      263       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

arthurschreiber and others added 2 commits October 11, 2026 13:19
Resolve doc comment conflicts in src/connection.ts by taking master's
{@link} format and keeping the new @PARAM options lines, and convert
the remaining [[...]] links in src/execution-options.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7HqQexCdhYHrJmE6Svkys

Copy link
Copy Markdown
Collaborator Author

CI on 15b958e (the merge of master, doc comments only) has failures that look unrelated to this change:

  • SQL Server 2022 Windows / Node.js 26.x: SQL Server setup.exe exited with code 2233073689 while installing, before any test ran.
  • Azure SQL Server / Node.js 22.x (2 of the 4 jobs): in each, only the first five integration test files failed (abort-signal, binary-insert, browse-mode, bulk-load, connection), all timing out while connecting (Failed to connect to …:1433 in 15000ms). Every test after them passed, including the rest of the suite against the same server. These were the two Azure jobs that started first. The two that started a minute or more later passed, which looks like the Azure database was slow to respond after about 4 hours with no CI runs. The previous run on this branch (54f25f9) was fully green.
  • codecov/project: probably follows from the missing coverage uploads of the failed jobs.

No fix exists or is needed in this PR. I'm re-running the failed jobs once to confirm.


Generated by Claude Code

Split the methods that take `ExecutionOptions` into their existing
signature and a new overload with `options`, and mark that overload
and `ExecutionOptions` itself with `@experimental`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7HqQexCdhYHrJmE6Svkys
@arthurschreiber arthurschreiber changed the title feat: support abort signals when executing requests and bulk loads feat: experimental support for abort signals when executing requests and bulk loads Oct 11, 2026
Fail an execution that was canceled by its abort signal with a
`RequestError` whose `code` is `ABORT_ERR` and whose `name` is
`AbortError`, with the signal's reason as its `cause`, like Node.js' own
`AbortError`. Passing the reason on as the error broke callers for
reasons that are not errors: a falsy reason, e.g. `abort(null)`, made a
canceled execution look successful (and a canceled `prepare` emit
`prepared` without a handle).

Only replace the cancellation error if the signal canceled the
execution. If it was canceled via `cancel()` or by a request timeout
first, its error is kept.

Only listen to the signal in `makeRequest`, once the request is certain
to be executed and to call back. Before, a synchronous throw while
preparing the payload (e.g. from an unserializable parameter) left the
signal's listener and the callback wrapper in place, so they affected
the next execution of the request. A pre-aborted signal also canceled an
execution of the same request that was still in progress, before the
new one was rejected with `EINVALIDSTATE`.

Ignore a request timeout for a request that was canceled already. The
request timer kept running after a cancellation, and replaced the
cancellation's error with `ETIMEOUT` if it expired before the server
acknowledged the attention message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012J9pZFGtq7FuJxgVVmwrwW

This branch has not been deployed

No deployments
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.

3 participants