Electron Stagewrightdocs

ADR-011: Request cancellation and operation-timeout backstop

Status: Accepted

Status update (2026-10-03): cooperative request cancellation

Cancellation now reaches handlers through ToolContext.signal. This supersedes the original abandon-only handler policy: the dispatcher still cannot interrupt JavaScript already sent to Electron, but it can stop subsequent work and reclaim resources acquired by cancelled initialization. A controlled regression demonstrated a launch handler registering a session after its dispatcher had already returned OPERATION_TIMEOUT.

Each dispatch owns an AbortController. MCP request cancellation and the operation timeout both abort it. Nested dispatches inherit the active parent's signal; unrelated requests remain isolated. DispatchOptions.signal provides the same behavior to direct callers. Cancellation returns the non-retryable OPERATION_CANCELLED envelope to direct callers and dispatch observers. For MCP, the SDK handles the cancelled request's protocol response; no replacement response is sent by this layer. Client cancellation reasons are not copied into errors or logs. A timeout retains the existing retryable OPERATION_TIMEOUT code. Disabling the timeout does not disable cancellation.

ToolContext.onCancel registers idempotent, cancellation-only resource cleanup. A registration made after cancellation runs immediately, which handles transports that resolve late. Completion clears callbacks, timers and parent listeners; cleanup failure is logged without blocking the cancelled response. Progress scopes close their heartbeat and phase timers even if the handler never settles. The signal and cleanup hook are optional in the interface for existing manually constructed contexts; the dispatcher always supplies both.

Launch/attach/inject keep cleanup armed until dispatch completion. Windows and launch renderer readiness are prepared before registration. Cancellation stops only an app owned by launch; attach/inject release their connection without asking an existing app to quit, even when a PID was supplied. A cancelled initialization's handle is removed even if disconnect fails. Successful requests transfer the session to the registry and later client cancellation does not reclaim it. Renderer readiness retries check cancellation and their delay is abortable.

This is cooperative cancellation, not atomic rollback. Already-dispatched evaluations and renderer polls may finish; completed actions and previously completed nested calls are not undone. Plugins must check ctx.signal between asynchronous steps and register their own resource cleanup. A launch handshake that never yields a session remains bounded by its transport's startup timeout; the dispatcher can only clean up the session once that transport returns it. No arbitrary process is killed to force cancellation.

Context

The server drives a real app over a transport. Most per-tool operations are already bounded — interaction actions clamp a Playwright timeoutMs (≤ 30s), the wait family self-bounds its poll in the renderer (≤ 60s), and eval surfaces a transport timeout as EVAL_TIMEOUT. But there is one unbounded class: a transport call that simply never settles. A frozen renderer makes page.evaluate (the basis of snapshot / find / read / expect) wait indefinitely — Playwright's evaluate has no implicit timeout. A tool whose handler awaits such a call hangs the dispatch forever, and the agent is stranded with no envelope and no recovery.

The resilience/chaos review surfaced this as a real gap (a "hung app" has no bound) and explicitly deferred it pending a policy decision rather than faking a fix.

Decision

Add a dispatch-level backstop timeout: the dispatcher races each handler against a configurable budget. If the handler does not settle within the budget, the dispatch resolves with a registered, retryable OPERATION_TIMEOUT envelope (details.timeout_ms carries the budget) instead of hanging.

Rationale

Alternatives considered

Consequences

References

Status update (2026-10-29): launch timeoutMs cap

electron_launch accepted any timeoutMs, and Playwright granted that budget to process start and to the first-window wait separately. A launch blocked before its first window, retried with timeoutMs: 120000, ran into this backstop and returned OPERATION_TIMEOUT, so the agent never saw the LAUNCH_TIMEOUT hint about a blocked main process. timeoutMs is now capped at 50 000 ms and shared by both phases; with the 60 000 ms readyTimeoutMs cap, a launch ends before the default 120 s backstop.