Skip to content

fix: settle cancel-during-stream stalls (readiness livelock, heap-guard wedge, buffered durable updates) - #1917

Open
WyvernMonarch wants to merge 4 commits into
rivet-dev:mainfrom
WyvernMonarch:fix/readiness-target-missing-livelock
Open

fix: settle cancel-during-stream stalls (readiness livelock, heap-guard wedge, buffered durable updates)#1917
WyvernMonarch wants to merge 4 commits into
rivet-dev:mainfrom
WyvernMonarch:fix/readiness-target-missing-livelock

Conversation

@WyvernMonarch

Copy link
Copy Markdown

Summary

Cancelling a prompt while a bash or filesystem stream is active can stall the session until the host force-disposes the VM. In our stress reproducer (5 cancel iterations per run, linux/arm64 containers) the stall reproduced in 18 of 40 baseline runs (~45%); with this branch it is 0 of 32, and a cancellation/accounting probe went from 12/20 to 20/20 GO.

The stall turned out to be a chain of four defects; each fix unmasked the next, so they are validated together but committed separately.

1. fix(v8-runtime): bound unacknowledged readiness retries — the livelock

The readiness broker is level-triggered: complete_wake (crates/runtime/src/readiness.rs) re-arms a wake whenever a capability still has non-empty flags, and flags clear only on acknowledgement. dispatch_ready_batch_callbacks (crates/v8-runtime/src/session.rs) deliberately left a TargetMissing observation unacknowledged, assuming the miss was transient. When the guest's dispatch map permanently lacks the capabilityId:capabilityGeneration key (teardown raced the flags, or readiness was published before registration with a generation that never matches), the result is an infinite hot loop: wake → miss → unacknowledged → instant re-arm — a full core spun, the turn never reaches a terminal boundary, and session/cancel never settles.

The fix tracks consecutive misses per (capability, generation, revision) and acknowledges on the second miss of the same revision, so the loop dies. A genuine transient race keeps one free retry, and a live capability recovers regardless: new data bumps the revision and republishes readiness (mark_ready bumps unconditionally, and a stale-revision ack is a no-op, so no real wake can be lost).

Regression test (no VM needed): a readable capability with interest enabled and no JS registration; asserts Delivered/Rearmed wake metrics stop growing after the bounded miss (they grow without bound before the fix) and that delivery resumes after the target registers and new data is published.

2. fix(native-sidecar): retire readiness capabilities on socket teardown

Teardown hygiene for the same machinery: dropping a socket's readiness registration now also retires the capability broker-side (remove_capability), instead of leaving level flags behind for a target that will never return. Unit-tested at the unregister seam.

3. fix(v8-runtime): stop the heap guard from silently wedging a guest

Found while validating: V8's near-heap-limit callback fired for a healthy guest at its high-water mark and terminated it mid-frame; when the termination lands outside Execute, the isolate is left terminating and every later guest call returns nothing — which the readiness turn then reads as a missing dispatch target (feeding defect 1), while the session sits idle forever with no error on any lane. The fix grants the headroom the callback already computes and terminates only when the raised limit is also exhausted, and surfaces an out-of-band termination observed during a readiness dispatch as a terminated execution instead of silence.

4. fix(acp): stream durable session updates that follow a message chunk

DurableUpdateSink::handle_notification buffered every non-message session/update once a message chunk had opened the completion buffer, committing only at the next message boundary. An agent that streams a text banner before calling a tool therefore withheld tool_call_update { in_progress } (and everything else) until the tool finished — so a caller waiting for a live turn before cancelling raced prompt completion and got no_active_prompt. The fix commits the in-progress text run first, then the update; the durable sequence is unchanged, only batched smaller. The regression test fails on the old behavior with the in_progress update arriving at 2.06 s of a 2.06 s turn.

Validation

  • Unit red/green for defects 1 and 4 (each regression test fails with only its fix reverted, passes at HEAD); cargo test green across agentos-runtime (60), agentos-v8-runtime --lib (155), native-sidecar state:: (8), agentos-sidecar suites; rustfmt --check clean on all touched files.
  • End-to-end, linux/arm64 release containers, same environment for baseline and fix: stall reproducer 18/40 stalls → 0/32; cancellation/accounting probe 12/20 → 20/20 GO; ACP accounting integration tests green.

