Repository navigation
Conversation
A fatal Kill (SIGKILL/SIGTERM) now admits exactly the node-local SQLite sandboxes.state values that SandboxState::can_transition_to(Stopping) accepts. That rule is widened from started/paused to every state whose VM is live or launching, mid-operation included: starting, started, stopping, pausing, paused, resuming, checkpointing, suspending and restoring. Kill still refuses creating, created, stopped, suspended, cleanup_required, cleanup_failed, failed, deleting and quarantined with FailedPrecondition, as before. Kill reads only the row, so a live runtime does not change its answer. A non-fatal signal starts no cleanup and keeps its started/paused guard. Stop is unchanged. Its RPC selects only a live runtime and then admits every state but deleting and quarantined, including the settled and cleanup states the shared rule excludes. Making Stop consult the rule would refuse a live runtime under creating, created, stopped, suspended, cleanup_required, cleanup_failed or failed, so Stop keeps its runtime-gated admission. The rule excludes those states because Kill has no runtime gate: superseding a failed or cleanup row would replace its pending cleanup or erase-safety evidence with a stop. The newly admitted Kill states race their in-flight operation exactly as Stop already does for a live runtime. Activation, resume and the scheduled checkpoint use compare-and-set writers and lose cleanly. Tests sweep all 18 states for Stop (live and not live, blocking and not), fatal Kill (both signals, live and not live, blocking and not) and non-fatal Kill, plus can_transition_to(Stopping) itself.
Contributor
Author
|
Closing for now. Review showed Stop and Kill can't share one admission rule as scoped: they answer different questions.
Widening Kill alone admits We'll revisit after the state-write conversions make in-flight writers compare-and-set, as part of the lifecycle redesign. The branch |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What merging this does
sbx-mgr's fatal
Kill(SIGKILL/SIGTERM) is admitted from exactly the node-local SQLitesandboxes.statevalues thatSandboxState::can_transition_to(Stopping)(state.rs) accepts. That rule is widened fromstarted/pausedto every state whose VM is live or launching, mid-operation included.Killstill refusesdeletingandquarantined.Stopis unchanged in every one of the 18 states, live and not live, blocking and non-blocking.Stopdoes not consult the widened rule. Making it do so would change Stop's answer for 7 states (shown below), and the owner decision says Stop must not change. The rule's doc comment explains why Stop's runtime-gated admission is separate.Everything here is sbx-mgr's node-local SQLite
sandboxes.state. The control-plane Postgrespublic.sandboxes.lifecycleand its API gating are not touched.Analysis
Target resolution and gates
service/grpc_core.rsstop)service/grpc_lifecycle/kill.rs)resolve_lifecycle_target(.., running_only = true)(cleanup_targets.rs): the row for one exact UUID, or a rowless cache directory. Honors the template filter; space-tmplmgr may only target template-owned rowslookup_sandbox: the row must exist (NotFoundotherwise). No template filter, no cache-directory fallbackrunning_onlyselects nothing unlessfc.pidresolves. ReturnsOkwith nostopped_idsand writes nothingmatches!(Started | Paused). Thetransition(Stopping)call after it had the same set, so it never refused. After: fatal signals usecan_transition_to(Stopping); non-fatal signals keepStarted | Pausedadmit_selected_cleanup(unfenced). The supersede writer refusesdeleting/quarantinedwithUnavailableadmit_lifecycle_cleanup_for_observed_execution. Same supersede writer, plus an execution-generation check:Abortedif the generation changed since lookupStopProcess(signal)under the admission fence, then the sameMarkStoppedcleanupSweep of all 18 states (pinned by the new tests)
Stop. Every state, live and not live, blocking and not, behaves the same before and after this change:
stopped_ids = [id]; the row endsstoppedwith no intent or job left.stopped_ids = [id]; the row isstoppingwith theMarkStopped/keep-filesystem intent and an active cleanup job.Okwith no ids, and the whole database is unchanged.Unavailable, and the whole database is unchanged.Kill. Same answer live or not live, and for both signals 9 and 15:
StopProcess(signal)once; the row endsstopped.StopProcess(signal)once; the row isstoppingwith intent and job.FailedPrecondition, no signal sent, and the whole database is unchanged.Unavailable)Unavailable)Stop admits a live runtime under
creating/created/stopped/suspendedonly when a runtime has outlived its row's lifecycle, such as a leaked VM. It is a repair path, and it is kept.Evidence for the "before" column: the new Kill sweep was run against the parent commit's
kill.rsandstate.rs. With the after-expectations it fails, first atstarting. With the expectation set tostarted/pausedit passes. On the parent commit, the Stop sweep and the non-fatal sweep pass unchanged.The widened set
can_transition_to(Stopping)was{started, paused}. It is now these 9 states:{starting, started, stopping, pausing, paused, resuming, checkpointing, suspending, restoring}That adds 7 edges into
stopping, fromstarting,stopping(a self-edge),pausing,resuming,checkpointing,suspendingandrestoring. The rule is a private, wildcard-free predicate (can_stop), so a new state does not compile until it is placed.These states stay excluded even though Stop admits them with a live runtime. Kill has no runtime gate, so admitting them would act on rows whose VM is not known to be live:
failed: mutation M2 shows Kill would admit it and end itstopped. Afailedrow that carries the retained-filesystem-only-copy marker would become erase-safe, and a later Delete could erase the only copy.cleanup_required/cleanup_failed: superseding drops the cleanup episode and theMarkSuspendedintent that keepsSuspendPublicationPending.suspended: Kill would discard the suspended lifecycle.creating/created/stopped: there is no VM to signal. M5 shows Kill would convertcreatingtostoppedwithout the gate.Other callers of
transition(Stopping)/can_transition_to(Stopping)service/grpc_lifecycle/kill.rsdb.rsgeneric_writer_transition_blockedtransition(target)only when the previous state isquarantined.quarantined → stoppingstays false, so it is unchangeddb/tests.rsevery_allowed_state_transition_tracks_the_change_timecan_transition_to; now also drives the 6 new non-self edges throughupdate_state. Passesstate.rsrequired_states()Stoppingtext names the new sources. It is only used intransition()'sInvaliderror, which no production caller ofStoppingreaches nowtransition(..)/can_transition_to(..)Stopping(Deleting,Starting,Checkpointing,Quarantined,Pausing,Resuming); unaffected.SandboxStateis not used outside the sbx-mgr crateNo other behavior changes. The refusal code for every still-refused state stays
FailedPrecondition; the message for a refused fatal kill now readscannot kill: sandbox is <state>, which cannot stop. Stop is not a caller.Why Stop does not consult the rule
Mutation M8b temporarily made Stop refuse any state the rule rejects, with expectations set to the 9-state rule; the Stop sweep then passes. So consulting the rule would turn Stop on a live runtime from admitted to refused in exactly 7 states:
creating,created,stopped,suspended,cleanup_required,cleanup_failedandfailed. Widening the rule to Stop's 16 states instead would make Kill act on those 7 states with no runtime gate (above). Per the owner decision, Stop keeps its runtime-gated admission.Kill's behavior change and its consequences
Kill is newly admitted from
starting,stopping,pausing,resuming,checkpointing,suspendingandrestoring. In each case Kill:stoppingthrough the same supersede writer Stop uses, which drops any activation or cleanup episode;MarkStoppedcleanup job;StopProcess(signal)under the admission fence;stopped, blocking or non-blocking.The in-flight operation's own completion write then races Kill's cleanup. Stop already has every one of these races for a live runtime; this change gives Kill the same exposure. Making the in-flight writer lose cleanly is the job of state-write PRs 2–5.
mark_start_succeeded: compare-and-set onstartingplus the activation episodeAborted. Cleanmark_restore_succeeded: CAS onrestoringplus the episoderestore_resident_resume_state: CAS onresumingAborted)restore_checkpoint_state: CAS oncheckpointingMarkStoppedcompletionstoppingand stops again. Redundant and harmless. AStopProcessagainst a VM that is already gone can return an error after cleanup is admittedartifact_lockonly, no cleanup fenceupdate_state(Paused), orStartedon failureupdate_staterefusesstarted/pausedoverstopping, but not overstopped. A pause that finishes after Kill's cleanup reachedstoppedrewrites the row topaused/startedwith the VM deadwake_lockonlyupdate_state(Paused), success or failurestoppedwithpaused; the long snapshot window makes it realisticfinalize_suspended_runtime(re-checks ownership)suspendingthen converts the freshlysuspendedrow tostopped. Kill already has this check-then-act gap for astarted/pausedlookup that a suspend overtakesfinalize_suspended_runtimereturnsAbortedonce Kill owns the rowstopped, whichsandbox_erase_safetytreats as safe. The staged artifact and teardown job remain, and the permanently-failed marker can no longer be stamped (it requiressuspending). A later Delete could erase the filesystem before the remote backup lands. Stop already has the same exposure for a live runtimeAlso: Kill on
starting/restoringcan returnAborted("cleanup execution changed") when launch attaches a new execution generation between Kill's lookup and its admission. A caller may retry.The four Finding rows come from reading the code; no test reproduces them.
Possible follow-ups, not in this PR:
pausing/checkpointing(state-write PRs 2–5);suspendingbecause a stop superseded it.Coordination
State-write PR 1 (
ac/sbxmgr-state-write-pr1-helper, stacked on #372; not yet pushed when this was opened) adds a parity test between a persisted-edge table andcan_transition_to. This PR adds 7(X, Stopping)edges, listed above, so whichever lands second updates that test's allowlist.#372 is not merged. This PR ports only what it needs from #372's
service/cleanup/stop_admission_tests.rs: the expected-outcome table, therestoringseed and a whole-database unchanged check. It does not touch #372's files.Tests
state/tests.rsstopping_is_reachable_exactly_from_live_states: a wildcard-free sweep of all 18 states throughcan_transition_to(Stopping)andtransition(Stopping). It replaces the singlestarted_to_stoppingcase.service/tests/stop_kill_admission_tests.rs(new). Expected outcomes are written as wildcard-free matches, independent of the code under test:fatal_kill_admits_every_state_a_stop_may_supersede: 18 states × signals 9 and 15 × live and not live × blocking and not. Admitted cases check the response, the delivered signal, and the row (stopped, orstoppingwith intent and job). Refused cases checkFailedPrecondition, no signal, and an unchanged whole database.non_fatal_kill_signals_only_a_started_or_paused_sandbox: 18 states × live and not live; the signal is forwarded only tostarted/paused, and nothing is ever written.stop_admits_a_live_runtime_from_every_state_but_deleting_or_quarantined: 18 states × live and not live × blocking and not. It covers the Stop RPC end to end, including the runtime gate.grpc_lifecycle/backup_policy_tests.rskill_refuses_signals_it_cannot_deliver_before_cleanup_admissionnow takes its fatal-signal expectation from the same oracle,fatal_kill_admits.db::test_conn);for_test_in(tempdir);FakeNodeManageron a socket in its own temporary directory;fc.pid;Mutation proofs
Each mutation was applied alone from a single backup, run against the targeted tests, then restored and verified by hash, strictly in sequence.
can_stop:suspending→ falsesuspending), the Kill sweep, and the backup-policy Kill testcan_stop:failed→ truefailed; the backup-policy testcan_stop:deleting→ truedeletinggetsUnavailablefrom the supersede writer instead ofFailedPrecondition; the backup-policy teststartingsignalled)creatingadmitted) and the backup-policy testrunning_only = falsecreating, not live, admitted)quarantinedsourcequarantined, live, not refused)creating, live, refused)kill.rs/state.rsstarting; Stop and non-fatal sweeps PASS. Withstarted/pausedexpectations, every sweep PASSesValidation
cargo fmt --allcargo clippy --locked --workspace --all-targets -- -D warningspre-commit run --from-ref 64afd0b --to-ref HEAD)SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0, run 1#[ignore]root-onlydistinct_service_uids_can_exchange_downloads, not a dependency skip)smoke-kind-e2e.shbootstrappedspace-localfrom this checkoutspace-sbxmgr-svcprocesses (2 sandbox nodes, 2 builders) have/proc/<pid>/exesha256cd6c8771…e785a, equal to the artifact from this checkout; that binary contains the new Kill refusal text and the newrequired_statestextsmoke-kind-e2e.shsmoke-kind-typed.sh all, ECW (SPACE_SMOKE_KIND_COLD_RECEIPTSset)local.telemetry_reaches_local_backends(classified ENVIRONMENTAL, below)smoke-kind-typed.sh all, nativelocal.telemetry_reaches_local_backendsinfra/space/kind/scripts/teardown.sh)ENVIRONMENTAL:
local.telemetry_reaches_local_backends.space-sandbox-manager) and the Prometheus phases passed.500 Ingester is shutting down, so no pod log lines reached Loki.docker builder prune -af(reported 21.88GB; 17,583,058,944 bytes freed on the filesystem) anddocker image prune -f(0B). The ingester did not recover until Loki was restarted in this disposable cluster. After that, Alloy push errors stopped.🤖 Generated with Claude Code