---
url: 'https://adk.nht.io/the-loop/pipelines/abort.md'
description: >-
  Three perspectives on abort — how to raise one from a middleware, how to react
  to one already in flight, and how to classify abort traffic from outside the
  runner.
---

# Abort

## LLM summary — Abort

* Abort has three triggers, all equivalent: `ctx.abort(reason)`, an external `AbortSignal` fire, or a stage throwing a platform `AbortError` — classified by [`isInstanceOf`](https://adk.nht.io/api/@nhtio/adk/guards/functions/isInstanceOf)`(err, 'AbortError')` (a `constructor.name` match, cross-realm safe), not by reading `error.name`. That covers what `signal.throwIfAborted()` throws and what `fetch()` rejects with when its signal fires.
* **Abort never emits on the `error` bus.** Not when raised in input middleware, not when raised during dispatch, not when an external signal fires after `dispatchStart`. If you alert only on `error`, every cancelled turn is invisible to alerting.
* Raising from inside a middleware: `ctx.abort(reason)` then `return`. The aborting middleware finishes its own body inline — code between `ctx.abort()` and `return` runs, but if you return before ever calling `await next()`, there is no post-step (a post-step is code that runs *after* `next()` resolves; you never awaited it, so there is nothing to run on the unwind). Every middleware after it in the same pipeline is skipped; the next major stage of the turn is skipped. Awaiting `next()` after `ctx.abort()` is pointless — the wrapper around every downstream middleware skips its body regardless. If you need post-`next()` cleanup, you must `await next()` first; otherwise put cleanup inline before the `return`.
* Reacting from inside a middleware: subscribe to `ctx.abortSignal` (a standard `AbortSignal`). Hand it to anything that accepts one (`fetch`, custom polls, streaming clients) so your own awaits cancel mid-flight instead of completing before the runner's inter-middleware skip kicks in.
* Scope by pipeline. Turn pipelines (`turnInputPipeline`, `turnOutputPipeline`): abort skips the remaining middlewares in that pipeline and every subsequent major stage. Dispatch pipelines (`dispatchInputPipeline`, `dispatchOutputPipeline`): abort skips the remaining middlewares for the current iteration, the iteration does not start a new one, and the dispatch exits with `dispatchEnd.status === 'aborted'`. Abort is scoped to *that* turn — concurrent turns on the same runner are unaffected.
* Classifying from outside: `turnEnd` (always fires — count of turns), `dispatchEnd` (only if dispatch started — `'ack'` / `'nack'` / `'aborted'`), `error` (throws and short-circuits — count of bugs). Wire all three. If `turnInputPipeline` aborts or throws, `dispatchEnd` never fires — the operational signal lives on `turnEnd` and the absence of `dispatchStart`.

[Composition](./composition) covers the function shape, `next()` semantics, and where to open a gate. [Throws](./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.

::: tip 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 you `return` without ever calling `await next()`, there is no post-step to run on the unwind: a post-step is code positioned after `next()` resolves, and you skipped that call. Put any cleanup inline before the `return`.
* Every middleware after this point in the same pipeline is skipped — the runner's wrapper checks `ctx.aborted` before invoking a downstream body and short-circuits if set.
* The next major stage of the turn is skipped (see the scope table below).

```ts
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()
}
```

::: warning 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 with `dispatchEnd.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](./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.

```ts
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.

::: tip Open gates self-reject — you do not need to wire them up
Every [`TurnGate`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/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`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/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`](https://adk.nht.io/api/@nhtio/adk/guards/functions/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.

* `turnEnd` always fires — once per turn. Your **count of turns**.
* `dispatchEnd` fires only if the turn reached dispatch. Inspect `dispatchEnd.status` (`'ack'` / `'nack'` / `'aborted'`) when it is present.
* `error` fires 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.

::: warning `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](../pipelines) — the hub.
* [Composition](./composition) — `next()`, sequencing, where to open a gate.
* [Throws](./throws) — how throws are wrapped, why post-steps run on the error path.
* [`stash`](./stash) — cross-middleware state.
* [Failure](../failure) — the full exception catalog.