Notes for maintainers

  • Not addressed here, flagged for a follow-up: forward_runtime_wake_locked (crates/v8-runtime/src/session.rs) try_recvs a wake from the capacity-1 lane and drops it if the capacity-1 executor lane is full, while the broker stays Outstanding — a silent-coalesce liveness hazard. We could not construct a reproducing schedule, so it is intentionally left out of this PR.
  • session::tests::begin_destroy_session_removes_entry_before_finish is flaky under heavy machine load at base (fails ~7/10 under load, 155/155 when idle, untouched by this diff) — likely worth its own issue.

🤖 Generated with Claude Code

Yuriy Butenko added 4 commits August 27, 2026 17:44
The session readiness broker is level-triggered: `complete_wake` rearms a
wake whenever any capability still carries flags, and flags only clear on
an explicit acknowledgement. `dispatch_ready_batch_callbacks` deliberately
left a `TargetMissing` observation unacknowledged, assuming the guest was
merely mid-registration.

When the target never appears — teardown already ran
`unregisterCapabilityReadiness`, or the capability generation is
permanently stale — that assumption livelocks the session: dispatch misses,
the observation stays unacknowledged, `complete_wake` rearms immediately,
and the loop spins at full CPU without ever reaching a turn boundary, so
the turn never terminates and cancellation never settles.

Give a missing target exactly one free retry instead. The registration race
the old behavior tolerates resolves within a single wake; the second
consecutive miss of the same revision is acknowledged, which clears the
level flags and lets the loop idle. A capability that registers later still
receives delivery, because new data bumps the revision and republishes
readiness.

The regression test drives the same bookkeeping without a V8 isolate and
asserts the wake counters stop growing. Reverting the acknowledgement makes
it spin: ~640k wake/batch/complete cycles per second on one capability.
Dropping a socket alias removed its readiness subscriber but left the
capability in that VM's readiness broker. The guest runs
`unregisterCapabilityReadiness` on the same teardown, so any level flags
still held for that capability now have no reachable dispatch target: they
keep the session's wake lane armed and the broker's handle table populated
against a capability that can never be acknowledged normally.

Hand the removed target back out of `unregister` so the RAII drop can also
call `remove_readiness`, which clears the broker entry through the existing
`SessionReadyBroker::remove_capability`. Re-registration is unaffected: it
swaps the previous identity directly and never routes through `unregister`.
V8 raises the near-heap-limit callback as soon as a full GC cannot fit the
live set under the configured cap. A healthy guest sitting at its high-water
mark reaches that without being a heap bomb, and terminating there kills
guest JS mid-frame: the termination exception is uncatchable, skips `finally`
so bridge invariants latch, and — when it lands outside an `Execute`, the only
place that calls `cancel_terminate_execution` — leaves the isolate terminating.
Every later guest call on that isolate then returns nothing, which the
readiness turn reads as a missing dispatch target and retires the capability's
level flags against. The session does not fail: it goes idle forever, with the
sidecar parked and no error on any lane.

Grant the headroom the callback already returns before terminating, and
terminate only when the heap reaches the raised limit too. The guest stays
bounded at `limit + NEAR_HEAP_LIMIT_HEADROOM_BYTES` — tighter than before,
where every callback both terminated and raised the limit again. Also surface
an out-of-band termination observed during a readiness dispatch as the
termination it is, so a guest that genuinely cannot fit reports a terminated
execution instead of idling.
`DurableUpdateSink::handle_notification` buffered every non-message
`session/update` — `tool_call`, `tool_call_update`, `plan`, `mode`,
`available_commands_update` — whenever a message chunk had already opened
the completion buffer, and committed the buffer only at the next message
boundary.

Any agent that streams text before calling a tool therefore withholds its
whole durable stream until the *post-tool* message arrives. Pi prints a
banner chunk on some turns, so roughly a third of prompt turns delivered
`tool_call_update { in_progress }` for a `sleep 60` only when the command
finished, together with every other update of the turn. A caller that
waits for that boundary before cancelling never observes a live turn:
`session/cancel` then races prompt completion and reports
`no_active_prompt`.

Commit the in-progress text run first, then the update. The durable
sequence is unchanged — `coalesce_completed_message` already ends the
text run it is building at any non-text update, so the same events are
emitted in the same order, only in more (smaller) batches — and the
update now reaches the host the moment the adapter produces it.

The regression test drives an ACP adapter that emits one message chunk,
then the tool updates, then holds the turn open for two seconds. It
records the live event sink and fails if the `in_progress` update lands
at turn end: on the previous behavior it arrives at 2.06s of a 2.06s
turn.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant