Skip to content

sbx-mgr: suspend writes only over the state it expects - #414

Merged
ac-pplx merged 1 commit into
ac/sbxmgr-sw-pr2-pausefrom
ac/sbxmgr-sw-pr5b-suspend
Oct 14, 2026
Merged

ac-pplx merged 1 commit into
ac/sbxmgr-sw-pr2-pausefrom
ac/sbxmgr-sw-pr5b-suspend

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #417 (on #421, #416, #413, #418, #438 and main). Base: ac/sbxmgr-sw-pr2-pause at 51bee12. This PR's diff is one commit on top of it. Stack, bottom to top: #438 → #418 → #413 → #416 → #421 → #417 → #414. #372 and #388 have merged to main.

All state here is sbx-manager's node-local SQLite sandboxes.state, not the control-plane Postgres public.sandboxes.lifecycle.

What merging does

Suspend's auto-pause (prepare_suspend_snapshot_publication_after_locks in service/grpc_lifecycle/suspend.rs) runs for the Suspend RPC, auto-lifecycle suspend (reconcile.rs) and teardown suspend. It writes the row three times. After this PR each of those writes names the state it expects, and a row outside that from-set is refused with every row unchanged.

Call site Before After Edge
begin auto-pause, after reading started update_state(Pausing) update_state_from(&edges::PAUSE_BEGIN) Request, {started} → pausing
roll back a failed guest preparation update_state(Started) update_state_from(&edges::PAUSE_ROLLBACK) Compensation, {pausing} → started
complete once node-manager confirms the pause update_state(Paused) update_state_from(&edges::PAUSE_COMPLETE) Completion, {pausing} → paused

All three outcomes go through #438's shared require_state_write_from; this PR adds no mapping helper of its own. The rollback keeps a non-best-effort write: update_state_from_best_effort would also mark the sandbox dirty, which this site never did.

Suspend's guest preparation now goes through #417's prepare_guest_for_pause, which this PR makes pub(super) along with its test-only GUEST_PREPARATION seam. In production that function is exactly the previous call (prepare_guest_for_vm_snapshot_via_spaced_host), so this changes nothing outside tests.

Side columns, the caller's mutex and the lifecycle-lock scopes are unchanged. update_state_from adds its own IMMEDIATE transaction around its reads and the UPDATE in autocommit, which update_state did not; on the single connection that is harmless.

#417's fail-closed adapter on pause_with_reconciliation (suspend continues only on Ok(Paused)) is kept as #417 wrote it.

Who can move the row

All three writes run under the cleanup fence (try-locked), the wake lock and the artifact lock. Those exclude:

  • Stop, Delete and Kill admission and their cleanup completion, eviction, and cold-start activation (cleanup fence);
  • restore activation, pause and the process-snapshot rollback (artifact);
  • resident resume, wake, checkpoints, template snapshots and auto-lifecycle (wake).

Stop's stopping/stopped therefore cannot land during the auto-pause.

The one writer that could move a started or pausing row under these locks was warm-snapshot Start's unvalidated update_state(Suspended) (try_start_from_warm_snapshot; the WARM_START_UNVALIDATED known race). #418, below this PR, converted it: a warm Start now refuses a row outside created, stopped and suspended before staging, and its restage write applies only over created or stopped. No known writer can now move the row during the auto-pause. Production from_warm Starts come from create's own autostart and the direct substrate's Start right after create, both on a just-created row; a from_warm Start on a running row was in practice an operator CLI Start.

What the refusal buys

This is a defensive refusal, not a race fix. Should a writer move the row anyway, the refused row keeps that writer's state over a VM that is running (begin, rollback) or that node-manager has just paused (completion). That end state is no better than #388's, where the auto-pause overwrote it. #418 (PR 4, WARM_START_RESTAGE from {created, stopped}) closes the warm-start interleaving itself, and sits below this PR, so it lands first.

On this stack, together with #417 (whose PAUSE_ROLLBACK best-effort rollbacks and pausing-only quarantine compare-and-set cover pause_with_reconciliation), no write in the suspend auto-pause lands over a state other than the one it expects. Before #417, pause_with_reconciliation's best-effort started could still write suspended → started.

