Repository navigation
5.6.0: on cancel, 'done' is emitted before the query has ended, and also when nothing was cancelled #479
Description
Version: 5.6.0. The code involved is older, but #470 means it now runs for every query.
Platform:
- Seen in CI: Windows (GitHub
windows-2022andwindows-2025images), Node 22.23.3 and 24.21.0, SQL Server 2016, 2017, 2019 and 2022, SQL Server Native Client 11.0. - Points 2 and 3 below come from reading the 5.6.0 source only. I could not run msnodesqlv8 myself (macOS, no ODBC).
Thanks for #470 and #473. Being able to cancel an ordinary streaming query is a real improvement, and node-mssql's request.cancel() now actually stops a running query. I have three questions about the events a cancel produces. They came up while working out why node-mssql's CI went red on 5.6.0 (run 38225080240). We have already changed node-mssql to cope with the current behaviour (tediousjs/node-mssql#1917), so none of this is urgent for us. I'm reporting it so the event contract around cancel is settled, and other event-API consumers don't have to work it out the way we did.
1. 'done' arrives before the query ends, and the HY008 'error' comes after it (seen in CI)
When you cancel a running, non-polling query, the sequence is done → cancel callback → error HY008 [msnodesql] Operation canceled (more=false) → free.
node-mssql's test a cancelled stream emits done event cancels select * from streaming at row 10. It failed the same way in all 16 Node 22 and 24 jobs. In the Node 24 / SQL Server 2019 job, for example, 'done' was handled at 09:31:55.380Z and the HY008 followed about 3 ms later, after the test had already finished:
RequestError: [msnodesql] Operation canceled
at handleError (lib/msnodesqlv8/request.js:344:21) <- node-mssql's 'error' listener
at node_modules/msnodesqlv8/lib/reader.js:97:20
at Query.routeStatementError (node_modules/msnodesqlv8/lib/reader.js:87:19)
at Query.end (node_modules/msnodesqlv8/lib/reader.js:306:12)
at node_modules/msnodesqlv8/lib/reader.js:418:12
{ state: 'HY008', number: 0, class: 0, serverName: '', lineNumber: 0 }
The empty serverName and the 0 fields suggest this is the synthetic error from return_cancelled(), not one reported by the driver.
2. 'done' is emitted when the cancel callback gets an error (from code; plain JS, so it should be deterministic)
connection.cancelQuery emits 'done' before it passes on e, whatever e is. So when DriverMgr.cancel fails with cannot cancel query (empty queue|not found), the query gets another 'done'. One way to hit this is to cancel a query that has already finished.
On 5.5.0 a non-polling query never reached this code, because the guard sent back cancel only supported… instead. Polling queries already took this path. Upstream's own test cancel single query and cancel again goes through it; it checks the error but not the extra 'done'.
3. A cancel that the native statement refuses is reported as success (from code, not run; please check)
This is the one I'm least sure of, and the one that matters most if it's right.
OdbcStatementLegacy::Cancel()only callsSQLCancelHandlefor a non-polling statement inSTATEMENT_SUBMITTEDorSTATEMENT_READING. In any other state it pushes acancel only supported…error and returnsfalse.return_odbc_error()setsSTATEMENT_ERRORwhenever there is any diagnostic record, and that includes info records.try_execute_directcalls it onSQL_SUCCESS_WITH_INFO, andREADINGis only set once, straight afterSQLExecDirect.- So a query whose execute returns info has state
STATEMENT_ERRORwhile it streams. A leadingPRINTwould do it. Unless I've missed somewhere that resets the state, I'd also expect it after a fix: prints, errors and results after a failed statement in a batch were lost #475 carry-on error. - For that query:
- The native cancel is refused.
CancelWorker::OnOKcalls back(null, false), andforwardCancelonly treats an array as an error.- JS therefore sees success:
'done'is emitted and the callback getsnull. - Meanwhile the query keeps streaming rows and later emits its own
'done'.
- Because nothing is cancelled and nothing reports an error, I think this case is new in 5.6.0 in practice. On 5.5.0 the JS guard refused it first. On the paused path the statement is freed anyway, and polling always returns
true. - I haven't traced whether the
cancel only supported…record, whichCancel()writes to_errorsfrom the cancel thread, comes out later as an'error'.
Repro (msnodesqlv8 only)
// MSSQL_CS="Driver={ODBC Driver 18 for SQL Server};Server=...;UID=...;PWD=...;TrustServerCertificate=yes" node cancel-order.js
'use strict'
const sql = require('msnodesqlv8')
const { version } = require('msnodesqlv8/package.json')
const big = 'select top 5000 a.name from sys.all_objects a cross join sys.all_objects b'
function trace (q, log) {
q.on('error', (e, more) => log(`error ${e.sqlstate} ${e.message} more=${more}`))
q.on('done', () => log('done'))
q.on('free', () => log('free'))
}
// A / C: cancel a running, non-polling query at row 100
function runningCancel (conn, text) {
return new Promise(resolve => {
const out = []
let rows = 0
const q = conn.query(text)
q.on('row', () => {
if (++rows === 100) {
out.push('cancelQuery()')
q.cancelQuery(e => out.push(`cancel cb: ${e ? e.message : 'no error'}`))
}
})
trace(q, m => out.push(`${m} [rows=${rows}]`))
q.on('free', () => setTimeout(() => resolve(out), 200))
})
}
// B: cancel a query that has already finished
function lateCancel (conn) {
return new Promise(resolve => {
const out = []
const q = conn.query('select 1 as n')
trace(q, m => out.push(m))
q.on('free', () => setTimeout(() => {
out.push('cancelQuery()')
q.cancelQuery(e => {
out.push(`cancel cb: ${e ? e.message : 'no error'}`)
setTimeout(() => resolve(out), 200)
})
}, 50))
})
}
sql.open(process.env.MSSQL_CS, async (err, conn) => {
if (err) throw err
console.log(`msnodesqlv8 ${version} ${process.platform} node ${process.version}`)
console.log('A running cancel\n ' + (await runningCancel(conn, big)).join('\n '))
console.log('B late cancel\n ' + (await lateCancel(conn)).join('\n '))
console.log('C running cancel after a print\n ' + (await runningCancel(conn, `print 'start'; ${big}`)).join('\n '))
conn.close(() => {})
})Sequences
These are derived from the code. CI observed only the order of done and error in A. The row count at the cancel depends on batch timing.
| 5.5.0 | 5.6.0 (actual) | Suggested | |
|---|---|---|---|
| A running cancel | cancelQuery() → cb: cancel only supported… → done [rows=5000] → free |
cancelQuery() → done [rows≈100] → cb: no error → error HY008 … more=false → free |
cancelQuery() → cb: no error → error HY008 … more=false → done → free |
| B late cancel | done → free → cancelQuery() → cb: cancel only supported… |
done → free → cancelQuery() → done → cb: cannot cancel query (empty queue)… |
done → free → cancelQuery() → cb: cannot cancel query… |
C cancel after a PRINT |
as A on 5.5.0 | cancelQuery() → done [rows≈100] → cb: no error → rows continue → done [rows=5000] → free |
as A, or the cb gets an error and there is no early done |
With sql.PollingQuery(...), I'd expect 5.5.0 to already give the 5.6.0 order for A and B, because the 'done' emit has been there since c6b394a (2019, #120).
Cause
lib/connection.js:356-360and:366-369:notify.emit('done')runs in thedriverMgr.cancelcallback, beforecallback(e), whatevereis.- That callback fires as soon as
SQLCancelHandlereturns. - Since fix: segv cancelling a non polling query while rows are fetched #473, the worker thread ends the query afterwards:
return_cancelled(),odbc_statement_legacy.cpp:680-698.
- That callback fires as soon as
lib/reader.js:293-307:end()checks only the reader's ownthis.done, which the'done'fromcancelQuerynever sets. It then routes the HY008 as'error'.asCancelled()(:317) already checksnotify.getCancelSent(), so the reader does know a cancel was sent at this point.
lib/driver.js:48-58:forwardCancelonly treatserr && err.length > 0as an error.lib/driver.js:137-155: these are thecannot cancel query…errors that still lead to'done'.cpp/include/js/workers/cancel_worker.h:34-37:OnOKcalls back(null, success_), so a native refusal arrives as success.cpp/src/odbc/odbc_statement_legacy.cpp::341-377:Cancel(). The refusal branch writes_errorsfrom the cancel thread (:376).:656-671:return_odbc_error()setsSTATEMENT_ERRORfor any diagnostic.:1184-1185: the only transition toREADING.:1217-1220:SQL_SUCCESS_WITH_INFOat execute.
Suggested fix
These are only ideas; you know the lifecycle better than I do.
- Emit
'done'only for a cancel that worked. Inconnection.cancelQuery, emit it only wheneis null. - Report native refusals.
- Either
CancelWorker::OnOKpasses anErrorwhensuccess_is false, orforwardCancelhonours the boolean (and accepts a non-array error). - Separately, should a running statement in
STATEMENT_ERRORbecause of info-only diagnostics still be cancellable? One option is to havereturn_odbc_error()setSTATEMENT_ERRORonly when a record is not01xxx.
- Either
- Keep
'done'on cancel, but let it be last.- node-mssql asked for
'done'on cancel in done event not fired on cancelled queries #120, so we'd rather keep it. - For a query the reader will end itself (anything other than the paused-and-freed branch in
DriverMgr.drop), the'done'could come fromreader.end()after the HY008 is routed, whennotify.getCancelSent()is true. - A once-only guard at the notifier level would stop a query ever emitting two
'done's. - If you'd rather keep the current order, documenting "on cancel,
'error'may follow'done';'free'is always last" would also work for us.
- node-mssql asked for
- Add an event-order test for cancel, alongside the
events()helper inbatch-errors.test.js. Cover a running query, a streaming query, a paused query and a late cancel. Assert:- at most one
'done'; - only
'free'after'done'; - no
'done'when the cancel callback gets an error.
- at most one
Docs (small, related)
lib/index.d.tsis unchanged from 5.5.0, so it predates both this change and fix: prints, errors and results after a failed statement in a batch were lost #475:Query.onstill says'error'is a "critical error that caused the query to end" (:863) and that'done'means the "query is complete" (:869).ErrorEventCbis(err: Error) => void(:1005), with nomoreargument.
- The wiki's "Cancel A Query" section still says only polling queries can be cancelled.
The release notes and the api-tour already describe the new behaviour well. If the typings described it as well, TypeScript users would see it too.
Activity
dhensby commentedon Oct 13, 2026
I found two more things on the cancel path while reviewing tediousjs/node-mssql#1917. Both involve a paused query and the
isExecuting()check that #470 added toDriverMgr.drop(). Same caveat as before: this comes from reading the 5.6.0 source, which is unchanged on master at 1353ab0, and I couldn't run any of it. The numbering carries on from the issue.4. A paused polling query that is cancelled mid-stream never ends, and blocks the connection
isExecuting()countsSTATEMENT_POLLINGas executing (lib/driver.js:21,:103-106).try_execute_directtakes a polling statement fromSUBMITTEDtoREADINGtoPOLLING(odbc_statement_legacy.cpp:1175,:1184-1185,:1189-1190). A polling statement that succeeds stays inPOLLINGafterpoll_checkreturns. Only a diagnostic (STATEMENT_ERROR,:669) orClose()moves it on.drop()therefore un-parks the work item and cancels it as the active query, without freeing it (driver.js:111-117).Cancel()on a polling statement only sets_cancelRequested(odbc_statement_legacy.cpp:368-370). Nothing on the fetch path reads that flag:fetch_read(:226-262) only checks_cancelHandleIssued.Query.paused(reader.js:476) stays true, sodispatch()returns early (reader.js:392-395).What an event consumer sees:
doneand a cancel callback with no error, and then nothing else: noerrorand nofree.queue.js:31-37). A follow-upconn.query()orconn.close()never calls back.done, and a seconddonefollows them.On 5.5.x the same pause and cancel took the paused branch and freed the statement, so I think this is new in 5.6.0.
A non-polling query can reach this branch too, in a narrow window.
lastStateChangearrives through a thread-safe function, so JS can still seeSUBMITTEDafterSQLExecDirecthas returned.SQLCancelHandleon the idle statement then only closes the cursor (as the comment atodbc_statement_legacy.cpp:675-679says), and the parked reader never makes the fetch that would report it.Expected: the query ends with
free, and the follow-up prints[ { n: 1 } ].What I expect from the code on 5.6.0:
done, thencancel cb no error, and nothing after that. The follow-up never calls back.To check the branch, turn on
sql.logger.setLogLevel('DEBUG')andsql.logger.setConsoleLogging(true). You should then seedrop called for query id …, paused: true, executing: true(driver.js:110), followed byun-park executing query id … to cancel(driver.js:115). This assumes the polling fetches return no diagnostics. One would move the state toSTATEMENT_ERROR, and the old free branch would run instead.5. A cancel requested before the handle is known isn't caught by
isExecuting()cancelToken(notifier.js:173-178). The state-change callback fires it (:209-212) before it runsthis.lastStateChange = stateChange(:213).isExecuting()seeslastStateChange === nulland returns false (driver.js:104-105). A paused query then takes the branch that cancels and then callsfreeStatement(driver.js:118-126). That is the caseisExecuting()was added to prevent (see the comment atnotifier.js:63-65).:209-212and:213would not be enough on its own:CREATED→BINDING. The constructor setsCREATED(odbc_statement_legacy.cpp:203) before the notifier is attached (odbc_connection.cpp:351-353), andBINDING(:1136) is not inexecutingStates.SQLExecDirect.Cancel()runs while the native state is stillBINDING, the cancel is refused but reported as success (point 3).done, the cancel callback (with no error) andfreecan all arrive, in that order, before the statement has stopped executing.ConnectionHandles::checkinfrees the handle) while the worker is still using it. fix: cancel leaving the connection busy, pause ending queries early, nvarchar output params #470's description shows what that looks like in the native log:SafeHandle freed while still referenced.SUBMITTED, then as far as I can tell the reader's execute-error path runs when the worker returns. That would put the HY008errorafterfree, followed by a secondfree.This happens when the query is paused and cancelled before its first state change reaches JS, for example in the same tick as
query(). The reader parks itself insetQueryWorker()beforebegin()(notifier.js:154-160,driver.js:283-287).With DEBUG logging on, the
drop called for query id …line (driver.js:110) should showpaused: true, executing: false. 5.5.x also freed the statement on this path, so this isn't a regression; the new check just doesn't cover it.Suggested fix (ideas only)
READINGoncepoll_checkreturns (odbc_statement_legacy.cpp:1189-1191).POLLINGwould then mean "still executing", asSUBMITTEDdoes, and a paused polling query that is streaming would take the free branch again, as on 5.5.x.drop()'s executing branch, have the reader end rather than park oncenotify.getCancelSent()is true: route HY008 and free the statement._cancelRequested, so it would stream the remaining rows after the earlydone.asCancelled()already reports as HY008.lastStateChangebefore firing the held cancel, and count every state up to the end of execution as executing:null,BINDING,SUBMITTEDandPOLLING. That sends more cancels down point 4's branch, so the reader fix there matters for both.Merge pull request #487 from TimelordUK/fix/done-every-query
Merge pull request #488 from TimelordUK/fix/cancel-after-print
TimelordUK commentedon Oct 18, 2026
Fixed on master in #487, #488 and #490. It will ship in v6, which is not released yet. That is a major version because the event contract below changes behaviour that workarounds may depend on.
The contract in v6
donemeans no more results. It is raised on every query, exactly once, whatever happened: success, error, bind failure, cancel or refusal.freemeans the native statement has been released. It always comes afterdone.doneexceptfree.Cancel
cancel-cb ok→error HY008→done→free.What was wrong
donecame fromcancelQueryitself: before the HY008, and again on a late cancel (A, B).doneat all.Your repro is
test/contract/scripts/issue-479.js. Its transcript,corpus/scripts/issue-479.txt, and allcancel/*scenarios are now agreed and checked on every PR.There is one Linux caveat, unrelated to cancel: #483. After an error at execute, the rest of the batch is lost on Linux, so a cancel issued after such an error finds nothing to cancel there.