Skip to content

fix(core,sidecar): route sidecar events by VM ownership, not execution id alone - #1926

Open
WyvernMonarch wants to merge 1 commit into
rivet-dev:mainfrom
WyvernMonarch:pr/vm-ownership-routing
Open

fix(core,sidecar): route sidecar events by VM ownership, not execution id alone#1926
WyvernMonarch wants to merge 1 commit into
rivet-dev:mainfrom
WyvernMonarch:pr/vm-ownership-routing

Conversation

@WyvernMonarch

Copy link
Copy Markdown

The bug

AgentOs._handleSidecarEvent dispatches execution_output / execution_completed purely on executionId, over a shared sidecar client. Execution ids are minted per VM as operation-{now_ms:x}-{per-VM counter:x}, and the uniqueness loop only checks that VM's own map — there is no process-wide registry.

So two VMs whose Nth execution is admitted in the same millisecond mint the identical id. That is not an exotic race: the common fan-out (create K VMs together, each runs execution #1) puts every VM at the same counter ordinal simultaneously.

Impact when it fires: stdout/stderr chunks and completion (exit code, outcome) from VM B are delivered to VM A's handlers and process objects — a cross-VM data leak, not just a mixed-up log line.

The fix

Two layers, both small:

  1. Ownership guard at the single shared entry point. _handleSidecarEvent now drops any vm-scoped frame whose event.ownership.vm_id is not this instance's VM, before mapExecutionOutputEvent / mapExecutionCompletedEvent discard ownership. Because the guard sits in the one shared entry point, every id-keyed lookup downstream is VM-local by construction — including the in-flight "*" admission subscriptions, which needed no separate patch. Session- and connection-scoped events are not VM-specific and still pass through.

    This mirrors the pattern already used in the same file for the VM-readiness wait, which filters ownership.scope === "vm" && ownership.vm_id === nativeVm.vmId.

  2. Harden the mint at the source. language_execution.rs now formats the public id from a process-wide AtomicU64 instead of the per-VM counter, so ids are unique across VMs by construction. VmState::next_public_execution_id had no other reader and is removed.

Why it is safe

  • The guard only drops frames that provably belong to another VM; frames without vm scope are untouched, so session/connection lifecycle behaviour is unchanged.
  • The mint change alters only the opaque id's suffix source. Ids stay unique within a VM (the previous property) and become unique across VMs (the new one); nothing parses their contents.
  • cargo build -p agentos-native-sidecar is clean, and the new packages/core/tests/cross-vm-execution-event-isolation.test.ts covers the dispatch filter (2 tests, passing).

Found while running many concurrent VMs from one host process; happy to adjust the test's shape if you would rather drive it from a decoded EventFrame fixture than the instance-level probe.

…n id alone

odw-0a6. Every AgentOs built from one sidecar handle listens on the SAME
shared native process, so `_handleSidecarEvent` sees every tenant VM's
events. It dispatched purely on `executionId`, and the mappers
`mapExecutionOutputEvent` / `mapExecutionCompletedEvent` drop `ownership`
before the handler ever sees it — so a colliding id delivered VM B's stdout
and exit into VM A's handlers and language-process bookkeeping.

Collisions were not hypothetical: ids were minted as
`operation-{now_ms:x}-{counter:x}` from a PER-VM counter checked only
against that VM's own map, so K VMs fanning out all minted
`operation-{ms}-1` in the same millisecond.

Two fixes, both at the root rather than per call site:

- One ownership guard at the top of `_handleSidecarEvent`, before the
  mappers. Every id-keyed lookup downstream — including the in-flight
  `_onExecutionOutput("*")` / `_onExecutionCompleted("*")` admission
  subscriptions — is then VM-local by construction, so no per-caller
  filtering is needed. Session- and connection-scoped events are not
  VM-specific and still pass through.
- Mint from a process-wide `AtomicU64` instead of the per-VM counter, which
  closes the collision at the source. `VmState::next_public_execution_id`
  had no other reader and is deleted.

Also unblocks the `--lib` test build: `vm.rs` referenced
`crate::execution::javascript::rpc::error_code` through the private
`javascript` module, so `cargo test --lib` failed to compile on HEAD.
Routed through the existing `pub(crate)` re-export.
@NathanFlurry

NathanFlurry commented Aug 31, 2026

Copy link
Copy Markdown
Member

Related: #1892, #1899

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.

2 participants