Async failure & cancellation model

September 18, 2026 · View on GitHub

This is the contract that keeps the TUI's fire-and-forget work from leaking unhandled rejections or misclassifying cancellations. It exists because a bare void somePromise() in this codebase means the failure either crashes nothing (silent), escapes to nowhere (unhandled rejection), or gets misreported as a cancellation when it was a real failure.

Diagnostics sink (src/diag.ts)

ctx.logger is invisible in this process (no exporter), so the TUI's own diagnostics go to stderr + $DSH_HOME/logs/pi-tui-<pid>.log (env DSH_PI_TUI_LOG / DSH_PI_TUI_LOG_LEVEL, default info). Keep new lifecycle logging in diag, not just ctx.logger — a log line that only goes to ctx.logger is a log line that never exists here.

Detached tasks (src/detached.ts)

Rule: never a bare void somePromise(). Two entries, both taking a TASK FACTORY that is invoked SYNCHRONOUSLY before the helper returns:

  • runDetached(label, () => task, opts) — detached work with NO result consumer: settings writes, theme autodetect, skill refresh.
  • runOwned(label, () => task, { isCancellation?, onResult, onCancel, onError }) — result-consuming main flows: submit/steer dispatch, command execution, local commands, local-shell card settle, the ! shell-context submit (re-validate → followup; a refused write keeps the card, an unexpected error notifies), session switch, question flows, model-picker selection writes, external editor.

Why a synchronous factory: ownership actions (e.g. Ctrl+G's stop()) take effect immediately, and a synchronous throw is converted to a rejection and classified like any other failure — nothing escapes classification.

The failure phases are distinct (the core trap)

  1. TASK phase (factory throw / task rejection) — classifies cancellations using the error shape (isCancellation, cancellationError(...)) plus the task-local isCancellation predicate (use it when only the task knows it was cancelled: () => localSignal.aborted, () => this.disposed). Records DEBUG for cancellations, ERROR for failures.
  2. onResult is the RESULT CONSUMER. Its sync/async failure is a PRIMARY failure — ERROR + onError, never a cancellation, regardless of error shape (even an AbortError) or task-local state, because the task already settled.
  3. onCancel / onError are TERMINAL side-effect handlers and the task-local classifier. Their own sync/async failures land exactly once in the ${label} handler failed sink, are never re-classified or repeated, and produce zero unhandled rejections. Thrown values are never mutated: primitives, null and frozen errors are carried by an internal wrapper.

Error observation (src/error-boundary.ts)

Error observation is SYNC-TOTAL: describing any legal thrown value (hostile Proxy/getter/coercion) can never make a chain reject. Honest limit: an observer that spawns its OWN detached async work while being observed (e.g. void Promise.reject(...) inside toString) is OUTSIDE the contract — do not describe this module as a strict "any legal value, zero side effects" guarantee.

Lifecycle roots are equally total

Startup and exit (src/index.ts root catch, src/exit.ts) protect every step individually (diag, cleanup, hint, exit), so no throw can skip teardown or leak a rejection. The Direct owned-session retirement (src/runtime/direct/owned-session-retirement.ts) is equally total: every phase failure is recorded and the remaining phases still run, so a hostile rejection can never skip the final flush or the handle dispose.

Host fork settlement

/fork and /rewind separate Host publication from local navigation:

  • rejected means no child was published and the current surface remains;
  • published-with-error carries an authoritative child id when publication succeeded but follow-up addressing or workspace work failed;
  • indeterminate means the result is not knowable and is never retried;
  • forked is a known published child, while superseded means a newer local navigation owns the visible surface.

Navigation supersession is not Host cancellation. A published Direct child that is not selected is parked in the runner-owned owner pool and can later be claimed by /sessions; teardown retires every still-parked owner exactly once. Remote uses one official ClientSessions.fork() call and preserves a known published identity rather than issuing a second attempt.

Where the contract is wired in

  • Owned callbacks do CORRECTNESS FIRST (draft restore, card settle, controller release) and best-effort formatting/notify afterwards, all through the shared safeErrorMessage.
  • UI-layer modules receive the owned entry by injection (TuiAppEvents.runOwned, SubmenuDeps.runOwned) — never their own bare void.
  • The external-editor hook is a BOUND pair in TuiAppEvents: openExternalEditor requires runOwned (type union + constructor check), and the launch is SINGLE-FLIGHT with the ownership latch cleared in the outermost finally — a stop/start throw can never leave it stuck.

The bare-void allowlist (and its static guard)

The ONLY bare-void exceptions are the terminal sinks inside src/detached.ts (exempt by filename in rules.test.ts — the helpers' own sinks need no marker) and the two lifecycle roots (startup in index.ts, exit in exit.ts), which carry an allowlist comment on the same line. test/rules.test.ts statically detects COMMON SINGLE-LINE void call() discards (recursive over src/, with matcher self-tests). It is deliberately NOT a substitute for review or a type-aware lint (@typescript-eslint/no-floating-promises) — new hand-written void chains in the detected forms fail the suite.

Operation identity: text equality is NOT operation identity

Draft restore deduplication once compared current === submitted text and endsWith suffixes to make "restore the same draft twice" idempotent. That text-level dedup silently dropped one of TWO INDEPENDENT operations that happen to carry the same text: two separate Ctrl+S submits of the same string that both fail/stale must both restore their drafts, because each corresponds to exactly one user operation. Content-based dedup is only safe for idempotence of the SAME operation retried — identify operations by the capture-then-write identity the runner actually checks (the CAPTURED agent object plus the session generation via sessionUnchanged; a full steer also re-validates the queue snapshot), never by text equality. Test with a second identical draft plus a third different draft typed mid-failure.