From 5f8417589d22f84a306d0329444a41d01884eb4f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:38:26 +0200 Subject: [PATCH 1/9] Candidate cleanup owed is one method both abort intents share The two-flag helper let either call site swap or fold an argument unnoticed. DeploymentTransaction::candidate_cleanup_owed reads the transaction itself, and a four-row table drives the method and both intents. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 77 ++++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index 7943698..c068360 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -1370,6 +1370,17 @@ impl DeploymentTransaction { } } + /// Whether an abort owes the candidate's durable cleanup: it does once an + /// activation was issued or a workload was observed, either of which leaves + /// something behind. + fn candidate_cleanup_owed(&self) -> CleanupEvidence { + if self.activation.is_some() || self.workload.is_some() { + CleanupEvidence::Pending + } else { + CleanupEvidence::Skipped + } + } + fn rejected(command: &DeploymentCommand, error: anyhow::Error, now: u64) -> Result { let mut transaction = Self::new(command, command.selector.clone(), 0, None, now)?; let detail = truncate(&format!("{error:#}"), 2048); @@ -7991,10 +8002,7 @@ fn incumbent_was_stopped_during_fencing(fencing: &FencingEvidence) -> bool { fn pre_fencing_abort_intent(transaction: &DeploymentTransaction, error: &str) -> PreFencingAbort { PreFencingAbort { error: truncate(error, 2048), - candidate_cleanup: candidate_cleanup_requirement( - transaction.activation.is_some(), - transaction.workload.is_some(), - ), + candidate_cleanup: transaction.candidate_cleanup_owed(), topology_reconciliation: transaction.abort_topology_reconciliation(), source_cleanup: if transaction.command_kind == CommandKind::Deploy { CleanupEvidence::Pending @@ -8025,10 +8033,7 @@ fn post_fencing_abort_intent( } else { CleanupEvidence::Skipped }, - candidate_cleanup: candidate_cleanup_requirement( - transaction.activation.is_some(), - transaction.workload.is_some(), - ), + candidate_cleanup: transaction.candidate_cleanup_owed(), topology_reconciliation: transaction.abort_topology_reconciliation(), source_cleanup: if transaction.command_kind == CommandKind::Deploy { CleanupEvidence::Pending @@ -8038,17 +8043,6 @@ fn post_fencing_abort_intent( } } -fn candidate_cleanup_requirement( - has_prepared_activation: bool, - has_workload_observation: bool, -) -> CleanupEvidence { - if has_prepared_activation || has_workload_observation { - CleanupEvidence::Pending - } else { - CleanupEvidence::Skipped - } -} - fn sequence_requires_admission( latest: Option<&TopologyEvidence>, maximum_admitted_sequence: u64, @@ -9803,19 +9797,38 @@ mod tests { } #[test] - fn activation_without_workload_still_requires_durable_candidate_cleanup() { - assert_eq!( - candidate_cleanup_requirement(true, false), - CleanupEvidence::Pending - ); - assert_eq!( - candidate_cleanup_requirement(true, true), - CleanupEvidence::Pending - ); - assert_eq!( - candidate_cleanup_requirement(false, false), - CleanupEvidence::Skipped - ); + fn candidate_cleanup_is_owed_when_an_activation_or_a_workload_exists() -> Result<()> { + // (activation issued, workload observed, expected cleanup) + let rows = [ + (false, false, CleanupEvidence::Skipped), + (true, false, CleanupEvidence::Pending), + (false, true, CleanupEvidence::Pending), + (true, true, CleanupEvidence::Pending), + ]; + for (has_activation, has_workload, expected) in rows { + let world = ContinuityProjection::after_candidate_observed()?; + let mut transaction = world.continuity_transaction()?; + if !has_activation { + transaction.activation = None; + } + transaction.workload = has_workload.then(|| workload(1000, 1, 2)); + assert_eq!( + transaction.candidate_cleanup_owed(), + expected, + "activation {has_activation}, workload {has_workload}" + ); + assert_eq!( + pre_fencing_abort_intent(&transaction, "x").candidate_cleanup, + expected, + "pre-fencing intent, activation {has_activation}, workload {has_workload}" + ); + assert_eq!( + post_fencing_abort_intent(&transaction, "x").candidate_cleanup, + expected, + "post-fencing intent, activation {has_activation}, workload {has_workload}" + ); + } + Ok(()) } #[test] From bf5347c3704d2f7bb5626c5c284a4d97ba0eeb2b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:40:11 +0200 Subject: [PATCH 2/9] The chunk-size line admits whitespace only before an extension RFC 9112 7.1 allows whitespace before a chunk extension and nowhere else. The reader trimmed both ends of the size, so " 3" and "3 " were taken. It now splits at the first ';', trims trailing SP and HTAB only when an extension follows, and leaves parse_digits as the digit authority. Rows use an honest body so only the size line differs. Co-Authored-By: Claude Sonnet 5.5 --- src/drivers.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/drivers.rs b/src/drivers.rs index 6af3559..dd9f6c0 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -5813,10 +5813,16 @@ fn request_http_snapshot(target: SocketAddr, payload: &[u8]) -> Result, let mut response = Vec::new(); loop { let size = answer.read_line(ROUTE_HTTP_CHUNK_LINE_BYTES, body)?; + // RFC 9112 7.1: `chunk-size [ chunk-ext ] CRLF`, where an + // extension may follow whitespace. Nothing else is trimmed. let size = std::str::from_utf8(&size) .ok() - .and_then(|size| size.split(';').next()) - .and_then(|size| parse_digits(size.trim_matches([' ', '\t']), 16)) + .and_then(|line| match line.split_once(';') { + Some((digits, _extension)) => { + parse_digits(digits.trim_end_matches([' ', '\t']), 16) + } + None => parse_digits(line, 16), + }) .context("stable CultNet HTTP chunk size is invalid") .map_err(refused)?; if size == 0 { @@ -8786,9 +8792,15 @@ mod tests { /// An honest chunked answer with `after_body` in place of its end. fn chunked_honest_then(id: &str, after_body: &[u8]) -> Vec { + chunked_honest_sized(id, "", "", after_body) + } + + /// An honest chunked answer whose chunk-size line is the hex length between + /// `before` and `after`. + fn chunked_honest_sized(id: &str, before: &str, after: &str, after_body: &[u8]) -> Vec { let body = honest_body(id); let mut reply = CHUNKED_HEAD.to_vec(); - reply.extend_from_slice(format!("{:x}\r\n", body.len()).as_bytes()); + reply.extend_from_slice(format!("{before}{:x}{after}\r\n", body.len()).as_bytes()); reply.extend_from_slice(&body); reply.extend_from_slice(b"\r\n"); reply.extend_from_slice(after_body); @@ -8803,6 +8815,30 @@ mod tests { challenge(scripted_peer(|id| chunked_honest_then(id, b"0\r\nT: 1\r\nU: 2\r\n\r\n")), |_| {}) .expect("an honest chunked answer with trailers"); + // RFC 9112 7.1: whitespace may precede a chunk extension and nothing + // else. The answer is otherwise honest, so only the size line differs. + for (before, after) in [("", ";x"), ("", " ;x"), ("", "\t;x"), ("", ";x=1;y")] { + challenge( + scripted_peer(move |id| chunked_honest_sized(id, before, after, b"0\r\n\r\n")), + |_| {}, + ) + .unwrap_or_else(|error| panic!("size line {before:?}+size+{after:?}: {error:?}")); + } + for (why, before, after) in [ + ("whitespace before the chunk size", " ", ""), + ("a tab before the chunk size", "\t", ""), + ("whitespace after the chunk size and no extension", "", " "), + ("whitespace before the size, ahead of an extension", " ", ";x"), + ] { + assert_refused( + challenge( + scripted_peer(move |id| chunked_honest_sized(id, before, after, b"0\r\n\r\n")), + |_| {}, + ), + why, + ); + } + // A trailer that begins and then the peer stops. assert_refused( challenge(scripted_peer(|id| chunked_honest_then(id, b"0\r\nT: 1\r\n")), |_| {}), From 525856e9cd353b2566e96cd508846d1de84ee436 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:15:32 +0200 Subject: [PATCH 3/9] scripts/verify.sh is the one verification command The Windows check on idunn-host lived only in README prose, so nothing ran it and d32395a broke the Windows build unnoticed. verify.sh runs the library tests and the x86_64-pc-windows-gnu check of idunn-host, and the README names it as the verification command. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +++- scripts/verify.sh | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100755 scripts/verify.sh diff --git a/README.md b/README.md index 297a9f1..503409e 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,9 @@ purpose; Idunn discovers no authority implicitly. Idunn targets Linux and does not compile on Windows (`drivers.rs` uses `std::os::unix` unconditionally). A local `cargo check` on a Windows -workstation proves nothing. Test and build where it runs, in the same image +workstation proves nothing. `scripts/verify.sh` is the verification command: +the library tests plus a Windows-target check of `idunn-host`. Run it on Linux +with the `x86_64-pc-windows-gnu` std installed. Test and build where it runs, in the same image Odin uses, with the working tree shipped to the host's build root: ```bash diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..c73a99b --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# The one verification command. Run it on Linux with the Windows GNU std installed +# (the Eureka verify image has both); a Windows workstation proves nothing. +# --lib because the tests under tests/ assume the host layout. +set -euo pipefail +cargo test --locked --lib +cargo check --locked --target x86_64-pc-windows-gnu --bin idunn-host From 36467153b524c164ce075bb42f843af48f419982 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:17:23 +0200 Subject: [PATCH 4/9] An abort's recorded candidate cleanup is judged by candidate_cleanup_owed The pre-fencing validator respelled the owed rule inline. Both abort validators now compare against candidate_cleanup_owed, so the post-fencing abort, which had no check on its recorded candidate cleanup, is held to the same rule. One test drives both aborts through every activation and workload row. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 91 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index c068360..a009fa7 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -1381,6 +1381,19 @@ impl DeploymentTransaction { } } + /// Whether an abort's recorded candidate cleanup is what + /// `candidate_cleanup_owed` says it must be: owed cleanup is pending or + /// done, unowed cleanup is skipped. + fn candidate_cleanup_recorded(&self, recorded: CleanupEvidence) -> bool { + matches!( + (self.candidate_cleanup_owed(), recorded), + ( + CleanupEvidence::Pending, + CleanupEvidence::Pending | CleanupEvidence::Complete + ) | (CleanupEvidence::Skipped, CleanupEvidence::Skipped) + ) + } + fn rejected(command: &DeploymentCommand, error: anyhow::Error, now: u64) -> Result { let mut transaction = Self::new(command, command.selector.clone(), 0, None, now)?; let detail = truncate(&format!("{error:#}"), 2048); @@ -1655,14 +1668,7 @@ impl DeploymentTransaction { "aborted transaction also carries post-commit cleanup" ); ensure!( - matches!( - ( - self.workload.is_some() || self.activation.is_some(), - abort.candidate_cleanup - ), - (true, CleanupEvidence::Pending | CleanupEvidence::Complete) - | (false, CleanupEvidence::Skipped) - ), + self.candidate_cleanup_recorded(abort.candidate_cleanup), "abort candidate cleanup differs from its prepared activation" ); ensure!( @@ -1712,6 +1718,10 @@ impl DeploymentTransaction { self.phase >= DeploymentPhase::Fencing, "post-fencing abort exists before the fence" ); + ensure!( + self.candidate_cleanup_recorded(abort.candidate_cleanup), + "abort candidate cleanup differs from its prepared activation" + ); } let failed_after_fencing = matches!( @@ -9831,6 +9841,71 @@ mod tests { Ok(()) } + #[test] + fn a_stored_abort_whose_candidate_cleanup_disagrees_with_the_owed_rule_is_refused() -> Result<()> { + let world = ContinuityProjection::after_candidate_observed()?; + // (activation issued, workload observed, recorded cleanup, accepted) + let rows = [ + (false, false, CleanupEvidence::Skipped, true), + (false, false, CleanupEvidence::Pending, false), + (false, false, CleanupEvidence::Complete, false), + (true, false, CleanupEvidence::Skipped, false), + (true, false, CleanupEvidence::Pending, true), + (true, false, CleanupEvidence::Complete, true), + (false, true, CleanupEvidence::Skipped, false), + (false, true, CleanupEvidence::Pending, true), + (true, true, CleanupEvidence::Skipped, false), + (true, true, CleanupEvidence::Complete, true), + ]; + for (has_activation, has_workload, recorded, accepted) in rows { + let label = format!("activation {has_activation}, workload {has_workload}, {recorded:?}"); + let shape = |mut transaction: DeploymentTransaction| -> DeploymentTransaction { + transaction.activation = has_activation.then(|| world.candidate_activation.clone()); + transaction.workload = has_workload.then(|| workload(1000, 1, 2)); + transaction + }; + + let mut pre = shape(world.continuity_transaction()?); + pre.pre_fencing_abort = Some(PreFencingAbort { + error: "candidate refused".into(), + candidate_cleanup: recorded, + topology_reconciliation: pre.abort_topology_reconciliation(), + source_cleanup: CleanupEvidence::Skipped, + }); + assert_eq!(pre.validate().is_ok(), accepted, "pre-fencing, {label}"); + + let mut post = shape(DeploymentTransaction::new( + &command(CommandKind::Deploy), + "ghostlight".into(), + 0, + None, + 100, + )?); + post.phase = DeploymentPhase::Complete; + post.post_fencing_abort = Some(PostFencingAbort { + error: "candidate died after the fence".into(), + route_restoration: CleanupEvidence::Skipped, + lease_withdrawal: CleanupEvidence::Skipped, + candidate_cleanup: recorded, + topology_reconciliation: post.abort_topology_reconciliation(), + source_cleanup: CleanupEvidence::Complete, + }); + post.last_error = Some("candidate died after the fence".into()); + post.completion = Some(TransactionCompletion::FailedAfterFencing { + error: "candidate died after the fence".into(), + recovery: TerminalRecovery::RestoreIncumbent, + }); + if recorded == CleanupEvidence::Pending { + // A Pending cleanup is not terminal, so the completion above + // would already be refused; judge the shape without it. + post.completion = None; + post.phase = DeploymentPhase::Fencing; + } + assert_eq!(post.validate().is_ok(), accepted, "post-fencing, {label}"); + } + Ok(()) + } + #[test] fn bad_selector_becomes_one_terminal_refusal_record() -> Result<()> { let command = command(CommandKind::Deploy); From 61842a395216e4127db7a48228ef5e2e7b6d5466 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:17:27 +0200 Subject: [PATCH 5/9] Pin the chunk-size line against vertical tab and carriage return Rows for a vertical tab before an extension, a carriage return before an extension, and a size line ending in one or two carriage returns, so a wider trim than SP and HTAB, or a stripped trailing carriage return, cannot be taken. Co-Authored-By: Claude Sonnet 5.5 --- src/drivers.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/drivers.rs b/src/drivers.rs index dd9f6c0..8743b94 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -8829,6 +8829,10 @@ mod tests { ("a tab before the chunk size", "\t", ""), ("whitespace after the chunk size and no extension", "", " "), ("whitespace before the size, ahead of an extension", " ", ";x"), + ("a vertical tab before an extension", "", "\x0b;x"), + ("a carriage return before an extension", "", "\r;x"), + ("a bare carriage return ends the size line", "", "\r"), + ("a doubled carriage return ends the size line", "", "\r\r"), ] { assert_refused( challenge( From 88ce30905f7ca4a5016068b55b0abbe2479c8cb3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:31:03 +0200 Subject: [PATCH 6/9] Judge the post-fencing abort test only on terminal shapes The Pending post-fencing rows sat in a Fencing-phase transaction that is refused for unrelated reasons, so the row could not say what it claimed. Post-fencing rows now cover the recorded values a terminal abort carries. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index a009fa7..c72c878 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -9895,13 +9895,11 @@ mod tests { error: "candidate died after the fence".into(), recovery: TerminalRecovery::RestoreIncumbent, }); - if recorded == CleanupEvidence::Pending { - // A Pending cleanup is not terminal, so the completion above - // would already be refused; judge the shape without it. - post.completion = None; - post.phase = DeploymentPhase::Fencing; + // A Pending cleanup is not terminal, so this completed shape is + // only judged for the recorded values a terminal abort can carry. + if recorded != CleanupEvidence::Pending { + assert_eq!(post.validate().is_ok(), accepted, "post-fencing, {label}"); } - assert_eq!(post.validate().is_ok(), accepted, "post-fencing, {label}"); } Ok(()) } From e7ee05342725b5945dd36ea7b090406e608afb12 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:36:31 +0200 Subject: [PATCH 7/9] Drop the workload-only rows from the abort cleanup test A transaction holding a workload and no activation is refused by the fixture before the cleanup rule is reached, so those rows could not speak for the rule. The method-level owed test keeps the workload-only case. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index c72c878..5929245 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -9844,7 +9844,8 @@ mod tests { #[test] fn a_stored_abort_whose_candidate_cleanup_disagrees_with_the_owed_rule_is_refused() -> Result<()> { let world = ContinuityProjection::after_candidate_observed()?; - // (activation issued, workload observed, recorded cleanup, accepted) + // (activation issued, workload observed, recorded cleanup, accepted). + // Workload-only rows are omitted: the fixture refuses that shape for other reasons. let rows = [ (false, false, CleanupEvidence::Skipped, true), (false, false, CleanupEvidence::Pending, false), @@ -9852,8 +9853,6 @@ mod tests { (true, false, CleanupEvidence::Skipped, false), (true, false, CleanupEvidence::Pending, true), (true, false, CleanupEvidence::Complete, true), - (false, true, CleanupEvidence::Skipped, false), - (false, true, CleanupEvidence::Pending, true), (true, true, CleanupEvidence::Skipped, false), (true, true, CleanupEvidence::Complete, true), ]; From bdcd23768ba1d3e022ba5da1849ec2ff1c3f0131 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:41:58 +0200 Subject: [PATCH 8/9] Abort cleanup test rows carry only the activation The workload rows were refused by the fixture on grounds outside the rule, so they were dropped instead of asserted. The workload operand of the owed rule stays pinned by the method-level test. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index 5929245..f724834 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -9844,23 +9844,22 @@ mod tests { #[test] fn a_stored_abort_whose_candidate_cleanup_disagrees_with_the_owed_rule_is_refused() -> Result<()> { let world = ContinuityProjection::after_candidate_observed()?; - // (activation issued, workload observed, recorded cleanup, accepted). - // Workload-only rows are omitted: the fixture refuses that shape for other reasons. + // (activation issued, recorded cleanup, accepted). + // Rows with a workload are omitted: a transaction holding one is refused on + // other grounds, so they could not speak for this rule. The workload operand + // of the owed rule is pinned by the method-level test above. let rows = [ - (false, false, CleanupEvidence::Skipped, true), - (false, false, CleanupEvidence::Pending, false), - (false, false, CleanupEvidence::Complete, false), - (true, false, CleanupEvidence::Skipped, false), - (true, false, CleanupEvidence::Pending, true), - (true, false, CleanupEvidence::Complete, true), - (true, true, CleanupEvidence::Skipped, false), - (true, true, CleanupEvidence::Complete, true), + (false, CleanupEvidence::Skipped, true), + (false, CleanupEvidence::Pending, false), + (false, CleanupEvidence::Complete, false), + (true, CleanupEvidence::Skipped, false), + (true, CleanupEvidence::Pending, true), + (true, CleanupEvidence::Complete, true), ]; - for (has_activation, has_workload, recorded, accepted) in rows { - let label = format!("activation {has_activation}, workload {has_workload}, {recorded:?}"); + for (has_activation, recorded, accepted) in rows { + let label = format!("activation {has_activation}, {recorded:?}"); let shape = |mut transaction: DeploymentTransaction| -> DeploymentTransaction { transaction.activation = has_activation.then(|| world.candidate_activation.clone()); - transaction.workload = has_workload.then(|| workload(1000, 1, 2)); transaction }; From 1afe0466a4e620c49eaca603d3dbcce6b10c0afe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:58:19 +0200 Subject: [PATCH 9/9] verify.sh runs the Windows check before the tests The probe at d32395a hung inside a library test and never reached the Windows check. The check is fast and independent of the tests, so it goes first and cannot be masked by a hang. Co-Authored-By: Claude Sonnet 5.5 --- scripts/verify.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/verify.sh b/scripts/verify.sh index c73a99b..365694e 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # The one verification command. Run it on Linux with the Windows GNU std installed # (the Eureka verify image has both); a Windows workstation proves nothing. +# The Windows check runs first: it is fast, and a hung test cannot mask it. # --lib because the tests under tests/ assume the host layout. set -euo pipefail -cargo test --locked --lib cargo check --locked --target x86_64-pc-windows-gnu --bin idunn-host +cargo test --locked --lib