Skip to content

[BUG] Session cancel races cause lost cancels, stale aborts, and dangling waits #20095

Closed

Description

@ESRE-dev

Description

Session cancellation has multiple race-condition failure modes:

  1. Lost cancel before start
    If cancel() is called before start() initializes session state, the cancellation is dropped and the session still runs.

  2. 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.

  3. 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

  1. Start a prompt that triggers tool use and session loop activity.
  2. Immediately cancel before the first response is fully initialized.
  3. Re-send a prompt quickly in the same session.
  4. 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.

Screenshot and/or share link

N/A

Operating System

macOS 15.4.1 (also seen in Linux CI)

Terminal

Ghostty

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Mar 30, 2026
  2. github-actions commented on Mar 30, 2026

    @github-actions
    Contributor

    This issue doesn't fully meet our contributing guidelines.

    What needs to be fixed:

    • 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:

    If you believe this was flagged incorrectly, please let a maintainer know.

  3. 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
  4. github-actions commented on Mar 30, 2026

    @github-actions
    Contributor

    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.

  5. zswll2 commented on Mar 31, 2026

    @zswll2

    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 file 2026-03-30T103243.log)

    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.

  6. ESRE-dev commented on Apr 1, 2026

    @ESRE-dev
    Author

    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

    Commit Date What it fixes
    814a515a8a Mar 24 prompt_async fire-and-forget — Adds .catch() + Bus.publish(Session.Event.Error) to the prompt_async route. Includes a test. Fixes @zswll2's "followup queue auto-fire" trigger path.
    860531c275 Mar 28 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.
    c5442d418d Mar 30 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

    1. Lost cancel before start → Runner.ensureRunning + SynchronizedRef atomic state transitions prevent this.
    2. Stale cancel aborting new generation → Runner uses fiber IDs; cancel only interrupts the current fiber.
    3. 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.

    @zswll2's additional trigger paths → resolution

    1. 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.
    2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions