Files
qwen-code/docs/design/2026-08-07-safe-session-restore-timeout.md
T
a810f7e16c fix(serve): Make session restore timeouts safe and observable (#8691)
* fix(serve): make session restore timeouts safe

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): restore missing core mock exports in the ACP worktree suite

The restore-tracing change added `extractDaemonTraceContext` and
`withDaemonSpan` to `acpAgent.ts`, but `acpAgent.worktree.test.ts`
replaces `@qwen-code/qwen-code-core` with a full mock factory that never
listed them. `loadSession` then failed on an undefined export, taking all
three cases down and producing teardown rejections from the half-built
agent. The sibling suite was updated; this one was missed.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(serve): bound and disambiguate the abandoned restore lifecycle

Four follow-ups from review of the restore timeout work.

A startup budget may now raise the restore budget but never lower it.
Taking an explicitly configured `initializeTimeoutMs` as the restore
fallback meant a deployment that tightened its child-initialize check
still inherited a sub-default restore deadline — exactly the failure this
change exists to remove. An explicit `sessionRestoreTimeoutMs` still wins
outright, including below the default, for deployments that want restore
to fail fast. Validation now names the field actually at fault.

A restore fenced behind a timed-out predecessor is no longer reported as
an ordinary in-flight restore. It carries `reason:
awaiting_abandoned_cleanup` and a retry hint of one restore budget
(capped at 120s) instead of the ordinary 5 seconds, because the fence
cannot clear until the non-cancellable ACP request settles and a 5-second
cadence just spins the caller against a 409 it cannot resolve.

Whether a channel is condemned is now derived rather than sticky. A
timeout recorded `emptyReapPending` permanently, so any channel that had
ever seen one was guaranteed to be reaped once its remaining work
drained, forcing a cold respawn even when the late restore had landed and
closed cleanly. The reap condition is now computed from an outstanding
`unsettledAbandonedRestores` set, quarantine, or an ordinary pending
empty reap; real settlement clears the entry and hands the channel back
to the configured idle policy.

Abandonment no longer retains ownership without bound. One further
restore budget after the deadline, a still-unsettled restore marks the
channel `restoreSettlementOverdue`: existing sessions and workspace
control keep working, but fresh session work is refused so the channel
can drain, since closing the transport is the only lever that releases a
permanently hung request. Releasing capacity while hidden work runs would
allow unbounded oversubscription, and force-killing a channel with live
siblings would reintroduce the failure this work removes, so neither is
done. Fresh-admission blocking is now scanned across alive channels
rather than tracked in a single reference, so a second condemned channel
cannot silently displace the first.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(serve): keep the abandoned restore lifecycle off ids it no longer owns

Two correctness gaps in the abandoned-restore machinery introduced by this
PR, both reported by automated review and both confirmed by mutation
testing (each new test fails when its fix is reverted).

A caller-supplied `sessionId` is used verbatim by the agent, but
`spawnOrAttach` never consulted `inFlightRestores`. A fresh spawn could
therefore take an id that a restore still owns, in either lifecycle phase.
The consequences were silent: `abandonedRestoreIds` suppresses session
updates, guardrail events, and child notifications, so the new session
would have registered successfully and then emitted nothing; and a late
`settleAbandonedRestore` would have closed and tombstoned it out from
under its owner. Such a spawn is now rejected with the same
`RestoreInProgressError` and reason the restore path uses, so the caller
gets the correct retry hint for whichever phase is holding the id.

The cleanup path is guarded independently, because the request-level check
only covers the id the caller asked for and a session registers under the
id the child returns. An abandoned restore never reaches
`createSessionEntry` — the deadline rejects before registration — so any
live entry under that id belongs to someone else. Cleanup now detects that
and returns without closing or tombstoning, releasing its own bookkeeping
instead.

The notification fence has no TTL and was only cleared by
`markRestoreInFlight`, which covers a subsequent restore and nothing else.
`createSessionEntry` now clears it for every registration route, so a
legitimate owner of the id is never handed a session that silently drops
everything the child sends it.

Also tightens two tests that could not observe the values they pin. The
SDK default restore timeout admitted any value in (30s, 70s]; it is now
split at the exact boundary, so collapsing the default onto the 60s server
budget — which would make the client abort race the daemon's own deadline
and cost the caller its structured 504 — fails. And the advertised-budget
propagation from capabilities through to the SDK call had no live-path
assertion; dropping the capabilities argument at the real call site left
every existing test green. The `as never` casts are replaced with typed
`DaemonCapabilities` values so a field rename fails typecheck.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(serve): let a condemned channel drain without its wedged child

Merging main's active-work close protocol (#8588) into this PR's abandoned
restore bound produced a deadlock that neither side has on its own, and the
conflict resolution was committed without running tests.

`maybeCloseIdleSession` now routes through `confirmChildUnheld`, which asks
the child whether it still holds work before closing a session nobody is
attached to. That is right in general and wrong for a channel this PR has
already condemned. `restoreSettlementOverdue` and quarantine exist precisely
because the child stopped being answerable, and their whole premise is that
visible work drains so the channel can be reaped — closing the transport is
the only thing that can release a restore we cannot cancel. Making that
drain depend on a round trip to the wedged child inverts it: a child stuck
in a non-cancellable restore is exactly the one that cannot reply inside
`ACTIVE_WORK_CLOSE_TIMEOUT_MS`, so the sessions never close, the channel
never drains, the reap never fires, and the bound never takes effect.

A channel condemned by the restore lifecycle now skips the round trip and
proceeds to local teardown. Nothing is attached to the session by then —
`maybeCloseIdleSession` gates on that — and the sibling-safety invariant is
untouched: this closes sessions whose clients have already left, it does not
force-kill a channel that still has live ones.

The regression test drives an overdue channel whose child never answers the
close-if-unheld probe and asserts the detach still reaps it. Reverting the
guard reproduces the deadlock as a test timeout.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(serve): pin the restore-timeout contract the review found unasserted

Automated review identified eleven places where the restore-timeout work's
behavior was correct but unpinned — each with a mutation that ships green.
Every fix below was verified the same way: apply the mutation, watch the new
assertion fail, revert, watch it pass.

The timeout path's telemetry had no coverage at all, which is the sharpest
gap given that observability is what this work exists to deliver. A shared
recorder now asserts the public timeout result and its kill_empty-vs-
fence_shared signal, the late arrival, and the cleanup outcome for both the
closed and quarantined cases.

The deadline timer's cancellation on a successful restore was likewise
unpinned: deleting both `clearTimeout` calls kept the whole suite green,
while in production the stale timer fires one budget after a successful
restore and abandons a live session — fencing its frames, closing its event
bus, and emitting a spurious timeout. A success-path test now advances past
the deadline and asserts no second public result.

Three more bridge assertions proved less than they claimed: the concurrent-
restore case never checked that the abandoned restore settles, the
workspace-control case never checked that the deferred reap eventually
fires, and the resolver never pinned the accepting side of the MAX boundary
(a `>` to `>=` mutation rejects the largest legal delay at boot). The
workspace-control case also needed a positive channel idle budget, since
with the default zero the idle-timer kill substitutes for the reap junction
under test; its assertions are rewritten around the derived reap semantics
rather than the sticky flag they predate.

Outside the bridge: the scheduled-task timeout wiring had no test, so
deleting the arguments silently fell back to the helpers' own defaults; the
cold restore path never asserted that `live_restore_ms` is absent; the SDK's
per-request validation and its over-ceiling clamp were untested; the WebUI
watchdog test jumped straight to its own value, staying green for any
watchdog at or below it, including the 30s attach value that would recreate
the original symptom in the browser; and the two new known error types were
unexercised, so dropping either would relabel every restore-timeout and
quarantine error as unknown.

Two review items are deliberately not taken here and are recorded in the
design doc's non-goals instead: transcript materialization is still not
separately attributable from `config_setup`, which needs instrumentation
inside the core session loader that P1/P2 restructures anyway, and sibling
event-loop latency during a large restore remains unmeasured.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(serve): bound the condemned-channel close and complete the fence contract

Second automated review round, on the code the first round produced. One
Critical and twelve suggestions; all verified by mutation before and after.

**The Critical is a regression I introduced.** Letting a condemned channel
skip the bounded hold probe routed it into `closeSessionImpl`, whose agent
close is unbounded when it throws on failure — so the fix traded a bounded
wait on a wedged child for an unbounded one. A settlement-overdue channel
with an unresponsive child would hang `detachClient` forever, strand the
session in `closing`, never drain, never reap, and 503 every new session
until restart: strictly worse than before. `CloseSessionOpts` now carries an
`agentCloseTimeoutMs` that the condemned path sets, so a hang lands in the
existing unknown-outcome recovery, which kills the channel — the teardown
the drain was waiting for. The earlier test missed this because its fake
child still answered the plain close; it now answers nothing at all, and
asserts the detach itself returns.

**The fence was invisible on the transports clients actually use.**
`toRpcError` had no `RestoreInProgressError` case, so over acp-http and
acp-ws — which SDK negotiation prefers over REST — the fence degraded to an
opaque internal 500 with no code, reason, or hint, and the backoff contract
this work documents was impossible to honor.

**Two retry hints still advertised five seconds for states that outlive a
budget.** The restore 504 creates the fence, and quarantine lasts until the
channel drains; a fresh-id caller never reaches the 409 that carries the
real hint, so its header was the only signal it got. Both now derive from
the budget through one shared clamp helper, which also replaces the formula
that was inlined in the bridge and gives the documented 5-120s bounds a
test.

**A spawn collision reported an operation the caller never issued**, naming
the restore owner's action as both the active and the requested one and
telling the caller to retry an endpoint it never called.

The rest: five places still described the initialize-timeout fallback as a
plain chain rather than raise-only, contradicting sibling docs shipped in
this same PR; the design doc omitted the retry-hint clamp; the protocol
reference omitted the new spawn emission site; the error taxonomy omitted
`restore_settlement_overdue`, which matters because its audience is
monitoring. Test-only gaps: the dynamic 409 had no HTTP-layer coverage, the
120-second cap was unpinned, and the SDK's precedence of an explicit global
timeout over the advertised budget was pinned only branch-by-branch.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(serve): preserve restore session ownership handoff

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-08-09 15:47:09 +00:00

13 KiB
Raw Blame History

Safe and observable large-session restore timeouts

Context

Issue #8678 exposed that session restore reconstructs the persisted JSONL transcript before it can return an ACP loadSession or unstable_resumeSession response. Large transcripts can therefore exceed the bridge's former shared 10-second initialization timeout. The bridge cannot cancel the underlying ACP request because the current SDK does not expose its JSON-RPC request id or a cancellation operation.

The old timeout path stopped waiting while the child continued restoring. Channel cleanup could then kill a multiplexed child that still owned unrelated live sessions, or a late restore could create a session and writer lease after the caller had already received an error. The resulting error was also classified as a generic initialization timeout or plain 500, which hid the affected session and restore phase.

The 8 MiB live journal and 4 MiB compacted replay limits do not bound this work. They constrain client replay after reconstruction; they do not cap the on-disk transcript read.

Goals and non-goals

This change gives restore a dedicated configurable deadline, makes timeout errors structured, keeps non-cancellable late work fenced until settlement, and provides phase-level traces across the daemon and ACP child.

It does not stream the JSONL parser, add a transcript index or snapshot, guarantee that every large transcript finishes within 60 seconds, or change the WebUI's detach-before-load transaction. Those are follow-up changes.

Two deliberate gaps are worth stating rather than leaving to be rediscovered.

