supervisor: mark deliberate severance so a probe-induced death is not a crash (#64) - #80
Conversation
There was a problem hiding this comment.
All reported issues were addressed across 14 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
Valid finding — fixing it. One clause of the stated consequence does not hold, and the difference changes what the fix has to do, so recording both. The gap is real
Correction: the drain path does not consume restart budgetI read The two defects that are there: 1. The terminal record is mislabelled. 2. So the corrected consequence is: a probe-induced death reaped by the drain path is recorded as a crash and leaves no lifetime trace — a provenance defect rather than a budget one. Same severity, different fix. What the fix has to be careful aboutThe obvious repair — mark every drain — would be wrong. I am also auditing all four unmarked sites rather than the two named, and requiring a per-site verdict on whether a severed child can reach it — a path that provably cannot see one does not need the marker, but that has to be stated per site rather than skipped silently. The Thanks for the catch — this is the same class as the finding on #76: a classifier correct on the path it was written for, and absent from the paths that reach the same state by another route. |
3408a7d to
4c86649
Compare
|
Fixed at The fix
Centralised deliberately: a future reap path inherits classification instead of having to remember it. The defect was a classifier living on one path while four other paths reached the same state by another route — patching the two named sites would have preserved the shape that produced it. All five audited for reachability rather than the two reported. The post-kill wait at Lifetime conditionality — the part that could have gone wrongstate.last_exit = Some(exit_report.clone());
if exit_report.kind == ExitKind::DeliberateSeverance {
state.lifetime_restarts += 1;
}
Two arms, and the second is the load-bearing one: RED evidence: with the classifier absent the deliberate drain observed One deliberate behaviour change beyond the finding
if exit_report.kind != ExitKind::DeliberateSeverance {
exit_report.kind = ExitKind::Crash;
}Without this, a severed replacement would be relabelled a new-binary failure — the same mislabelling, one layer up. A genuine new-binary failure carries no marker and still becomes Verification781 = 779 + the 2 new arms. Existing arms unchanged, including |
There was a problem hiding this comment.
1 issue found across 14 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/subc-core/src/server.rs">
<violation number="1" location="crates/subc-core/src/server.rs:441">
P2: The deliberate-severance marker is recorded on every fatal routing failure for a module connection, including non-probe data connections where the module process can survive the connection teardown. Because `snapshot.deliberate_severance` persists until the next reap of a matching (pid, start_time) or a reset, a module that outlives the severance and later exits for a genuine reason is classified as `DeliberateSeverance`, masking a real crash and skipping restart-budget consumption. The record should be scoped to the probe/severance path that is actually followed by process death, or the marker should be cleared when the process is observed still alive after the connection close.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| error = %err, | ||
| "fatal routing failure" | ||
| ); | ||
| router.record_deliberate_connection_severance(ctx.connection_id); |
There was a problem hiding this comment.
P2: The deliberate-severance marker is recorded on every fatal routing failure for a module connection, including non-probe data connections where the module process can survive the connection teardown. Because snapshot.deliberate_severance persists until the next reap of a matching (pid, start_time) or a reset, a module that outlives the severance and later exits for a genuine reason is classified as DeliberateSeverance, masking a real crash and skipping restart-budget consumption. The record should be scoped to the probe/severance path that is actually followed by process death, or the marker should be cleared when the process is observed still alive after the connection close.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/subc-core/src/server.rs, line 441:
<comment>The deliberate-severance marker is recorded on every fatal routing failure for a module connection, including non-probe data connections where the module process can survive the connection teardown. Because `snapshot.deliberate_severance` persists until the next reap of a matching (pid, start_time) or a reset, a module that outlives the severance and later exits for a genuine reason is classified as `DeliberateSeverance`, masking a real crash and skipping restart-budget consumption. The record should be scoped to the probe/severance path that is actually followed by process death, or the marker should be cleared when the process is observed still alive after the connection close.</comment>
<file context>
@@ -438,6 +438,7 @@ where
error = %err,
"fatal routing failure"
);
+ router.record_deliberate_connection_severance(ctx.connection_id);
return Err(ConnectionError::Router(err));
}
</file context>
|
Valid, and it is the exact failure direction this design was chosen to avoid. Fixing. Confirmed at source
} else {
debug!(connection_id = ..., error = %err, "fatal routing failure");
router.record_deliberate_connection_severance(ctx.connection_id);
return Err(ConnectionError::Router(err));
}That is every fatal routing failure on a module connection, not only a deliberate severance. Many do not kill the module process. The identity binding does not protect against this, and I want to be precise about why, because it protects against something adjacent. This is the option I argued against, arrived at from the other sideOn #64 I rejected marking intent in side state in these words:
The maintainer ruled (b) partly on that reasoning. The implementation then reproduced the same defect by a different route — a marker that outlives the event it describes. Choosing the right architecture does not confer immunity from the failure mode that architecture was chosen to avoid; the marker just had to be set somewhere, and the somewhere was too broad. It is also strictly worse than the mislabelling fixed last round. That one attributed our kill to the module; this one attributes the module's own failure to us, and spares the budget that would have caught it. DirectionNarrowing the record site rather than clearing the marker afterwards. The invariant: a marker must never be settable by a path that does not know the severance was deliberate. A generic routing error that is fatal to the connection is not a deliberate severance, and the party that decided to sever is the party that knows — so the marker belongs with the initiator, not inferred from a teardown branch. Clear-if-still-alive is available as a backstop but is weaker on its own: it depends on an observation window and has a race. Not setting it has neither. If the deliberate path turns out not to be distinguishable at that call site, that changes what this mechanism can promise and I will say so on this PR rather than quietly substituting the weaker form. The fix arm is written to fail first: a module connection takes a fatal routing failure, the process survives, and a later genuine crash must classify as Good catch. Two rounds, two real findings, both the same underlying shape — a mechanism correct at the point it was written and wrong about the paths that reach it. |
Operator restart and reload paths own their teardown and successor lifecycle; they do not exempt a child death. A fatal connection severance therefore needs its own exit label so a probe-caused exit remains observable without spending restart budget, while still advancing lifetime restart history. Classification now happens at every child reap path, and drain reaps increment lifetime only for deliberately severed exits. The marker is armed only by the deliberate-severance initiator; generic fatal routing failures cannot leave a stale marker that would exempt a later genuine crash. CONSUMER-IMPACT: Exit kinds use an open string decoder. New consumers render unrecognized future kinds as unknown, and older decoders ignore the additive exit_kind field rather than discarding the terminal record.
4c86649 to
317db83
Compare
|
Fixed at The fixThe generic fatal-routing branch no longer arms anything — that call and its forwarding/control/router plumbing are gone. The marker is armed only by an explicit initiator: /// must not call this:
/// a surviving process would otherwise retain an exemption for a later
/// genuine crash.
pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
...
module.record_deliberate_severance(ProcessIdentity { pid, start_time })
}The invariant is now structural rather than conventional: a path that cannot know the severance was deliberate has no way to arm the marker. The initiator captures Defect reproduced before fixing, per your point: restoring the old call made Weakness in that regression test — flagging rather than presenting it as strongThe guard is a source-text assertion: assert!(!include_str!("server.rs")
.contains("router.record_deliberate_connection_severance(ctx.connection_id)"));It catches a literal reintroduction of that exact call, which is the likely regression, and it is cheap. It does not catch a semantically identical reintroduction through a renamed helper, a different receiver, or a reformatted call. It is a tripwire, not a proof. The behavioural form — fatal routing failure, process survives, later genuine crash classifies as The mechanism is currently dormant, deliberatelyNothing in production calls Consequence for review: the risk this PR carries is not "does the classification fire correctly in production" — it cannot fire yet. It is "is the mechanism correct and unable to fire wrongly", which is what the last two rounds were about. The first probe caller is the point at which the behaviour becomes observable. VerificationExisting arms unchanged and passing: deliberate classification, genuine crash spending budget, the successor-race identity binding, and ordinary drains staying lifetime-neutral. Three rounds, three real findings, and all three were the same shape — a mechanism correct where it was written and wrong about which paths reach it. Round 1: classifier on one reap path, four others unmarked. Round 2: marker armed by a branch that could not know the severance was deliberate. The narrowing each time was toward fewer places allowed to assert the fact, which is where it should have started. |
There was a problem hiding this comment.
1 issue found across 11 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/subc-core/src/supervise.rs">
<violation number="1" location="crates/subc-core/src/supervise.rs:778">
P1: The new deliberate-severance marker is never armed in production, so probe-induced child deaths are still classified as crashes and consume restart budget. Invoke this API from the actual daemon-initiated severance path, while keeping generic connection teardown out of it.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| /// must not call this: | ||
| /// a surviving process would otherwise retain an exemption for a later | ||
| /// genuine crash. | ||
| pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> { |
There was a problem hiding this comment.
P1: The new deliberate-severance marker is never armed in production, so probe-induced child deaths are still classified as crashes and consume restart budget. Invoke this API from the actual daemon-initiated severance path, while keeping generic connection teardown out of it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/subc-core/src/supervise.rs, line 778:
<comment>The new deliberate-severance marker is never armed in production, so probe-induced child deaths are still classified as crashes and consume restart budget. Invoke this API from the actual daemon-initiated severance path, while keeping generic connection teardown out of it.</comment>
<file context>
@@ -743,6 +770,22 @@ impl SupervisorHandle {
+ /// must not call this:
+ /// a surviving process would otherwise retain an exemption for a later
+ /// genuine crash.
+ pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
+ let Some(module) = self.get(module_id) else {
+ return Ok(false);
</file context>
|
Accurate on the fact, and I disclosed it myself in the comment above — but it is the ruled scope of this PR, not a defect in it. Declining, with the check I ran before declining. The ruling scoped it this wayFrom #64:
The mechanism lands first; the probe is the next PR. Dormancy is the intended state of this change, not an oversight in it — which is why I stated it plainly rather than leaving it to be found. But "no production caller" deserved a check, not a citationThe finding says to invoke the API "from the actual daemon-initiated severance path". That presumes such a path exists today. I went looking, because if the daemon already deliberately severs a module connection in a way that kills the process, arming there would be legitimate and the ruling would not cover it. It does not: /// True only when an undeliverable GOODBYE should escalate to closing the
/// target connection. Never escalate for module recipients.
pub(crate) fn close_on_delivery_failure(&self) -> bool {
matches!(self.kind, GoodbyeTargetKind::Client)
}Module-targeted GOODBYEs close routes, not connections, and explicitly never escalate to closing the connection. The drain and reload paths take ownership of the process lifecycle directly — they do not sever a connection and wait for the module to die from it, which is the same finding that started #64: the operator paths avoid the budget by owning the lifecycle, not by severing. So there is no existing daemon-initiated, process-killing severance to wire this to. The first one will be the probe. Wiring it to any path that does not actually kill the process is precisely the P2 fixed one round ago — a marker armed by something that cannot know the severance was fatal, left to exempt a later genuine crash. What would change my mindIf you want the mechanism to land with its first caller rather than ahead of it, that is a reasonable position and I will build the probe into this PR — but it is a scope change to a ruled decision, so it is @ualtinok's call rather than mine to take from a review comment. Say the word and I will fold it in. Worth noting the three rounds together, because the trajectory is consistent: round 1 narrowed classification to every reap path, round 2 narrowed arming to an initiator that knows the severance was deliberate, and this would widen arming again to a path that does not. The first two moved toward fewer places allowed to assert the fact. I would rather leave the mechanism unarmed than arm it somewhere that cannot honestly make the claim. |
There was a problem hiding this comment.
Review by execution against the three #64 requirements, all three landing exactly — plus the #79 ruling applied ahead of its own paper trail:
- Identity binding: marker is
(pid, start_time), one-shottake(), equality-gated — and the mismatch direction fails TOWARD crash (budget consumed), which is the correct failure polarity: a lost label costs an operator a wrong record; a loose label would cost the budget its meaning. The mismatch test at the reaped-wrong-process arm pins it. - Two-sided counter divergence: severance arm asserts
lifetime_restarts == 1 && restart_count == 0with the crash control asserting both increment — both polarities, so the variant silently decaying to crash reddens by name. - Wire tolerance:
TerminalExitKindas an open string enum withUnknown(String)beats theserde(other)floor I asked for — the unknown retains its wire spelling for rendering — andOption + defaultcovers absent-on-old-daemons. Golden updated, both directions.
Naming verdict: DeliberateSeverance is the mechanism-honest choice per the ruling — the attestation is the severance, and the doc comment saying generic teardown must not arm it is the guard against label borrowing. The bot's never-armed-in-production P1 is by design (mechanism-only ahead of the probe, the #64 sequencing); the arming call is the probe PR's first line. Gate: 469/0 locally, full matrix green on the head. Merging.
Mechanism only, ahead of the Tier-2 probe, per the ruling on #64.
A probe that induces a fatal connection state kills the module, and today that death is indistinguishable from a genuine crash at
on_child_exit—ExitReportcarries no reason. So the probe would consume restart budget and could push a module toFailedwhile measuring whether it decodes a flag bit.The three requirements
1 — severance bound to
(pid, start_time), not module id.record_deliberate_severancestores aProcessIdentityand the child-wait path compares the exited child's identity before classifying. The race arm is the point of it:Same pid, different start time — the successor's death is a crash, not a laundered exemption. Same identifier-reuse discriminator as #76, reusing that PR's
process_start_timerather than adding a second reader.2 — budget/lifetime divergence, asserted in both directions. A deliberate severance increments
lifetime_restartsand leavesrestart_countalone. Both counters are asserted in the same test, and the crash arm asserts both move — a change that exempts everything passes the first arm while destroying the budget, so the second arm is what keeps the first honest.3 — decode tolerance.
TerminalExitKindis an open string enum with anUnknown(String)variant: an unrecognised wire value decodes toUnknown("future_exit_kind")rather than failing the record.A note on that third requirement, because it bears on #79
Unknown(String)retains the unrecognised value rather than collapsing it. That is what makes it different from the catch-alldocs/subc-control-protocol.md:275rejects — the objection there is that a fallback makes an unknown op and a malformed body decode alike, and those warrant opposite responses. An open variant carrying the string preserves that distinction: an unknown kind arrives asUnknown("..."), while a malformed body still fails to decode.I raise it because #79 asks the same question about
RunningImageUnavailableReason, which shipped closed in #76 and breaks 0.6.0 clients on the wholesupervisor.provenanceresponse. This PR demonstrates the middle path exists and costs one variant. Whichever way you rule on #79, the two enums should end up on the same principle rather than differing by which PR they arrived in.Platform limitation — states plainly, because the probe author needs it
process_start_timereads/proc/<pid>/statand returnsNoneoff Linux. No identity means no severance marker, so on macOS and Windows a probe-induced death is classifiedCrashand consumes restart budget.That is the correct failure direction — fail closed, never grant an exemption we cannot substantiate — and it matches the house rule that an unconfirmable identity forfeits the claim. But it is a real constraint on the probe: on the macOS fleet, Tier-2 adoption checking spends budget. Worth knowing before the probe is designed rather than discovering it when a module hits
Failed.A macOS path would need a different start-time source (
sysctl KERN_PROC_PID/kinfo_proc.ki_start); out of scope here.Naming
DeliberateSeverancenames the mechanism — the daemon attests that it severed the connection, not why. The doc comment says so, so a future non-probe deliberate severance either reuses it honestly or adds its own variant instead of borrowing a probe-shaped label.Verification
Baseline on
6cf83890is 775, not the 774 I briefed — 775 + 4 new tests = 779. The implementer measured 779, flagged that it could not reproduce my number, and reported the measurement instead of adopting the brief. My error, caught by refusing to launder it.Red proofs, one per arm: deliberate branch spending budget → expected 0 got 1 · crash branch omitting the increment → expected 1 got 0 · identity match removed → stale-marker assertion failed · deliberate kind serialised as crash → expected
deliberate_severancegotcrash.Versions:
subc-control0.8.0,subc-client-rs0.10.0,subc-core0.10.0. One golden fixture updated —client_control_response_supervisor_terminals.jsonnow pinsexit_kind: crashon real-handler terminal records, which is the pre-existing behaviour made explicit rather than a change to it.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Fixes probe-induced module deaths being classified as crashes: a probe that kills a module via fatal connection severance now gets a deliberate severance marker, so the death no longer consumes restart budget.
(pid, start_time)so a successor process with a reused PID is still treated as a crash.lifetime_restartsbut leavesrestart_countunchanged.exit_kindwire field is additive and decodes unknown future kinds asUnknown(String)instead of failing the record.process_start_timeis unavailable, so severance cannot be verified and probe-induced deaths still count as crashes and spend restart budget — fail closed.Consumer impact
ck module terminalsnow prints the exit kind.subc-control0.8.0,subc-client-rs0.10.0,subc-core0.10.0.Written for commit 317db83. Summary will update on new commits.