Behavior changes

  • Begin. A row another writer moved off started before the pausing write is refused. The RPC returns Aborted, and the row stays as that writer left it. Before, the row was overwritten with pausing and the suspend went on.
  • Rollback after a failed guest preparation. A row no longer pausing is not written started. The refusal is logged (failed to roll back checkpoint pause state), and the client still gets the guest-preparation Internal, unchanged. This differs from sbx-mgr: pause writes only over the state it expects #417's Pause RPC, which answers a superseded rollback Aborted. I kept suspend's answer because the guest-preparation failure is the error the caller acts on.
  • Completion. A row no longer pausing is not written paused. The RPC returns Aborted and does not begin the suspend; node-manager has already paused the VM. Before, the row was rewritten to paused and the suspend continued over the warm start's restaged snapshot.

Client-visible messages, compared with #388

Case Before After
begin or completion over a row outside the from-set write applied; no message Aborted: begin checkpoint pause: sandbox state write was superseded: sandbox is <state> / complete checkpoint pause: … (new; #438's canonical text)
guard refusal (quarantine, cleanup ownership), missing row Aborted: <context>: sandbox state write was not applied: blocked_by_quarantine / superseded_by_cleanup_episode / missing unchanged
database error Internal: <context>: <error> unchanged
rollback refusal (applied) logged only; client message unchanged (guest snapshot preparation before suspend failed: …)

Test

service/grpc_lifecycle/suspend/tests.rs::suspend_auto_pause_writes_only_over_the_state_it_expects drives the Suspend RPC on a started row. It sweeps every SandboxState at each of the three writes:

  • Begin. A test-only NodeManagerTransport holds suspend inside its node-manager connect, between the started read and the pausing write, while the test moves the row. Guest preparation then fails, so the accepting control (started) writes pausing and rolls back to started.
  • Rollback. The injected guest preparation moves the row and fails. The accepting control is pausing: it rolls back to started. Every other state keeps every row as moved, and the client still gets the guest-preparation Internal.
  • Completion. The injected guest preparation moves the row and succeeds. A FakeNodeManager confirms PauseSandbox. The accepting control is pausing: it writes paused, and a temporary trigger then rejects begin_suspend's suspending write, so the control stops before snapshot work (Internal, db error: <tripwire>).

Expected answers come from a wildcard-free per-state guard table written from #372's PLAIN grid, not derived from the writer. Refusals assert the exact Aborted message and that every row of every table is unchanged since the interleaving (assert_unchanged). The tripwire trigger is installed at all three exits, so a begin or rollback case that wrongly reached begin_suspend would fail on it rather than run snapshot work; it fires on no refused case. The test also asserts guest-preparation and node-manager call counts, and that the cleanup fence is released afterwards.

Host safety: in-memory SQLite, for_test_in(tempdir), the guest-preparation seam (no spaced-host socket is dialed) and a test-served node-manager. Every wait is bounded (30 s timeouts on the gate, the RPC and service shutdown), with no sleeps.

Edges and allowlist

  • Edges added or deleted: none. No KnownRace edge described these writes.
  • PAUSE_BEGIN's doc comment now says the suspend auto-pause writes after reading started; it never calls transition(Pausing). The PAUSE_COMPLETE and PAUSE_ROLLBACK comments from sbx-mgr: pause writes only over the state it expects #417 are accurate on this head and unchanged.
  • Parity allowlist: unchanged.

Pins

All green and unchanged: the suspend and teardown-suspend tests, #372's matrix, accepted-shape and thin-writer tests, #388's table, parity and edge-binding tests, and #417's pause supersession tests.

Overlaps

Findings

  1. CONFIRMED. sbx-mgr: add the persisted state-transition table and update_state_from (no callers) #388's edge table had no edge for suspended → pausing, suspended → started or suspended → paused, although the warm-start interleaving reached all three through the auto-pause. On this stack (with sbx-mgr: pause writes only over the state it expects #417) they are unreachable, so no edge is needed.
  2. A refused completion leaves a paused VM under a row another writer owns. That is the intended "refuse, don't overwrite" outcome. The only known such writer was the unvalidated warm start, which sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, fixes.

Mutation proof

Each mutation ran alone, from one backup, against the final head, and was restored before the next.

# Mutation Result
R All three writes restored to update_state (+ require_state_write), with guest preparation still on the seam fails (first: Begin, Creating)
1 Begin only restored to update_state(Pausing) fails
2 Rollback only restored to update_state(Started) fails
3 Completion only restored to update_state(Paused) fails
4 PAUSE_BEGIN's from-set set to StateSet::ALL fails
5 PAUSE_ROLLBACK's from-set set to StateSet::ALL fails
6 PAUSE_COMPLETE's from-set set to StateSet::ALL fails

The literal #417 suspend.rs is not used as the red baseline: its guest preparation would dial the host's spaced-host socket.

Validation of the restacked stack

The stack was restacked linearly on main (b18051f) after #372 and #388 merged: #438 → #418 → #413 → #416 → #421 → #417 → #414. The per-PR validation further down ran on this PR's pre-restack head.

This PR's head (9a0a251): pull CI is green. fmt, workspace clippy --all-targets --all-features -D warnings and cargo arch (0 required findings) all pass. SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0 passed 1650/1650 three times on this exact head, with 0 dependency skips. Kind Pull CI ran on #417, the PR below this one (all 4 shards passed). This PR's own Kind evidence is the Kind run on the top head below.

This PR is the top of the stack (9a0a251):

Check Result
sbx-mgr nextest ×3 1650/1650 each, 0 dependency skips
Whole-workspace nextest 8985/9021 passed, and every API crate passed. The 36 failures are all outside sbx-mgr. 34 fail identically on main: spaced-guest and spaced-host-cli process tests (the test box can't switch identity) and one net-gateway test. The other 2 are common-grpc retry-deadline tests that pass on rerun on both heads.
The stack's 30 new or changed tests, ×30 900/900
cargo test -p pplx-space-sbx-mgr (shared process) 1650/1650
Mutation spot-check on the top head #417's quarantine-miss mutation and #418's warm-start admission mutation are both caught
Kind, fresh cluster; the deployed sbx-mgr binary is from this head smoke-kind-e2e.sh passed. smoke-kind-typed.sh all: native 23/23; ECW 76 passed, 2 infra errors. local.ecw_template_build_from_oci_boots passed, and so did the leader-election brownout.
The 2 ECW infra errors node_group_boundary_schedules and node_selector_extras_render_and_enforce. Both scenarios restart the API Deployment, and the same failure family appears on main in an A/B on the same cluster. In the failed runs the kill never reached sbx-mgr.
Lifecycle race soak on Kind 11 concurrent operation pairs × 40 iterations, on the stack and on main on the same fresh cluster, checking that sbx-mgr's SQLite sandboxes.state, control-plane Postgres lifecycle and the VM agree. No change-induced violation: across 880 sandboxes, no quarantine, panic, restart or stuck state. Every violation reproduces on main with the same signature: an ECW pause that loses to Stop answers 503 instead of 409; after Suspend vs Delete, the node row can outlive the control-plane row until the next eviction pass (pass timing, not the build); and a refused quarantine write after Pause vs Delete is still logged. The soak's 0–200 ms API-level offsets did not reproduce the interleavings this stack closes, so the unit-level race tests remain the direct evidence for those.

Validation on the pre-restack head

Check Result
cargo fmt --all -- --check, scripts/check-rust-error-policy.sh pass
cargo clippy --workspace --locked --all-targets --all-features -- -D warnings pass (0 warnings)
cargo arch pass (exit 0); every finding is advisory and in a file this PR does not touch
pre-commit (Gitleaks) on the changed files pass
SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0 × 3, final head 1633/1633 passed each run; 0 dependency skips (SPACE_TEST_DEP_SKIP_LOG never written); 1 skipped = the #[ignore] privileged transfer_directory::tests::distinct_service_uids_can_exchange_downloads, unrelated
Mutation proof, final head all 7 caught (table above); restored, test green
Public-safety review of the diff, commit message and this body no findings in the reviewed scope
Kind: fresh cluster, final head none existed; created by smoke-kind-e2e.sh's bootstrap from this checkout
Kind: deployed binary provenance Kind's synced source of suspend.rs, pause_resume.rs and state_write.rs is identical to this head. The space-sbxmgr-svc artifact built during the bring-up has sha256 68c308ec…9aff, matching /opt/space/bin/space-sbxmgr-svc in both space-sandbox-manager and both builder sandbox-manager pods. The stack's service logs (node-ctl's, for one) report version 4d190bc08ed8, this head
Kind: Tilt quiet 50 resources: 46 ok, 4 not_applicable, none building or queued
smoke-kind-e2e.sh (DATA_PLANE_URL set as CI does) PASS
smoke-kind-typed.sh all (ECW + native; SPACE_SMOKE_KIND_COLD_RECEIPTS and admin token file set as CI does) ECW: 75 passed, 2 failed, 1 inconclusive; native: 23 passed, 0 failed
Rerun once: local.node_group_boundary_schedules, local.rate_limiters_enforce_and_fail_open both PASS on rerun. ENVIRONMENTAL: in each, the scenario had just rolled space-api with new env, and the client's create got no response within the scenario's timeout (status 0). Sandbox-manager's log shows the timed-out create admitted and its guest ready within about 2 s. Both passed in round 0 on this harness
Rerun once: local.ecw_template_build_from_oci_boots same INFRA_ERROR (build-manager storage admission needs 55834574848 bytes, finds about 15.3 GB). ENVIRONMENTAL: builder storage on this devbox, as in round 0; no sandbox-manager path
Kind teardown infra/space/kind/scripts/teardown.sh: pass; no clusters, containers or Tilt watcher left

Decisions made without an operator

  • Existing edges, no new ones. Their from-sets are exactly what the auto-pause expects.
  • Rollback answer kept as Internal (see Behavior changes).
  • No SuspendFailed event or create_failed metric on a refused begin or completion. Guard refusals at these sites have never emitted one. Adding a new failure-reason label is a telemetry-vocabulary change better made for every refusal arm at once, so I left it out.
  • Seam visibility. prepare_guest_for_pause, GuestPreparation and GUEST_PREPARATION become pub(super) within grpc_lifecycle rather than moving, to keep sbx-mgr: pause writes only over the state it expects #417's file stable.
  • Disk. Before each fresh Kind bring-up I ran docker builder prune -af and docker image prune -f, as authorized. No containers were running; volumes were untouched.
  • Kind prerequisites. The devbox lost its AWS CLI overnight, so I re-ran the harness's own install-prereqs.sh (INSTALL_DIR=~/.local/bin).

🤖 Generated with Claude Code

@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-sw-pr5b-suspend branch from 0599037 to 4d190bc Compare October 14, 2026 02:09
@ac-pplx
ac-pplx changed the base branch from ac/sbxmgr-state-write-pr1-helper to ac/sbxmgr-sw-pr2-pause October 14, 2026 02:09
Suspend's auto-pause writes sbx-mgr's node-local SQLite `sandboxes.state`
three times while it holds the cleanup fence and the wake and artifact
locks: `pausing` after it reads the row as `started`, `started` to roll
back a failed guest preparation, and `paused` once node-manager confirms
the pause. Each was a generic `update_state`, which overwrites any state
its cleanup and quarantine guards admit. This is not the control-plane
Postgres `public.sandboxes.lifecycle`.

Those locks exclude every writer of these states except warm-snapshot
Start, which takes no lock. Before its conversion (`WARM_START_RESTAGE`,
below this commit), a `from_warm` Start on a running row (in practice an
operator CLI Start) could land between suspend's read and any of the three
writes, which then wrote `pausing`, `started` or `paused` over its
`suspended` row. The converted warm start refuses a row outside `created`
and `stopped`, which closes that interleaving.

The three writes now go through `update_state_from` with the existing
edges `PAUSE_BEGIN` ({started} -> pausing), `PAUSE_ROLLBACK`
({pausing} -> started) and `PAUSE_COMPLETE` ({pausing} -> paused), and
their outcomes through the shared `require_state_write_from`. A row
outside the from-set is refused with every row unchanged. This is a
defensive refusal: no known writer reaches these writes. Were one to, the
refused row would keep that writer's state over a running or paused VM, an
end state no better than an overwrite.

Begin and completion answer a refusal `Aborted`: "<context>: sandbox
state write was superseded: sandbox is <state>", or `update_state`'s
guard message when a generic guard answered first. The rollback still
logs the refusal and answers the guest-preparation `Internal`. Side
columns, the caller's mutex and the lifecycle-lock scopes are unchanged;
`update_state_from` adds its own IMMEDIATE transaction around its reads
and the UPDATE in autocommit, which is harmless on the single connection.

Suspend's guest preparation now goes through pause's
`prepare_guest_for_pause`, so the test-only guest-preparation seam covers
the auto-pause too. `PAUSE_BEGIN`'s doc comment says suspend reads
`started` rather than calling `transition`. No edge is added or deleted
and the parity allowlist is unchanged. The auto-pause was the last
production caller of the legacy `require_state_write`, which only the
`update_state` parity tests still call, so it now expects `dead_code`
outside tests.

The new test drives the Suspend RPC and sweeps every `SandboxState` at
each write: between the read and the begin (holding node-manager connect),
and during guest preparation before the rollback and the completion. The
accepting controls are `started` at the begin and `pausing` after it. A
trigger on the `suspending` write, installed in every case, stops the
completion control at the next write, before snapshot work, and fails any
other case that gets that far. On the previous writes every non-guarded
state was overwritten at each of the three.

@nikhilgupta-pplx nikhilgupta-pplx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Suspend holds the cleanup fence (try-acquired, so Stop/Delete win) plus wake and artifact locks, so no known writer reaches these writes; edges match the gating, and the auto-pause fails closed on anything but a confirmed paused.

Non-blocking:

  • The auto-pause refusal (suspend.rs ~441) and the guest-prep rollback refusal (~371-395) still answer Internal, while begin/completion and #417's Pause answer Aborted. Unreachable under current locks; a one-line comment on why (or aligning to Aborted) would keep suspend and Pause consistent.
  • At the stack top, require_state_write survives only for parity tests via expect(dead_code); consider moving it to #[cfg(test)] or calling require_applied() directly.

Stack-level: no code blockers. The lifecycle race soak is a merge gate for you to close out, separate from this review.

@ac-pplx
ac-pplx added this pull request to the merge queue Oct 14, 2026
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 2 findings

Low

  1. crates/libs/sbx/mgr/server/src/service/grpc_lifecycle/suspend.rs:357 — the new comment gives warm-snapshot Start as the writer these refusals guard against, but on this stack it can't reach these writes.
    The comment says the held locks "do not exclude a writer that takes none of them, such as warm-snapshot Start". That's true of the locks. But WARM_START_RESTAGE (db/state_write.rs:199) only writes over created or stopped, so it can never move a started or pausing row. The test module's own doc (suspend/tests.rs:5-8) says this correctly: no production writer is known to reach these writes. A reader of the production comment will go looking for a warm-start interleaving that no longer exists. Suggest rewording it to match the test doc: this is a defensive refusal against a writer that takes none of these locks, and no such writer is currently known to reach a started or pausing row.

  2. crates/libs/sbx/mgr/server/src/service/grpc_lifecycle/suspend.rs:362 and :449 — a refused begin or completion leaves snapshot::create_started without a matching outcome.
    Both writes exit through a bare ?, after create_started has fired (suspend.rs:328). The node-manager connect, guest preparation and pause failures in the same block all call create_failed and emit_suspend_failed_event. You priced the missing SuspendFailed event and failure label under "Decisions made without an operator", and the reasoning holds: a new label is better added for every refusal arm at once. But the cost the body doesn't name is that a started-but-never-finished suspend counts toward neither side of the started/failed ratio. Until that follow-up lands, a metric reader can't tell such a suspend from one still in flight. Not a blocker, since no known writer reaches these refusals today. Worth naming in the follow-up's scope.

What I checked and found sound:

  • Production behaviour of prepare_guest_for_pause (pause_resume.rs:58). Outside cfg(test) it calls prepare_guest_for_vm_snapshot_via_spaced_host with the same arguments, so the guest preparation is unchanged.
  • The three edges' from-sets: PAUSE_BEGIN {started}, PAUSE_ROLLBACK and PAUSE_COMPLETE {pausing}. Each matches what the auto-pause left at that write.
  • pause_with_reconciliation returns Paused for its caller to complete and doesn't write paused itself, so the new PAUSE_COMPLETE write doesn't trip over its own pause.
  • The lock claim. prepare_suspend_snapshot_publication_after_locks takes the cleanup fence by try-lock (suspend.rs:263) while holding the wake and artifact guards, so Stop, Delete and Pause are excluded during the auto-pause.
  • That begin_suspend (db/state_transitions.rs:966) still compare-and-sets on state = 'paused', so the gap between completion and the suspend write stays guarded.
  • That no production caller of require_state_write remains on this head, so the new expect(dead_code) is fulfilled.
  • That the rollback's non-best-effort write keeps the old no-dirty-mark behaviour. Only update_state_from_best_effort calls mark_sandbox_dirty (cleanup_support.rs:353).

I deliberately left out the refused-rollback answer (Internal, not Aborted) and the paused VM under a refused completion. The description prices both, and the cleanup fence excludes the concurrent Stop that would make the first one visible.

Verified against 9a0a2513656b. Reproduced locally: cargo clippy -p pplx-space-sbx-mgr --all-targets --all-features -D warnings (clean) and cargo nextest run -p pplx-space-sbx-mgr -p pplx-space-db-test-fixtures --all-features (1661 passed, 0 failed) at this head.

@the-gigi-pplx the-gigi-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. 2 non-blocking findings in the review comment above.

Merged via the queue into main with commit e97db3b Oct 14, 2026
20 checks passed
@ac-pplx
ac-pplx deleted the ac/sbxmgr-sw-pr5b-suspend branch October 14, 2026 20:52
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.

3 participants