Repository navigation
sbx-mgr: wake and resident resume write only over the state they expect - #413
Conversation
61c5da8 to
5b14c80
Compare
5b14c80 to
bce3bb5
Compare
Every state named here is sbx-mgr's node-local SQLite `sandboxes.state`,
not the control-plane Postgres `public.sandboxes.lifecycle`.
A resident resume without an activation policy (the Resume RPC without
`activation_auto_lifecycle`, and Wake of a paused sandbox without one)
reads `paused`, runs its pre-wake network policy gate, and then wrote
`resuming` through the generic `update_state`, which has no expected-state
predicate. Stop takes neither the wake nor the artifact lock, and its
runtime gate admits the paused VM because its `fc.pid` resolves, so Stop
can settle `stopped` in that window. The resume then wrote `resuming` over
`stopped`, asked node-manager to thaw a VM that no longer exists and, with
the VM unobservable, quarantined the sandbox as `resident_resume_failed`.
The write now goes through `update_state_from(&edges::RESUME_BEGIN)`
(`{paused} -> resuming`), in a `begin_resident_resume` step that also
holds the unchanged policy branch, and maps its outcome with the shared
`require_state_write_from`. A row outside the from-set is refused with
every row unchanged, and the resume returns `Aborted` ("begin resident
resume: sandbox state write was superseded: sandbox is <state>") before
any node-manager call. Guard refusals and a missing row keep their
`Aborted` messages. The callers' mutex and lifecycle-lock scopes are
unchanged; in autocommit the helper adds its own IMMEDIATE transaction
around its reads and the UPDATE, which is harmless on the single
connection.
- Delete the `RESUME_BEGIN_AFTER_STOP` known-race edge and its
`stopped -> resuming` parity allowlist entry.
- Move the begin step into `service/auth_lookup/resume_begin.rs` with a
test-only barrier, and lower `auth_lookup.rs`'s archcheck cap from 1237
to 1221.
- Test a Resume RPC held between its gate and its write while the row
moves to each `SandboxState` or is deleted, with `stopped` reached by
Stop's own admission and completion writers. Only `paused` resumes;
every other case is `Aborted` with its exact message, every row
unchanged and no node-manager call. With the generic write restored,
the `stopped` case thaws and quarantines the sandbox.
bce3bb5 to
38e424c
Compare
nikhilgupta-pplx
left a comment
There was a problem hiding this comment.
Approve. {paused} is exactly what transition(Resuming) admits, the refusal fires before any node-manager call or watchdog edge, and side columns match update_state. Removing RESUME_BEGIN_AFTER_STOP and its allowlist pair is consistent.
Non-blocking (description only): the client-visible section covers the native API 500→409, but the policy-free branch is also reached by Wake and implicit command/access activation (activation.rs). There a resume losing to Stop now returns Aborted instead of quarantining + Internal; the data plane treats Aborted as retryable and cold-starts on retry, so it is an improvement. Worth one line in the body.
Review — 5 findingsThe fix does what it says: Low
What I checked and found sound: the pre-gate Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 5 non-blocking findings in the review comment above.
Stacked on #418 (on #438, on main). Base:
ac/sbxmgr-sw-pr4-create-warm-start@6a1c00f. Review #438 and #418 first; this PR is one commit on top of #418. Stack, bottom to top: #438 → #418 → #413 → #416 → #421 → #417 → #414. #372 and #388 have merged to main.Every state named here is sbx-manager's node-local SQLite
sandboxes.state, not the control-plane Postgrespublic.sandboxes.lifecycle.This is plan PR 3 (
reports/state-write-helper-plan.md§5): it fixes §3 finding 2.What merging does
Call sites converted
resume_after_wake_lock, the branch without an activation policy (service/auth_lookup.rs, previously ~812)require_state_write(update_state(&db, id, Resuming), "begin resident resume")require_state_write_from(update_state_from(&db, id, &edges::RESUME_BEGIN), "begin resident resume"), returned directly, in the newbegin_resident_resume(service/auth_lookup/resume_begin.rs), using #438's shared helperRESUME_BEGIN: Request,{paused} → resuming(existing, unchanged)resume_after_wake_lockvalidatespausedwithtransition(Resuming)before the gate, sopausedis exactly what the write legitimately expects.Other lines named in the task, traced:
stable_policy_update_stateand ~987set_auto_lifecycle_policy_exactdo not writesandboxes.state. One is a predicate; the other gates anauto_lifecycle_*column update (update_auto_lifecycle_with_state) behind that predicate under the wake lock. Nothing to convert.begin_resident_resume_with_policy(db/state_transitions.rs), moves intobegin_resident_resumeunchanged. It already re-readspausedand the execution generation inside its ownIMMEDIATEtransaction before its internalupdate_state(Resuming), so it cannot race. It is left as is: converting that internal call is behavior-preserving, but would growstate_transitions.rspast the cap sbx-mgr: add the persisted state-transition table and update_state_from (no callers) #388 just lowered. It remains for PR 6, which retiresupdate_state.update_state/update_state_best_effortcaller lives inauth_lookup.rs.Locks, transactions and side columns. The callers' mutex and lifecycle-lock scopes (wake and artifact locks, held by the Resume RPC and the Wake paths) are unchanged.
update_state(Resuming)ran as autocommit statements. In autocommit,update_state_fromopens its ownIMMEDIATEtransaction around its guard reads and theUPDATE, which is harmless on the single connection. The policy branch keeps its ownIMMEDIATEtransaction. Side columns areupdate_state's:update_state_fromclears the same failure reason, restore columns and non-matching receipts.Race fixed: plan finding 2
The race. A Resume without
activation_auto_lifecycle, or a Wake of a paused sandbox without an activation policy, works like this:paused.resumingwith the generic writer.Stop takes neither the wake nor the artifact lock. Its runtime gate admits a sandbox whose
fc.pidresolves, which a paused VM's does, and it can settlestoppedbetween steps 1 and 3. The resume then wroteresumingoverstoppedand asked node-manager to thaw the VM. With the VM gone and unobservable,finish_resident_resume_after_rpc_errorquarantined the sandbox asresident_resume_failed.Now the write refuses any row that is not
paused. The resume returnsAbortedbefore any node-manager call, with every row unchanged. The Stop'sstoppedrow stands.Red/green test.
service::auth_lookup::resume_begin::tests::policy_free_resume_writes_resuming_only_over_paused:task_localbarrier (precedent:cleanup_executor.admission_barrier) holds the resume between the gate and the write. Node-manager is aFakeNodeManagerthat answers onlyResumeSandbox, with the error a VM that is gone gets.SandboxState, or deleted withdb::delete_sandbox. The expected outcome comes from a wildcard-free table.stoppedis reached by Stop's own writers, sbx-mgr: characterize the state writers before the state-write helper (tests only) #372's documented sequence:update_state_superseding_activation_in_transaction(Stopping)plusset_cleanup_intent(mark_stopped), thencomplete_row_intent_cleanup_for_job(MarkStopped).paused(the accepting control) resumes: the write applies and node-manager receivesResumeSandbox.Abortedwith its exact message, leaves the whole database unchanged (sbx-mgr: characterize the state writers before the state-write helper (tests only) #372'ssnapshot), and makes no node-manager call.update_statecall restored): the 12 states the generic guards admit all fail. A disposable probe restricted tostoppedshowed the exact finding: the row endsquarantinedwithresident_resume_failed, node-manager receivedPrepareSandboxArtifactDir,ResumeSandboxandGetFirecrackerVmState, and the client gotInternal "resume failed: …".Behavior changes
pausedresuming, thawsstopped(the race), andcreating,created,starting,started,pausing,resuming,checkpointing,suspending,suspended,restoring,failed, when no cleanup episode owns the rowresumingand tried to thaw. Depending on node-manager's answer the row endedstarted, rolled back topaused, or was quarantinedresident_resume_failed(Internal/Unavailable)Aborted,begin resident resume: sandbox state write was superseded: sandbox is <state>; no row change and no node-manager callstopping,deleting,cleanup_required,cleanup_failed, and any row a cleanup episode owns (in production astartingorrestoringrow whose activation episode keeps the row)Aborted,begin resident resume: sandbox state write was not applied: superseded_by_cleanup_episodequarantinedAborted,…: blocked_by_quarantineAborted,…: missingReachability. Under the callers' wake and artifact locks, the reachable entries in the second row are
stopped(this race) and, in principle, rows a Stop and a later cold Start moved on:started, or astartingrow, which answers through thesuperseded_by_cleanup_episodeguard path while its activation episode owns it. The other entries are listed because the from-set now refuses them. The test forces those states without episodes, so it sees the from-set answer.Client-visible changes:
Aborted,begin resident resume: sandbox state write was superseded: sandbox is <state>(sbx-mgr: one refusal-to-status mapping for update_state_from #438's canonical text). It replaces a thaw attempt that ended inInternal,Unavailableor success, depending on node-manager. Every other resume message is unchanged. The policy branch still answersFailedPrecondition "resident execution changed before resume".Aborted) where it reported 500 (Internal) after quarantining the sandbox. The mapping ishttp_status_for_grpc_codeincrates/libs/api/src/controllers/mod.rs. ECWresumemaps to SPACE restore and does not reach this path.activation.rs). There, a resume that loses to Stop now returnsAbortedinstead of quarantining and returningInternal. The data plane treatsAbortedas retryable and cold-starts on retry.Edges, allowlist and #438's helper
RESUME_BEGIN_AFTER_STOP(KnownRace,{stopped} → resuming).RESUME_BEGINwas reused, and its doc comment now namesbegin_resident_resume'supdate_state_from.(Stopped, Resuming, WriteForbidden, "known race: policy-free resume after Stop"). No other edge writesstopped → resuming, andcan_transition_toforbids it, so the pair no longer disagrees. No mixed reason names this race; the remaining race reasons name pause, process-snapshot rollback and template snapshot (sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, removed the warm-start ones). 79 entries remain.KnownRaceedges remain, so the kind and itsany KnownRaceassertion stay; sbx-mgr: pause writes only over the state it expects #417, above this PR, retires them.dead_codeexpectations: sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, already removed them from the five items this PR reaches throughrequire_state_write_from: the function itself,StateWriteRejected,StateWriteGuard,state_write_resultandimpl StateWriteRejected(into_status).update_state_from_best_effortkeeps its expectation, because this PR does not call it. This PR does not touchcleanup_support.rs.Other changes
service/auth_lookup.rsdrops from 1242 to 1221 code lines on main's base. It was already over its 1237 archcheck cap; the begin step moved toresume_begin.rs. The cap is lowered to 1221.BEGIN_BARRIER) is#[cfg(test)]only. Production code waits on nothing new.Overlaps with the other PRs in the stack
PAUSE_ROLLBACK_AFTER_STOP/PAUSE_COMPLETE_AFTER_STOPsit directly above this PR's deleted edge indb/state_write.rs, and its allowlist deletions ((Stopped, Started),(Stopped, Paused)) sit directly above the removed(Stopped, Resuming)entry. Its rebase onto this PR kept both deletions.expect(dead_code)on sbx-mgr: one refusal-to-status mapping for update_state_from #438's items: sbx-mgr: create completion and warm start write only over the state they expect #418 (below) and sbx-mgr: snapshot completion and rollback write only over the state they expect #421 (above) remove them; this PR touches none.update_state): must still convertbegin_resident_resume_with_policy's internalupdate_state(Resuming)(see above). It also has the other internaldb/callers.architecture-baseline.toml: only theauth_lookup.rsline changes.auth_lookup.rsorresume_begin.rs, as far as the plan's split shows.Mutation proof
M1, M2, M9 and M10 (the lines this round's rebase touched) were re-run on the final head; M3–M8 ran on
5b14c80(the round-1 head before the final #438 rebase), whose mutated lines the rebase did not change. Each mutation was applied alone from a backup, and every SQL?Nstayed bound. The new test and thestate_write_tablesuite were run, then the file was restored and touched before the next. "Cases" lists the cases the test reported.require_state_write(update_state(&db, id, Resuming), …)policy_free_resume_…(the 12 states the guards admit)StateSet::ALL(update_state_from_states(ALL, Resuming)) instead ofRESUME_BEGINpolicy_free_resume_…(same 12)FromStatewith the guard'ssuperseded_by_cleanup_episodetextpolicy_free_resume_…(same 12)FromStatewithFailedPreconditioninstead ofAbortedpolicy_free_resume_…(same 12)superseded_by_cleanup_episodepolicy_free_resume_…(quarantined)RESUME_BEGINto{paused, stopped}policy_free_resume_…(stopped),persisted_edges_disagree_with_the_admission_machine_only_as_allowlistedRESUME_BEGIN_AFTER_STOPedgepersisted_edges_disagree_…(Stopped, Resuming)allowlist entrypersisted_edges_disagree_…(stale entry)MissingtoOkinstate_write_resultpolicy_free_resume_…(Missing)MissingwithNotFound(same text) ininto_statuspolicy_free_resume_…(Missing)All 10 were caught, and all were restored. M3–M5, M9 and M10 mutate #438's shared mapping and show that this PR's test pins the call site's statuses.
#372 / #388 / #438 tests
No #372 matrix, accepted-shape or race expectation changes, no #388 table, equivalence or binding test changes apart from the deleted allowlist entry above, and no #438 test changes. Pins green:
paused_suspend_serializes_resume_behind_wake_and_artifact_locks, theauth_lookuptests (service::auth_lookup::tests,auth_lookup::wait_ready_policy_tests,service::auth_lookup_tests),resident_resume_refusal_status_for_every_state,resume_fails_closed_when_network_policy_cannot_confirmandservice::cleanup_support::tests.Decisions made without an operator
RESUME_BEGIN, whose from-set is exactly{paused}, rather than adding an edge.require_state_write_fromas is. Every refusal isAborted, which is what the plan's row asks for a lost race, so nofrom.contains(found)branch and no typedStateWriteRejectedmatch is needed.auth_lookup.rs, which was already over its cap. The begin step, both branches, moved whole.task_localbarrier instead of a service field. It keepsSandboxManagerService, its constructor andservice/mod.rs(also over its cap) untouched.cleanup/stop_admission_tests.rs), which the host-safety rules forbid in tests, so the test runs the writers the Stop RPC runs, in sbx-mgr: characterize the state writers before the state-write helper (tests only) #372's documented order.begin_resident_resume_with_policy's internal call (see above).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 (
38e424c): pull CI is green. fmt, workspace clippy--all-targets --all-features -D warningsandcargo arch(0 required findings) all pass.SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0passed 1639/1639, with 0 dependency skips. Tested at5cefe54; the final head differs from it only inarchitecture-baseline.toml, which onlycargo archreads, andcargo archwas rerun on the final head. Kind Pull CI ran on #417, which contains this PR: all 4 Firecracker smoke shards passed.The top of the stack (#414 @
9a0a251, which contains this PR):cargo test -p pplx-space-sbx-mgr(shared process)smoke-kind-e2e.shpassed.smoke-kind-typed.sh all: native 23/23; ECW 76 passed, 2 infra errors.local.ecw_template_build_from_oci_bootspassed, and so did the leader-election brownout.node_group_boundary_schedulesandnode_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.sandboxes.state, control-plane Postgreslifecycleand 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
cargo fmt --all -- --check,scripts/check-rust-error-policy.shcargo clippy --workspace --locked --all-targets --all-features -- -D warningsdead_codeexpectations andupdate_state_from_best_effort's remaining one are all consistent)cargo archauth_lookup.rsat 1221 (cap lowered 1237 → 1221);resume_begin.rsandcleanup_support.rsunder the 1000-line limit; no listed file grewf1d1c9b..HEADSPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0,SPACE_TEST_DEP_SKIP_LOGset5b14c80: 1631/1631 × 3; on this head after the final #438 rebase: 1631/1631 × 1. 0 dependency skips every run; 1 skipped = the#[ignore]privilegedtransfer_directory::tests::distinct_service_uids_can_exchange_downloads, unrelatedupdate_staterestored (M1: 12 cases), green with this change5b14c80; the final rebase changed only #438's return type and this call site's?; Ok(()), so per the owner Kind was not rerun)smoke-kind-e2e.shon the current harnessspace-sbxmgr-svcinspace-rust-build-kind:localand/proc/<pid>/exein both sandbox-manager pods share sha2564af260dd…5afc25, and the binary containssandbox state write was superseded: sandbox is, which production reaches only through this call site. Same hash, 0 restarts after the typed runok/not_applicablebefore the smokessmoke-kind-e2e.shsmoke-kind-typed.sh all(CI env incl.SPACE_SMOKE_KIND_COLD_RECEIPTS, unsharded)native_contract.command_replay_credentials);ecw.cold_create_environmentpassed. No rerun neededinfra/space/kind/scripts/teardown.sh: Tilt stopped, cluster deleted🤖 Generated with Claude Code