From d5a3ba193441c7a4616d0b4b1c0c7196d5ee3153 Mon Sep 17 00:00:00 2001 From: "Claude (sandbox)" Date: Sat, 18 Jul 2026 21:55:56 +0000 Subject: [PATCH] =?UTF-8?q?fix(causal):=20born=20current=20=E2=80=94=20slo?= =?UTF-8?q?t=20reuse=20booked=20phantom=20park=20forgiveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh/reused slot started with causal_delay=0 and causal_parked=true: the first resume then 'forgave' the entire monotone global backlog, once per spawn, booked as park forgiveness. Under close-mode conn churn (~95k spawns/s) that is millions of phantom forgiven ms per 700ms window, even in 0% cells — the books could not balance under actor churn while ka-mode stayed plausible (few spawns). Impacts were unaffected: the first resume always precedes the first check, so nobody ever spun. reset_counters now installs Coz's new-thread rule: causal_delay starts at the current global ledger and causal_parked starts false — a newborn neither owes nor is forgiven the process's history, and delay injected while it sits spawn-queued (runnable, not blocked) is owed and paid at its first check, the semantics audit_zero_pct_window_absorbs_leftover_ debt pins. That test (born failing, unmasked by the run() propagation fix) and the new actor_churn_between_experiments_forgives_nothing regression test both go green. --- src/runtime.rs | 28 +++++++++++++++++++++------ tests/causal.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index 8b8d075..f491d3c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -463,8 +463,10 @@ pub(crate) struct Slot { /// instant-wake re-queue). Consumed by `causal::on_resume`: only a wake /// from genuine blocking forgives outstanding virtual delay; a merely /// preempted (runnable) actor stays in debt and must pay by spinning. - /// Starts true so a fresh spawn doesn't inherit the process's whole delay - /// history. Written by the owning scheduler thread at the deschedule / + /// Starts false: a fresh spawn is born current (`reset_counters` sets + /// `causal_delay` to the global ledger), and anything injected while it + /// sits spawn-queued is owed, not waived. Written by the owning + /// scheduler thread at the deschedule / /// resume boundary only — same single-writer discipline as `causal_delay`. causal_parked: AtomicBool, /// RFC 007 — TSC at this actor's last *runnable* (yield) deschedule @@ -495,7 +497,10 @@ impl Slot { budget_cycles: AtomicU64::new(0), causal_site: AtomicU32::new(0), causal_delay: AtomicU64::new(0), - causal_parked: AtomicBool::new(true), + // Overwritten at every occupancy by `reset_counters` (born + // current); false so a hypothetical reset-skipping path cannot + // waive the whole global backlog at first resume. + causal_parked: AtomicBool::new(false), causal_desched_tsc: AtomicU64::new(0), causal_desched_epoch: AtomicU64::new(0), cold: RawMutex::new(SlotCold { @@ -587,10 +592,21 @@ impl Slot { self.messages_received.store(0, Ordering::Relaxed); self.budget_cycles.store(0, Ordering::Relaxed); self.causal_site.store(0, Ordering::Relaxed); + // Born current (Coz's new-thread rule): a fresh incarnation neither + // owes the process's accumulated delay history nor books it as park + // forgiveness. The old init (delay 0, parked true) waived the whole + // monotone backlog once per spawn via the first resume — found live + // under close-mode conn churn (~95k spawns/s): millions of phantom + // forgiven ms per 700ms window, even in 0% cells. Parked starts + // false: delay injected while spawn-queued is *owed* (the newborn is + // runnable, not blocked) and paid at its first check — the semantics + // `audit_zero_pct_window_absorbs_leftover_debt` pins. + #[cfg(feature = "smarm-causal")] + self.causal_delay + .store(crate::causal::global_delay_cycles(), Ordering::Relaxed); + #[cfg(not(feature = "smarm-causal"))] self.causal_delay.store(0, Ordering::Relaxed); - // True, not false: the first resume of a fresh actor is a "wake" — - // it must not owe the process's accumulated delay history. - self.causal_parked.store(true, Ordering::Relaxed); + self.causal_parked.store(false, Ordering::Relaxed); self.causal_desched_tsc.store(0, Ordering::Relaxed); self.causal_desched_epoch.store(0, Ordering::Relaxed); } diff --git a/tests/causal.rs b/tests/causal.rs index acd1c8c..338ae45 100644 --- a/tests/causal.rs +++ b/tests/causal.rs @@ -977,3 +977,53 @@ fn audit_offcpu_gap_across_windows_not_counted() { ); }); } + +/// Slot reuse under actor churn must not book the process's accumulated +/// delay history as park forgiveness. A fresh incarnation is born current +/// (`causal_delay = GLOBAL_DELAY` at install), so churning short-lived +/// actors while no experiment is live forgives exactly nothing: the global +/// ledger is frozen (checks gate on the experiment word) and a newborn +/// starts even with it. Found live (urus close-mode load, ~95k conn +/// spawns/s): delay=0 + parked=true at install forgave the whole backlog +/// once per spawn — millions of phantom ms per 700ms window, even in 0% +/// cells, while ka-mode books stayed plausible. +#[test] +fn actor_churn_between_experiments_forgives_nothing() { + let _s = serial(); + smarm::init(smarm::Config::exact(1)).run(|| { + // Seed a nonzero global backlog with a real experiment. + let stop = Arc::new(AtomicBool::new(false)); + let stop_t = stop.clone(); + let target = smarm::spawn(move || { + let _g = smarm::causal_site!("churn-seed-site"); + while !stop_t.load(Ordering::Relaxed) { + smarm::check!(); + } + }); + let g0 = smarm::causal::global_delay_cycles(); + smarm::causal::begin_experiment_for_test("churn-seed-site", 50); + smarm::sleep(Duration::from_millis(60)); + smarm::causal::end_experiment_for_test(); + let backlog = smarm::causal::global_delay_cycles() - g0; + assert!(backlog > 0, "seed experiment injected nothing"); + stop.store(true, Ordering::Relaxed); + target.join().unwrap(); + + // Every live actor's wake (the sleep, the joins) is behind us and + // the ledger is frozen; snapshot, then churn reused slots with + // trivial actors. The first resume alone is the trigger — the actor + // body never parks. + let c0 = smarm::causal::ledger_counters(); + for _ in 0..200 { + smarm::spawn(|| {}).join().unwrap(); + } + let c1 = smarm::causal::ledger_counters(); + + let forgiven = c1.park_forgiven_cycles - c0.park_forgiven_cycles; + assert_eq!( + forgiven, 0, + "spawn churn booked {forgiven} phantom forgiveness cycles \ + against a frozen {backlog}-cycle backlog" + ); + }); +}