feat(causal): timer-heap virtual time — deadlines chase injected delay (RFC 007)

Injected delays dilate virtual time for the workload, but timer deadlines
stayed wall-anchored: a sleep or receive-timeout fired early in virtual
terms, so timeout/retry behaviour sped up relative to the dilated world
(v1 known gap #1).

Every heap Entry now carries delay_stamp — the global delay ledger at
(re-)queue time, cfg-gated on smarm-causal. pop_due converts any debt
accrued since the stamp to wall time (tsc_hz) and shifts the effective
deadline; a not-yet-due entry is re-queued at the shifted deadline with a
fresh stamp, so it keeps chasing delay injected while it waits. seq is
preserved across re-queues, keeping send_after cancellation identity
intact (cancelled entries are discarded before any shift). Zero debt is
byte-identical to the old path; peek_deadline may under-report, costing
one spurious scheduler wake per injected chunk (documented).

This also makes the park-gated resume credit *correct* rather than
forgiving for sleepers: a sleeping actor now physically pays its debt by
sleeping longer, so the on_resume fast-forward reflects real payment
(sleeping_actor_pays_injected_delay pins this end-to-end through the
runtime).

New test hooks: inject_delay_cycles_for_test (deterministic ledger
driver, eagerly TSC-calibrating so conversion never stalls a scheduler
loop) and cycles_to_duration. The ledger is process-global, so the
delta-sensitive causal tests now serialize on a shared test mutex — they
were racy under the parallel test harness before this, in principle.
This commit is contained in:
Claude (sandbox)
2026-07-13 07:31:08 +00:00
parent d496914d40
commit 04dbac1f4b
3 changed files with 237 additions and 13 deletions
+154
View File
@@ -73,6 +73,7 @@ fn site_guard_nesting_restores() {
/// catches up to the global one) instead of running delay-free.
#[test]
fn virtual_speedup_ledger() {
let _s = serial();
let global_out = Arc::new(AtomicU64::new(0));
let absorbed_out = Arc::new(AtomicU64::new(0));
let g_out = global_out.clone();
@@ -137,6 +138,7 @@ fn virtual_speedup_ledger() {
/// resume made all bystanders delay-immune, so experiments moved nothing.)
#[test]
fn runnable_bystander_pays_delay() {
let _s = serial();
let baseline = Arc::new(AtomicU64::new(0));
let during = Arc::new(AtomicU64::new(0));
let b_out = baseline.clone();
@@ -218,6 +220,7 @@ fn runnable_bystander_pays_delay() {
/// baseline Coz relies on, and it doubles as the overhead sanity check.
#[test]
fn zero_speedup_injects_nothing() {
let _s = serial();
smarm::init(smarm::Config::exact(2)).run(|| {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
@@ -299,6 +302,7 @@ fn impact_from_synthetic_results() {
/// section with experiment / throughput-point lines.
#[test]
fn controller_produces_report() {
let _s = serial();
smarm::init(smarm::Config::exact(2)).run(|| {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
@@ -341,6 +345,7 @@ fn controller_produces_report() {
/// (+83.5% where theory says +100%).
#[test]
fn site_boundaries_flush_tail() {
let _s = serial();
fn tsc() -> u64 {
// SAFETY: rdtsc has no preconditions on x86_64.
unsafe { core::arch::x86_64::_rdtsc() }
@@ -388,3 +393,152 @@ fn site_boundaries_flush_tail() {
"boundary flush attributed {injected} of {spent} spent cycles (want ~50%)"
);
}
// ---------------------------------------------------------------------------
// RFC 007 timer virtual time: injected delay dilates virtual time for
// everyone, so a pending timer's *effective* deadline is its raw deadline
// plus whatever delay was injected while it was armed. Without this, a
// sleep or receive-timeout fires "early" in virtual terms and timeout-driven
// behaviour speeds up relative to the dilated world.
//
// These tests (and every ledger-delta test above) share the process-global
// delay ledger, so they serialize on `serial()`.
// ---------------------------------------------------------------------------
fn serial() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A pending timer must not fire at its raw deadline if delay was injected
/// after it was armed; it fires once wall time covers raw + injected.
#[test]
fn timer_deadline_shifts_with_injected_delay() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::time::Instant;
let hz = smarm::causal::tsc_hz();
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now + Duration::from_millis(50), Pid::new(0, 0), 1);
// 100ms of debt lands while the timer is pending.
smarm::causal::inject_delay_cycles_for_test((hz * 0.100) as u64);
// Raw deadline passed, effective deadline not: nothing fires, entry kept.
assert!(t.pop_due(now + Duration::from_millis(60)).is_empty());
assert!(!t.is_empty(), "shifted entry must be re-queued, not dropped");
// Past raw + injected (with margin) it must fire. Chase in case a
// parallel test injected more debt meanwhile.
let mut fire_at = now + Duration::from_millis(170);
let mut fired = false;
for _ in 0..40 {
if !t.pop_due(fire_at).is_empty() {
fired = true;
break;
}
fire_at += Duration::from_millis(50);
}
assert!(fired, "timer never fired after covering injected delay");
}
/// With no debt accrued since insertion, behaviour is byte-identical to
/// before: due entries fire at their raw deadline.
#[test]
fn timer_zero_debt_pops_at_raw_deadline() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::time::Instant;
let mut t = Timers::new();
let now = Instant::now();
t.insert_sleep(now - Duration::from_millis(1), Pid::new(0, 0), 1);
assert_eq!(t.pop_due(now).len(), 1);
assert!(t.is_empty());
}
/// Re-queueing a shifted entry must preserve its identity: a `send_after`
/// cancelled *after* being shifted past its raw deadline must still cancel
/// (return true) and must never deliver.
#[test]
fn send_after_cancel_survives_reheap() {
let _s = serial();
use smarm::pid::Pid;
use smarm::timer::Timers;
use std::sync::atomic::AtomicBool;
use std::time::Instant;
let hz = smarm::causal::tsc_hz();
let mut t = Timers::new();
let now = Instant::now();
let delivered = Arc::new(AtomicBool::new(false));
let d2 = delivered.clone();
let id = t.insert_send(
now + Duration::from_millis(20),
Pid::new(0, 0),
Box::new(move || d2.store(true, Ordering::Relaxed)),
);
smarm::causal::inject_delay_cycles_for_test((hz * 0.100) as u64);
// Raw deadline passed: shifted, not fired.
assert!(t.pop_due(now + Duration::from_millis(30)).is_empty());
// Cancellation still works on the re-queued entry.
assert!(t.cancel(id), "cancel lost track of a re-queued send_after");
// Even far past the effective deadline nothing is delivered.
for e in t.pop_due(now + Duration::from_secs(3600)) {
if let smarm::timer::Reason::Send { fire } = e.reason {
fire();
}
}
assert!(
!delivered.load(Ordering::Relaxed),
"cancelled send_after delivered after re-heap"
);
}
/// End-to-end through the runtime: an actor mid-`sleep` pays delay injected
/// while it is parked by sleeping *longer* — which is exactly what makes the
/// park-gated resume-credit fast-forward correct rather than forgiving.
#[test]
fn sleeping_actor_pays_injected_delay() {
let _s = serial();
use std::time::Instant;
let slept = Arc::new(AtomicU64::new(0));
let s_out = slept.clone();
smarm::init(smarm::Config::exact(1)).run(move || {
let hz = smarm::causal::tsc_hz();
let s2 = s_out.clone();
let sleeper = smarm::spawn(move || {
let t0 = Instant::now();
smarm::sleep(Duration::from_millis(50));
s2.store(t0.elapsed().as_millis() as u64, Ordering::Relaxed);
});
// Let the sleeper arm its timer, then inject 150ms of debt from a
// plain OS thread — no slot, so nothing spin-absorbs it here and the
// only path by which it can slow the sleeper is the timer shift.
smarm::sleep(Duration::from_millis(10));
std::thread::spawn(move || {
smarm::causal::inject_delay_cycles_for_test((hz * 0.150) as u64);
})
.join()
.unwrap();
sleeper.join().unwrap();
});
let ms = slept.load(Ordering::Relaxed);
// Raw sleep 50ms + ~150ms debt injected mid-sleep => ~200ms effective.
// Loose lower bound; the pre-fix behaviour is ~50ms.
assert!(
ms >= 150,
"sleeper paid nothing: slept {ms}ms, expected >= 150ms (raw 50 + injected 150)"
);
}