Skip to content

bug(cli): TUI interrupt waits behind queue RPCs and terminal cleanup while still rendering Working #3698

Description

@MoonOld

Problem

The TUI interrupt path is not cancellation-first. Once the double-Escape gesture is recognized, requestTurnInterrupt() marks local state but serializes the actual stop behind queue convergence:

await settlePendingEnqueues();
const retracted = (await input.driver.retractQueued?.()) ?? '';
const fallback = await takePendingFallbackSettled();
// ...refill editor...
await input.driver.stop();

This is in packages/cli/src/pi-tui-runner.ts around the requestTurnInterrupt implementation.

settlePendingEnqueues() does not wait for the model provider response. It waits for outstanding Runtime Host turn.message.submit RPCs used by Steer/Queue. Those operations normally only mutate queue/admission/receipt state, but an RPC delayed by transport, Session admission, storage, or a fallback retry becomes an unbounded barrier in front of stop. queue.retract then adds a second Host round trip before cancellation is even requested.

After stop is finally sent, the TUI driver uses turn.stop. Runtime Host requestStop() awaits stopRoot(), and stopRoot() awaits active.done, so the control response includes backend abort, active tool cleanup, process termination grace, I/O drain, and durable terminal publication. During that entire interval the TUI continues to render Working…; there is no Cancelling… state.

This combines two distinct delays into one apparently ignored keypress:

  1. pre-stop delay: pending enqueue settlement + queue retract;
  2. post-stop delay: tool/backend cleanup + durable terminal convergence.

The Host already exposes the atomic turn.interrupt operation, which commits the queue stop fence, retracts queued entries, and owns the matching root Turn. Desktop already uses it in createRuntimeHostSessionStop; the TUI still composes queue.retract and turn.stop client-side.

User-visible trigger

The interrupt binding is itself difficult to discover: a running Turn requires two Escapes within 600 ms. The first Escape has no visible effect or hint. The binding appears only in /help, not in the startup guidance or running footer. This overlaps the discoverability discussion in #3538; this issue focuses on cancellation ordering, acknowledgement, and feedback rather than keybinding selection.

Ctrl+C is not a safe discoverability fallback because it also owns process-exit behavior: a second press while interruption is converging exits, and two presses while idle exit the TUI.

Reproduction and measurements

Tested with the installed Maka 0.2.0 TUI in a fresh tmux session on macOS, using qwen3.8-max through alibaba-token-plan-cn.

Provider stream

  1. Start a prompt and interrupt while the first model response is pending.
  2. Send Esc twice within 600 ms.

The durable aborted event was committed within the measurement resolution of the second Escape, and the TUI returned to idle in under 170 ms. This confirms the basic provider abort path can be fast.

Cooperative Bash process

  1. Ask the agent to execute sleep 120.
  2. Wait for the Bash tool to show running.
  3. Press one Escape: after one second the process is still alive and no stop was issued.
  4. Press Escape twice within 600 ms.

Observed after the second Escape:

  • child process exit: approximately 63 ms;
  • TUI idle: approximately 132 ms;
  • abort_requested to tool_completed: 20 ms;
  • abort_requested to durable aborted: 27 ms.

Process ignoring SIGTERM

Repeat with:

trap '' TERM; sleep 119

Observed after the second Escape:

  • child process group exit: approximately 2.13 s;
  • TUI idle: approximately 2.20 s.

This matches DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000. The grace may be intentional, but rendering Working… throughout makes it look as though interrupt was not accepted.

Missing deterministic regression case

A focused TUI test can expose the pre-stop barrier without timing or a real provider:

  1. use a driver whose steer()/queueMessage() returns a Promise that does not settle;
  2. submit a mid-Turn message so it enters pendingEnqueueTasks;
  3. trigger interrupt;
  4. assert stop/interrupt authority is invoked promptly.

Today step 4 never occurs because settlePendingEnqueues() waits first.

Expected behavior

  • User interrupt has control-plane priority over pending message submission.
  • Acceptance is visible immediately, independently of backend/tool cleanup.
  • Queue contents are not lost or duplicated when enqueue and interrupt race.
  • The running footer changes from Working… to Cancelling… as soon as the gesture is accepted.
  • Terminal durability remains authoritative; fast acknowledgement must not fake completion.

Proposed direction

  1. Give the TUI driver a first-class interrupt method backed by Host turn.interrupt, rather than composing queue.retract followed by turn.stop.
  2. Commit the stop/queue fence before waiting for client-side enqueue Promises. Host serialization can determine whether an enqueue committed before the fence and is returned as retracted, or lost the race and must be restored from its failed client request.
  3. Split interrupt acceptance from terminal convergence if needed: return/emit an accepted or stopping snapshot once the stop fence is durable and abort delivery has begun; observe the final terminal through the existing subscription.
  4. Render local Cancelling… immediately from interruptRequested, with elapsed cleanup time if convergence is slow.
  5. Keep graceful process cleanup, but consider a shorter user-stop grace or an explicit force-stop escalation separately; it should not be required to fix acknowledgement and feedback.

Acceptance criteria

  • A never-settling enqueue request cannot prevent interrupt dispatch.
  • Interrupt + enqueue races preserve each queued message exactly once, either in Host retraction output or restored client-side.
  • TUI shows Cancelling… in the same render tick as interrupt recognition.
  • Cooperative provider and Bash cancellation remain sub-second in integration coverage.
  • A SIGTERM-resistant process may take the configured grace, but the UI truthfully reports cancellation in progress.
  • TUI and Desktop use the same Runtime Host interrupt authority instead of divergent client-composed sequences.

Related: #3538 (composer reachability/discoverability), #3556 (queue-code simplification).

Investigated with AI assistance. Runtime behavior was verified against local source and a real tmux TUI run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions