fix(tasks): an aborted wait must not leave a settled task notice-suppressed (review finding)

Two windows where settle() could read a live waiter count, mark the
task reported (suppressing the completion notice), and then watch that
waiter reject with 'wait aborted' delivering nothing — leaving the
owning session with no terminal notification at all:

- abort and settlement in the same tick, settle continuation ordered
  first: the waiter now un-counts itself SYNCHRONOUSLY inside onAbort
  (the finally decrement alone lands a microtask too late), so the
  settle path sees no live waiter and the notice fires;
- abort landing after settlement but before the wait's resolve
  microtask: the wait now resolves and DELIVERS the terminal snapshot
  it owes instead of rejecting (settlement suppressed the notice on
  this waiter's behalf).

Both windows pinned by deterministic tests that fail on the previous
implementation; wait()'s abort contract updated in JSDoc/README/RFC.
This commit is contained in:
Yichen Jiang
2026-07-09 23:08:26 +08:00
parent 032510a85a
commit 43d60286e3
4 changed files with 74 additions and 7 deletions

View File

@@ -270,8 +270,12 @@ export class TaskService extends Service {
* terminal snapshot (marked {@link TaskSnapshot.reported} — the wait
* response reports the end, so the completion notice is suppressed), or
* with the still-live snapshot when the timeout expires first. An abort of
* `signal` rejects the WAIT only — the task keeps running. Throws for an
* unknown id, a task owned by another session, or a non-positive timeout.
* `signal` rejects the WAIT only (the task keeps running) — UNLESS the task
* has already settled: settlement saw this live waiter and suppressed the
* completion notice on its behalf, so the wait still resolves and delivers
* the terminal snapshot it owes (an abort must never leave a finished task
* both unreported and notice-suppressed). Throws for an unknown id, a task
* owned by another session, or a non-positive timeout.
* @param id - the task to wait for.
* @param timeoutMs - max wait in milliseconds (positive, finite; the surface caps it).
* @param caller - the waiting agent, checked against the task's owner.
@@ -286,7 +290,19 @@ export class TaskService extends Service {
}
if (!isTerminal(task.status)) {
if (signal?.aborted) throw new Error('wait aborted')
// `waiters` is the settle-path heuristic "someone WILL deliver the
// terminal snapshot, suppress the notice". An abort breaks that promise,
// so the un-count must happen SYNCHRONOUSLY inside onAbort — the
// `finally` decrement alone runs a microtask later, after a same-tick
// settlement could already have read the stale count and suppressed the
// notice for a waiter that then rejects and delivers nothing.
task.waiters += 1
let counted = true
const uncount = (): void => {
if (!counted) return
counted = false
task.waiters -= 1
}
try {
// The dsh-timeout deadline fits wait() exactly because both only
// NOTIFY: a wait timeout returns the live snapshot (the task keeps
@@ -296,8 +312,16 @@ export class TaskService extends Service {
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) resolve()
else reject(new Error('wait aborted'))
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement already ran and suppressed the notice for this
// waiter — deliver the terminal snapshot instead of rejecting.
resolve()
} else {
uncount()
reject(new Error('wait aborted'))
}
}
d.signal.addEventListener('abort', onAbort, { once: true })
void task.settled.then(() => {
@@ -306,7 +330,7 @@ export class TaskService extends Service {
})
})
} finally {
task.waiters -= 1
uncount()
}
}
if (isTerminal(task.status)) task.reported = true