Transcript materialization is not separately attributable. The child records config_setup as one stage, and the full JSONL read plus active-chain reconstruction both happen inside it. The JSONL read runs in loadCliConfig through argv.resume, which both load and resume handlers set; when chat recording and the writer lease are enabled, config.initialize() additionally performs an authoritative reload. So a restore that again exceeds its budget shows config_setup_ms ≈ budget without telling an operator whether the cost was the transcript or a regressed runtime setup, and the P1/P2 work cannot baseline the phase it exists to optimize from these traces alone. Issue #8678's P0 asks for transcript indexing/read and active-chain reconstruction as distinct phases; this change delivers the coarser split it claims (transcript/config work versus authentication, registration, and replay) and not that finer one. Closing it means instrumenting inside the core session loader — the same code P1/P2 restructures — so it is sequenced with that work. The contained version, if it is wanted sooner, is for the loader to record its own read duration on the resumed session data and for the child profiler to report it as a transcript_read stage under the existing attribute prefix.

Sibling latency during a large restore is unmeasured. Siblings keep their sessions and stay logically usable, which is asserted; nothing establishes that they stay responsive while a multi-hundred-megabyte transcript is reconstructed on the shared child's event loop. The instrument already exists — the child runs an event-loop lag monitor whose snapshot carries mean, p50, p99, and max — so a fixture slow enough to cross the deadline would close this and the untested empty-channel budget increase in one run.

Timeout contract

The server restore budget resolves in this order:

  1. sessionRestoreTimeoutMs / --session-restore-timeout-ms, which wins outright — including values below the default, for deployments that deliberately want restore to fail fast;
  2. otherwise 60,000 ms, raised to an explicitly supplied initializeTimeoutMs / --initialize-timeout-ms when that value is larger.

A startup budget may raise the restore budget but never lower it. The two measure different work, and a deployment that tightened its child-initialize check must not silently inherit the sub-default restore deadline this change exists to remove.

Server values must be positive integers no greater than 2^31-1. The daemon publishes the effective value as optional v1 field limits.sessionRestoreTimeoutMs in both bootstrap and runtime capabilities.

The TypeScript SDK chooses a restore timeout in this order: per-request timeoutMs, explicit global fetchTimeoutMs, cached server capability plus 10 seconds, then 70 seconds. Per-request zero disables the client timer and is not serialized. Calling capabilities() refreshes the cached static budget; a restore does not issue an implicit capability request.

The WebUI passes server budget plus 10 seconds to SDK load/resume and uses server budget plus 15 seconds for its own watchdog. Missing capability values fall back to 70 and 75 seconds. Attach retains its existing 30-second watchdog. A derived delay above the JavaScript timer ceiling disables that client-side timer so Node cannot compress it to approximately one millisecond.

Restore ownership and terminal arbitration

inFlightRestores owns two promises: a public promise returned to callers and a settlement promise representing the real ACP request plus cleanup. It also owns the session-id fence and accounts for restore capacity. On normal success, registration transfers capacity to the live session and may release admission; after abandonment, the fence, admission, and in-flight capacity remain until real settlement.

An explicit lifecycle flag arbitrates the deadline and ACP result. Whichever observes active first changes the public terminal state. This avoids relying on Promise.race callback ordering at an equal-time boundary.

State Meaning and allowed transition
active Same-action, same-shape requests coalesce. ACP success may register the session; failure may return normally.
abandoned The deadline won. The public caller receives a structured timeout. The id is removed from pending restore events and fenced against late child notifications. Same-id retries receive restore_in_progress with reason: awaiting_abandoned_cleanup and a retry hint derived from the restore budget — clamped to at least 5 seconds and at most 120 — rather than a fixed 5 seconds.
overdue One further restore budget passed with no settlement. Existing sessions and workspace control remain usable, but fresh spawn/load/resume/branch work is rejected so the channel can drain — closing the transport is the only lever that releases a permanently hung request. The channel is never force-killed while live siblings remain.
cleanup A late ACP success or failure triggers exactly one child qwen/control/session/close. Resource-not-found is already clean.
quarantined Cleanup outcome is uncertain. Existing sessions and workspace control remain usable, but fresh spawn/load/resume/branch work is rejected until the channel drains.
settled Close succeeded, the resource was absent, or the transport closed. Restore admission, the capacity slot, and the in-flight entry are released. The notification fence remains until a new registration attempt for that id takes ownership or the channel exits.

