Abort
Composition covers the function shape, next() semantics, and where to open a gate. Throws covers throws and the cleanup contract. This page is the abort companion: how to raise an abort from a middleware, how to react to one already in flight, and how to classify abort traffic from outside the runner. Three perspectives, one mechanism.
Abort never emits error
ctx.abort(reason), an external AbortSignal fire, or a stage throwing a platform AbortError — none of them ever land on the error bus, no matter where in the turn they happen. The classification channel for abort is dispatchEnd.status === 'aborted' during dispatch, or turnEnd plus the absence of dispatchStart when input aborts before dispatch. Alert only on error and every cancelled turn is invisible.
From inside a middleware: how to raise an abort
ctx.abort(reason) is how a middleware signals refusal. Three things happen the instant you call it:
- The aborting middleware finishes its own body. Whatever code runs after the
ctx.abort()call in this function still runs — return early or do final cleanup inline, your choice. If youreturnwithout ever callingawait next(), there is no post-step to run on the unwind: a post-step is code positioned afternext()resolves, and you skipped that call. Put any cleanup inline before thereturn. - Every middleware after this point in the same pipeline is skipped — the runner's wrapper checks
ctx.abortedbefore invoking a downstream body and short-circuits if set. - The next major stage of the turn is skipped (see the scope table below).
const policyMiddleware = async (ctx, next) => {
if (!(await policy.allows(ctx.identity))) {
// Refusal is a decision. Spell it out — a bare `return` here would emit
// E_PIPELINE_SHORT_CIRCUITED and light up your error alerting.
ctx.abort(new Error('policy denied: identity not authorised'))
return
}
await next()
}Do not await next() after ctx.abort()
await next() after ctx.abort() is harmless but pointless. The wrapper around every downstream middleware reads ctx.aborted and skips the body — calling next() just walks the pipeline to its terminal resolver invoking nothing. If you have cleanup that has to happen on the abort path, do it inline before you return (the function body keeps running on the aborting middleware). A middleware that aborts without ever calling await next() has no post-step at all — there is no point on the timeline after next() resolves, because you never awaited it. Cleanup that lives in an upstream middleware's post-step is fine — those upstream await next() calls resolve normally and their post-steps still run. Refuse, do your inline cleanup, then return.
The reason is preserved on ctx.abortSignal.reason while the turn is in scope — useful from inside any middleware or tool handler that wants to inspect it. It is not carried on dispatchEnd (the event has no signal / reason field — only status, error, iterations, and timing). Observers that want to log why a turn aborted should capture the reason via ctx.abortSignal.reason from inside the pipeline (write it into ctx.stash or your own telemetry sink) before the context goes out of scope. Any error-shaped object works as reason; supply a message you would want to read in a log. That is the whole bar.
What "skipped" means depends on which pipeline raised the abort:
- Turn pipelines (
turnInputPipeline,turnOutputPipeline) — abort skips the remaining middlewares in that pipeline, and the runner skips every subsequent major stage of the turn (dispatch and/or output). - Dispatch pipelines (
dispatchInputPipeline,dispatchOutputPipeline) — abort skips the remaining middlewares in that pipeline for the current iteration, the iteration does not start a new one, and the dispatch exits withdispatchEnd.status === 'aborted'.
Abort is scoped to the turn that raised it. It does not cancel other turns running concurrently on the same runner — each runner.run() call is its own promise chain (see Composition → Where to open a gate).
From inside a middleware: how to react to an abort already in flight
ctx.abortSignal is a standard AbortSignal. Hand it to anything that accepts one — an in-flight fetch, a custom poll, a streaming client — and that work cancels the instant the turn is aborted from elsewhere.
const policyMiddleware = async (ctx, next) => {
// Pass the signal through. If the turn aborts mid-flight, the fetch rejects
// with a platform AbortError and your middleware unwinds normally.
const decision = await fetch('https://policy.internal/check', { signal: ctx.abortSignal })
// ... use the result, then hand off.
await next()
}This is the intra-middleware channel — for code already mid-await when the abort lands. The runner's inter-middleware skip (the next middleware in the pipeline will not run) is automatic; what you opt in to here is making your own await responsive instead of letting it complete naturally before the skip kicks in.
The two channels are complementary. ctx.abort(reason) is how a middleware signals refusal between middlewares. ctx.abortSignal is how any middleware reacts to an abort already signalled — by itself, by another middleware, or by the caller from outside the turn.
Open gates self-reject — you do not need to wire them up
Every TurnGate opened by ctx.waitFor(...) is already wired to the turn's abort signal at construction. When an abort lands, every gate still open on that turn rejects with E_TURN_GATE_ABORTED on its own — the awaiting ctx.waitFor(...) rejects, the middleware (or tool handler) parked on it unwinds, and the standard abort skip takes over from there. You do not pass ctx.abortSignal into the gate, and you do not need a separate listener to cancel pending approvals on abort. ctx.abortSignal is for work you own (a fetch, a poll, a streaming client); gates the runner owns are already taken care of. Gates on other turns are unaffected — abort is scoped to the turn that raised it.
A third trigger exists for completeness. A stage that throws a platform AbortError — the one signal.throwIfAborted() throws and the one fetch() rejects with when its signal fires — is treated the same as ctx.abort(). Classification is isInstanceOf(err, 'AbortError') (a constructor.name match, cross-realm safe), not a check against error.name; throwing your own class AbortError extends Error {} will match because its constructor.name is 'AbortError', but that is not the intended path. Let the platform's AbortController/AbortSignal machinery raise it for you.
From outside the runner: how to classify a turn's outcome
Three events, three jobs. Wire all three or one category of outcome is invisible.
turnEndalways fires — once per turn. Your count of turns.dispatchEndfires only if the turn reached dispatch. InspectdispatchEnd.status('ack'/'nack'/'aborted') when it is present.errorfires for throws and short-circuits — never for abort. Your count of bugs.
turnEnd answers "how many turns happened". dispatchEnd answers "how did each dispatch end". error answers "what went wrong". Drop one of the three and you have a blind spot in production.
dispatchEnd does not fire if dispatch never started
dispatchEnd is emitted once the dispatch has begun. If the turn aborts during turnInputPipeline (via ctx.abort(reason) or an external AbortSignal fire) or turnInputPipeline throws (E_INPUT_PIPELINE_ERROR), dispatch is skipped entirely and the turn goes straight to turnEnd — no dispatchStart, no dispatchEnd. Don't go hunting for a dispatchEnd.status === 'aborted' that was never emitted; for input-pipeline aborts, the operational signal lives on turnEnd and on the absence of dispatchStart.
Where to go next
- Pipelines — the hub.
- Composition —
next(), sequencing, where to open a gate. - Throws — how throws are wrapped, why post-steps run on the error path.
stash— cross-middleware state.- Failure — the full exception catalog.