You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] Session cancel races cause lost cancels, stale aborts, and dangling waits #20095
Session cancellation has multiple race-condition failure modes:
Lost cancel before start
If cancel() is called before start() initializes session state, the cancellation is dropped and the session still runs.
Stale cancel aborts new generation
When a session is cancelled and quickly restarted (same session ID), a delayed cancel callback from the old run can abort the new run.
Dangling permission/question waits after cancel
Cancel does not reject pending permission/question waits, so tool execution can remain blocked indefinitely even though the session appears cancelled.
Plugins
None
OpenCode version
1.3.3 (also observed on dev as of 2026-03-30)
Steps to reproduce
Start a prompt that triggers tool use and session loop activity.
Immediately cancel before the first response is fully initialized.
Re-send a prompt quickly in the same session.
Observe one or more of:
cancel being ignored,
new run being aborted by stale cancel from prior run,
pending permission/question waits not being cleared.
The issue does not use one of the three required issue templates (Bug Report, Feature Request, or Question). Please resubmit using the Bug Report template, which requires fields like OpenCode version, operating system, terminal, and steps to reproduce.
No system information was provided (OpenCode version, OS, terminal). This context is important for triaging and reproducing the reported race conditions.
Please edit this issue to address the above within 2 hours, or it will be automatically closed.
This issue might be a duplicate of existing issues. Please check:
changed the title [-]Session cancel races: lost cancels, stale aborts, and dangling prompts[/-][+][BUG] Session cancel races cause lost cancels, stale aborts, and dangling waits[/+]on Mar 30, 2026
Thanks for updating the issue! It now includes all the required information — a clear description of the race conditions, reproduction steps, OpenCode version, OS, and terminal. Everything looks good.
Hi, I just closed #20250 as it overlaps with this issue. However, I'd like to add two pieces of new information that are NOT covered in the current description:
1. Todo Continuation as a Trigger Path (NEW)
This issue describes cancel races from manual user actions, but there's an automatic trigger path that also causes the same UnknownError: prompt_async failed:
Scenario: AI agent completes its agentic loop → session exits and gets cancelled → TUI detects remaining TODO items → auto-fires prompt_async (Todo Continuation feature) → session is already in cancelled/idle state → UnknownError.
This is distinct from the three failure modes listed because it's triggered by an automatic system mechanism (Todo Continuation), not a user action.
12:23:00 step=22 loop ← AI completes final agentic step
12:23:01 exiting loop ← Loop exits normally
12:23:01 compaction pruning
12:23:01 session.prompt cancel ← Session marked as cancelled
12:23:01 session.idle ← Session enters idle state
12:23:01 command.executed ← Command completes
12:23:01 GET /session/.../todo ← TUI fetches todo list (finds pending items)
12:23:01 GET /session/.../message
12:23:01 POST /session/.../prompt_async ← TUI auto-triggers Todo Continuation
12:23:01 ERROR UnknownError prompt_async failed ← Session already cancelled!
12:23:13 step=0 loop ← Second continuation attempt starts
12:23:27 step=1 loop
12:23:28 exiting loop
12:23:28 POST /session/.../prompt_async
12:23:28 ERROR UnknownError prompt_async failed ← Same error again
This happened twice in the same session within ~27 seconds, both triggered by Todo Continuation.
2. ESC on Question — Full Log Timeline
For failure mode #3 ("Dangling permission/question waits after cancel"), here's the complete log timeline with millisecond precision (session ses_2c13a106effeokLMx959pcfGgf, log file 2026-03-30T124351.log):
12:58:16 message.part.delta (continuous) ← AI streaming output, Question tool presented
... (user sees options but doesn't select) ...
13:10:54 POST /session/.../abort ← User pressed ESC to dismiss Question
13:10:54 session.prompt cancel ← Cancel signal received
13:10:54 session.idle ← Session goes idle
13:10:54 abort completed (3ms)
13:10:54 POST /session/.../prompt_async ← Immediate prompt_async (from queued operation)
13:10:54 ERROR UnknownError prompt_async failed ← Race condition
13:10:54 ERROR Aborted process
... (repeated at 13:11:00 and 13:12:05 — 3 total occurrences)
The key observation: the abort and prompt_async happen within the same millisecond (13:10:54), suggesting the prompt_async is dispatched from a code path that doesn't check session state after abort completes.
Hope this helps with the fix! The Todo Continuation path in particular may need its own state guard.
Closing: Resolved by upstream Effect/Runner migration
All three failure modes described in this issue — plus the two additional trigger paths reported by @zswll2 — are now addressed by upstream's migration to Effect/Stream/Runner.
Abort-safe stream iteration — Replaces raw for await with Effect/Stream (Stream.runDrain). Stream interruption is now native via Fiber.interrupt. cleanup() handles all dangling state.
Cancel correctness — Introduces the Runner state machine with SynchronizedRef-based atomic transitions. Runner.cancel → Fiber.interrupt eliminates stale-cancel and lost-cancel races. ensureRunning coalesces concurrent callers via Deferred. Effect interruption propagates through Permission/Question Deferred.await.
Original failure modes → resolution
Lost cancel before start → Runner.ensureRunning + SynchronizedRef atomic state transitions prevent this.
Stale cancel aborting new generation → Runner uses fiber IDs; cancel only interrupts the current fiber.
Dangling permission/question waits after cancel → Effect's interruption model propagates through Deferred.await in Permission/Question services. addFinalizer rejects all pending on scope close.
Followup queue auto-fire (prompt_async) → Runner.ensureRunning coalesces concurrent callers (if running, joins via Deferred instead of racing). prompt_async.catch() handler (814a515a8a) catches errors and publishes them to the bus.
ESC-on-Question race → Question.reject() fails the specific Deferred with RejectedError; Runner.cancel interrupts the fiber. Either winning the race produces a clean outcome — processor sets ctx.blocked on RejectedError, or fiber interruption triggers cleanup. No hang, no unhandled error.
Closing PRs #20101 and #20102 as well — they targeted the old async/await architecture and are fully superseded.
Activity
github-actions commentedon Mar 30, 2026
This issue doesn't fully meet our contributing guidelines.
What needs to be fixed:
Please edit this issue to address the above within 2 hours, or it will be automatically closed.
This issue might be a duplicate of existing issues. Please check:
SessionPrompt.loop()callers hang forever on cancel/dispose — overlaps with the "dangling prompts" race condition described hereIf you believe this was flagged incorrectly, please let a maintainer know.
[-]Session cancel races: lost cancels, stale aborts, and dangling prompts[/-][+][BUG] Session cancel races cause lost cancels, stale aborts, and dangling waits[/+]github-actions commentedon Mar 30, 2026
Thanks for updating the issue! It now includes all the required information — a clear description of the race conditions, reproduction steps, OpenCode version, OS, and terminal. Everything looks good.
zswll2 commentedon Mar 31, 2026
Hi, I just closed #20250 as it overlaps with this issue. However, I'd like to add two pieces of new information that are NOT covered in the current description:
1. Todo Continuation as a Trigger Path (NEW)
This issue describes cancel races from manual user actions, but there's an automatic trigger path that also causes the same
UnknownError: prompt_async failed:Scenario: AI agent completes its agentic loop → session exits and gets cancelled → TUI detects remaining TODO items → auto-fires
prompt_async(Todo Continuation feature) → session is already in cancelled/idle state → UnknownError.This is distinct from the three failure modes listed because it's triggered by an automatic system mechanism (Todo Continuation), not a user action.
Log Evidence (session
ses_2c1595b7cffeNCN7U4nFQfGGda, log file2026-03-30T103243.log)This happened twice in the same session within ~27 seconds, both triggered by Todo Continuation.
2. ESC on Question — Full Log Timeline
For failure mode #3 ("Dangling permission/question waits after cancel"), here's the complete log timeline with millisecond precision (session
ses_2c13a106effeokLMx959pcfGgf, log file2026-03-30T124351.log):The key observation: the
abortandprompt_asynchappen within the same millisecond (13:10:54), suggesting the prompt_async is dispatched from a code path that doesn't check session state after abort completes.Hope this helps with the fix! The Todo Continuation path in particular may need its own state guard.
ESRE-dev commentedon Apr 1, 2026
Closing: Resolved by upstream Effect/Runner migration
All three failure modes described in this issue — plus the two additional trigger paths reported by @zswll2 — are now addressed by upstream's migration to Effect/Stream/Runner.
Upstream commits that resolve this issue
814a515a8aprompt_asyncfire-and-forget — Adds.catch()+Bus.publish(Session.Event.Error)to theprompt_asyncroute. Includes a test. Fixes @zswll2's "followup queue auto-fire" trigger path.860531c275for awaitwithEffect/Stream(Stream.runDrain). Stream interruption is now native viaFiber.interrupt.cleanup()handles all dangling state.c5442d418dRunnerstate machine withSynchronizedRef-based atomic transitions.Runner.cancel→Fiber.interrupteliminates stale-cancel and lost-cancel races.ensureRunningcoalesces concurrent callers viaDeferred. Effect interruption propagates through Permission/QuestionDeferred.await.Original failure modes → resolution
Runner.ensureRunning+SynchronizedRefatomic state transitions prevent this.Runneruses fiber IDs; cancel only interrupts the current fiber.Deferred.awaitin Permission/Question services.addFinalizerrejects all pending on scope close.@zswll2's additional trigger paths → resolution
prompt_async) →Runner.ensureRunningcoalesces concurrent callers (if running, joins viaDeferredinstead of racing).prompt_async.catch()handler (814a515a8a) catches errors and publishes them to the bus.Question.reject()fails the specificDeferredwithRejectedError;Runner.cancelinterrupts the fiber. Either winning the race produces a clean outcome — processor setsctx.blockedonRejectedError, or fiber interruption triggers cleanup. No hang, no unhandled error.Closing PRs #20101 and #20102 as well — they targeted the old async/await architecture and are fully superseded.