Abandonment retains ownership, but not indefinitely. overdue bounds the window in which a hung restore can hold an admission slot, an in-flight entry, and a session-id fence that nothing else can release: after the grace period the channel stops taking new work, and the ordinary drain path then recycles the child. Releasing capacity while the hidden work is still running would allow unbounded oversubscription, and force-killing a channel that still has live siblings would reintroduce exactly the failure this change removes — so neither is done.

The abandoned notification fence has no TTL and remains until the channel exits or a new registration attempt for the id explicitly takes ownership. Caller-supplied spawns and restores synchronously reserve that ownership before either path can await channel setup, so opposite-direction races cannot run two ACP registrations for the same id. The ownership transfer happens before the ACP registration call so legitimate startup notifications can be buffered; a failed attempt purges those frames and restores the ordinary 60-second closed-session tombstone.

Channel safety

At timeout, the bridge removes the id from pendingRestoreIds, clears its early events, and records the id in the channel's unsettledAbandonedRestores. If the channel has no live sessions, pending spawn, other restore, or workspace-control operation, it is synchronously marked dying and its child is killed asynchronously; the 504 response does not wait for TERM/KILL escalation. The outer qwen serve process continues running.

If any sibling work exists, the child remains alive. A late cleanup failure sets a distinct quarantine bit and does not set isDying, so sibling attach, prompt, close, and workspace control continue. Fresh work is rejected before capacity checks so the stable error is acp_channel_unavailable rather than a coincidental session-limit response. Once visible work drains, the child is recycled and a subsequent request can create a clean channel.

Whether a channel is condemned is derived, not sticky. It is reaped once visible work drains while any of these still hold: an ordinary pending empty reap, a non-empty unsettledAbandonedRestores, or quarantine. Real settlement removes the id from that set, so a channel whose late restore landed and closed cleanly returns to the configured idle-channel policy rather than being forced into a cold respawn by a timeout it already recovered from. While a restore genuinely remains unresolved, draining is what closes the transport — the only lever that breaks a permanently hung request.

Shutdown waits each real settlement promise, but every ACP request is raced with channel.exited. Killing the channel therefore releases shutdown even if the SDK request promise itself never settles. All abandoned-result handlers are rejection-safe.

Error contract

SessionRestoreTimeoutError subclasses BridgeTimeoutError but is classified first as restore_timeout. REST returns HTTP 504 with a budget-derived Retry-After clamped to 5–120 seconds and the fields code, errorKind, retryable, sessionId, action, and timeoutMs. ACP HTTP and WebSocket return JSON-RPC -32603 with the same data plus httpStatus: 504 and retryAfterSeconds.

A channel closed to fresh work rejects it with HTTP/ACP status 503 and code and error kind acp_channel_unavailable. reason distinguishes the two causes: restore_cleanup_failed (cleanup of a timed-out restore failed, so the child's state is unknown) and restore_settlement_overdue (an abandoned restore has still not settled one restore budget after its deadline).

Observability

The bridge creates a restore span and always injects its trace context into ACP load/resume metadata. It records action, channel id, session id, configured budget, public result, late result, and cleanup or quarantine result without transcript content.

The child continues that context in qwen-code.daemon.session_restore and records durations for settings load, live restore or persistence existence check, config setup, authentication, filesystem setup, session registration/replay, and response construction. The first failing stage is recorded as failed_stage.

Verification

Unit tests use deterministic delayed children and fake timers for empty-channel reap, sibling survival, same-id fencing, exactly-once late close, quarantine drain and recovery, capacity retention, transport-close arbitration, and hanging-request shutdown. REST and ACP mappings, capability propagation, SDK transport abort behavior, WebUI timer derivation, trace propagation, and failed-stage attribution are asserted independently.

The E2E plan uses a delayed child to validate the public deadline and process survival. An approximately 80 MiB transcript is a manual benchmark and trace fixture only; CI does not assert a machine-dependent latency.