fix(causal): born current — slot reuse booked phantom park forgiveness
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.
This commit is contained in:
+22
-6
@@ -463,8 +463,10 @@ pub(crate) struct Slot {
|
|||||||
/// instant-wake re-queue). Consumed by `causal::on_resume`: only a wake
|
/// instant-wake re-queue). Consumed by `causal::on_resume`: only a wake
|
||||||
/// from genuine blocking forgives outstanding virtual delay; a merely
|
/// from genuine blocking forgives outstanding virtual delay; a merely
|
||||||
/// preempted (runnable) actor stays in debt and must pay by spinning.
|
/// 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
|
/// Starts false: a fresh spawn is born current (`reset_counters` sets
|
||||||
/// history. Written by the owning scheduler thread at the deschedule /
|
/// `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`.
|
/// resume boundary only — same single-writer discipline as `causal_delay`.
|
||||||
causal_parked: AtomicBool,
|
causal_parked: AtomicBool,
|
||||||
/// RFC 007 — TSC at this actor's last *runnable* (yield) deschedule
|
/// RFC 007 — TSC at this actor's last *runnable* (yield) deschedule
|
||||||
@@ -495,7 +497,10 @@ impl Slot {
|
|||||||
budget_cycles: AtomicU64::new(0),
|
budget_cycles: AtomicU64::new(0),
|
||||||
causal_site: AtomicU32::new(0),
|
causal_site: AtomicU32::new(0),
|
||||||
causal_delay: AtomicU64::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_tsc: AtomicU64::new(0),
|
||||||
causal_desched_epoch: AtomicU64::new(0),
|
causal_desched_epoch: AtomicU64::new(0),
|
||||||
cold: RawMutex::new(SlotCold {
|
cold: RawMutex::new(SlotCold {
|
||||||
@@ -587,10 +592,21 @@ impl Slot {
|
|||||||
self.messages_received.store(0, Ordering::Relaxed);
|
self.messages_received.store(0, Ordering::Relaxed);
|
||||||
self.budget_cycles.store(0, Ordering::Relaxed);
|
self.budget_cycles.store(0, Ordering::Relaxed);
|
||||||
self.causal_site.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);
|
self.causal_delay.store(0, Ordering::Relaxed);
|
||||||
// True, not false: the first resume of a fresh actor is a "wake" —
|
self.causal_parked.store(false, Ordering::Relaxed);
|
||||||
// it must not owe the process's accumulated delay history.
|
|
||||||
self.causal_parked.store(true, Ordering::Relaxed);
|
|
||||||
self.causal_desched_tsc.store(0, Ordering::Relaxed);
|
self.causal_desched_tsc.store(0, Ordering::Relaxed);
|
||||||
self.causal_desched_epoch.store(0, Ordering::Relaxed);
|
self.causal_desched_epoch.store(0, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user