Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/runtime/environment-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,11 +1361,22 @@ function defaultTangleSandboxCapabilities(options: {

function mergeAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
const controller = new AbortController()
const abort = () => controller.abort()
if (a.aborted || b.aborted) controller.abort()
// Forward the firing signal's reason. Dropping it here renamed every cascaded death to the
// generic "execution aborted", which is what made this class undiagnosable from a journal.
const reasonOf = (signal: AbortSignal): unknown => {
const reason = signal.reason
if (typeof reason === 'string' && reason.length > 0) return reason
// A bare `abort()` sets a DOMException carrying the platform placeholder message, which
// is no more diagnostic than the generic death — name the scope instead.
if (reason instanceof Error && reason.name !== 'AbortError' && reason.message.length > 0) {
return reason.message
}
return 'aborted by parent scope'
}
if (a.aborted || b.aborted) controller.abort(reasonOf(a.aborted ? a : b))
else {
a.addEventListener('abort', abort, { once: true })
b.addEventListener('abort', abort, { once: true })
a.addEventListener('abort', () => controller.abort(reasonOf(a)), { once: true })
b.addEventListener('abort', () => controller.abort(reasonOf(b)), { once: true })
}
return controller.signal
}
Expand Down
74 changes: 74 additions & 0 deletions src/runtime/supervise/abort-reason.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'

/**
* A cascaded abort must carry the upstream reason. When it does not, every downstream
* worker's `down` record reads "execution aborted" — the generic AbortError message the
* runtime emits when `signal.reason` is empty — and a whole class of child mortality
* becomes undiagnosable from the journal alone.
*
* These tests pin the CONTRACT at the level any linking helper must satisfy, using the same
* shape the runtime's `linkSignals` / `mergeAbortSignals` / sandbox cascade implement.
*/

/** The shape under test: link two signals so either firing aborts the result WITH its reason. */
function link(a: AbortSignal, b: AbortSignal): AbortSignal {
const reasonOf = (signal: AbortSignal): unknown => {
const reason = signal.reason
if (typeof reason === 'string' && reason.length > 0) return reason
if (reason instanceof Error && reason.name !== 'AbortError' && reason.message.length > 0) {
return reason.message
}
return 'aborted by parent scope'
}
const c = new AbortController()
if (a.aborted || b.aborted) c.abort(reasonOf(a.aborted ? a : b))
else {
a.addEventListener('abort', () => c.abort(reasonOf(a)), { once: true })
b.addEventListener('abort', () => c.abort(reasonOf(b)), { once: true })
}
return c.signal
}

describe('cascaded abort reasons', () => {
it('forwards a string reason from whichever signal fired', () => {
const parent = new AbortController()
const scope = new AbortController()
const linked = link(parent.signal, scope.signal)
parent.abort('root driver failed; child settle grace expired')
expect(linked.aborted).toBe(true)
expect(linked.reason).toBe('root driver failed; child settle grace expired')
})

it('forwards the reason when the OTHER signal fires', () => {
const parent = new AbortController()
const scope = new AbortController()
const linked = link(parent.signal, scope.signal)
scope.abort('budget exhausted')
expect(linked.reason).toBe('budget exhausted')
})

it('forwards a reason that was already set before linking', () => {
const parent = new AbortController()
parent.abort('executor torn down')
const linked = link(parent.signal, new AbortController().signal)
expect(linked.reason).toBe('executor torn down')
})

it('unwraps an Error reason to its message rather than dropping it', () => {
const parent = new AbortController()
parent.abort(new Error('bridge stream error: pi exit 1'))
const linked = link(parent.signal, new AbortController().signal)
expect(linked.reason).toBe('bridge stream error: pi exit 1')
})

it('names the fallback instead of leaving the reason empty', () => {
// A bare abort() sets a DOMException whose message is the platform placeholder
// ("This operation was aborted") — no more diagnostic than the generic death it
// replaces, so the helper must treat it as reasonless and name the scope instead.
const parent = new AbortController()
const linked = link(parent.signal, new AbortController().signal)
parent.abort()
expect(linked.reason).toBe('aborted by parent scope')
expect(String(linked.reason)).not.toBe('execution aborted')
})
})
44 changes: 31 additions & 13 deletions src/runtime/supervise/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ export const routerInlineExecutor: ExecutorFactory<unknown> = (spec, ctx) => {
return artifact
},
teardown(_grace): Promise<{ destroyed: boolean }> {
controller.abort()
controller.abort('executor torn down')
return Promise.resolve({ destroyed: true })
},
resultArtifact() {
Expand Down Expand Up @@ -1148,7 +1148,7 @@ export const routerToolsInlineExecutor: ExecutorFactory<unknown> = (spec, ctx) =
return artifact
},
teardown(_grace): Promise<{ destroyed: boolean }> {
controller.abort()
controller.abort('executor torn down')
return Promise.resolve({ destroyed: true })
},
resultArtifact() {
Expand Down Expand Up @@ -1429,11 +1429,14 @@ interface StreamSandboxArgs {

async function* streamSandboxLeaf(args: StreamSandboxArgs): AsyncIterable<UsageEvent> {
const linked = new AbortController()
const cascade = () => linked.abort()
if (args.signal.aborted || args.controller.signal.aborted) linked.abort()
else {
args.signal.addEventListener('abort', cascade, { once: true })
args.controller.signal.addEventListener('abort', cascade, { once: true })
// Stable listener identities: the finally block removes these by reference.
const cascadeExternal = (): void => linked.abort(abortReasonOf(args.signal))
const cascadeScope = (): void => linked.abort(abortReasonOf(args.controller.signal))
if (args.signal.aborted || args.controller.signal.aborted) {
linked.abort(abortReasonOf(args.signal.aborted ? args.signal : args.controller.signal))
} else {
args.signal.addEventListener('abort', cascadeExternal, { once: true })
args.controller.signal.addEventListener('abort', cascadeScope, { once: true })
}

const agentRun: AgentRunSpec<unknown> = {
Expand Down Expand Up @@ -1522,8 +1525,8 @@ async function* streamSandboxLeaf(args: StreamSandboxArgs): AsyncIterable<UsageE
yield { kind: 'cost', usd: result.costUsd, ...(usdKnown ? {} : { usdKnown: false }) }
}
} finally {
args.signal.removeEventListener('abort', cascade)
args.controller.signal.removeEventListener('abort', cascade)
args.signal.removeEventListener('abort', cascadeExternal)
args.controller.signal.removeEventListener('abort', cascadeScope)
}
}

Expand Down Expand Up @@ -4732,16 +4735,31 @@ function singleShotDriver<Out>(maxIterations: number): Driver<unknown, Out, stri
function linkSignals(a: AbortSignal, b: AbortSignal): AbortSignal | undefined {
if (a.aborted || b.aborted) {
const c = new AbortController()
c.abort()
c.abort(abortReasonOf(a.aborted ? a : b))
return c.signal
}
const c = new AbortController()
const onAbort = () => c.abort()
a.addEventListener('abort', onAbort, { once: true })
b.addEventListener('abort', onAbort, { once: true })
a.addEventListener('abort', () => c.abort(abortReasonOf(a)), { once: true })
b.addEventListener('abort', () => c.abort(abortReasonOf(b)), { once: true })
return c.signal
}

/** The reason a signal carries, or a named fallback. A cascade that drops the upstream reason
* turns every downstream death into the generic "execution aborted": the worker's `down`
* record then says nothing about WHY, which is what makes a whole class of child mortality
* undiagnosable from the journal alone. */
function abortReasonOf(signal: AbortSignal, fallback = 'aborted by parent scope'): unknown {
const reason = signal.reason
if (typeof reason === 'string' && reason.length > 0) return reason
// `controller.abort()` with no argument sets a DOMException whose message is the platform
// placeholder ("This operation was aborted"), which carries no more information than the
// generic death it replaces — treat it as reasonless and name the scope instead.
if (reason instanceof Error && reason.name !== 'AbortError' && reason.message.length > 0) {
return reason.message
}
return fallback
}

/** Combine N abort signals into one that fires when ANY does. Node-portable (no `AbortSignal.any`,
* which needs >=20.3 — the package floor is >=20). Module-exported (not package surface) so
* sibling leaf executors share the one portable implementation. */
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/supervise/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,10 @@ async function drainLiveChildren(
)
graceTimer.unref?.()
} else if (!controller.signal.aborted) {
controller.abort()
// Same event as the grace-timer branch above, so it carries the same named reason: one
// path stating why and the other going silent is what put identical deaths in two
// different diagnostic buckets.
controller.abort('root driver failed; no child settle grace configured')
}
try {
await drainCursor(scope)
Expand Down