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
+14
View File
@@ -397,6 +397,20 @@ mod inner {
end();
}
/// Test support: grow the global delay ledger directly, as if target-site
/// samples had attributed `cycles` — deterministic driver for the timer
/// virtual-time tests. Calibrates the TSC eagerly so conversion later
/// never stalls a scheduler loop.
pub fn inject_delay_cycles_for_test(cycles: u64) {
let _ = tsc_hz();
GLOBAL_DELAY.fetch_add(cycles, Ordering::Relaxed);
}
/// Convert ledger cycles to wall time at the measured TSC rate.
pub fn cycles_to_duration(cycles: u64) -> Duration {
Duration::from_secs_f64(cycles as f64 / tsc_hz())
}
/// Controller parameters: which speedups to try per site, and the
/// experiment/cooldown windows.
pub struct ExperimentPlan {
+69 -13
View File
@@ -102,6 +102,12 @@ pub struct Entry {
seq: u64,
pub pid: Pid,
pub reason: Reason,
/// RFC 007 virtual time: the global delay ledger reading when this entry
/// was (re-)queued. `pop_due` shifts the effective deadline by any delay
/// injected since, so timers dilate together with the causally-delayed
/// workload instead of firing early in virtual terms.
#[cfg(feature = "smarm-causal")]
delay_stamp: u64,
}
impl PartialEq for Entry {
@@ -166,7 +172,14 @@ impl Timers {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.armed.insert(seq);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason: Reason::Send { fire } }));
self.heap.push(Reverse(Entry {
deadline,
seq,
pid,
reason: Reason::Send { fire },
#[cfg(feature = "smarm-causal")]
delay_stamp: crate::causal::global_delay_cycles(),
}));
TimerId(seq)
}
@@ -183,7 +196,14 @@ impl Timers {
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason }));
self.heap.push(Reverse(Entry {
deadline,
seq,
pid,
reason,
#[cfg(feature = "smarm-causal")]
delay_stamp: crate::causal::global_delay_cycles(),
}));
}
pub fn is_empty(&self) -> bool {
@@ -210,22 +230,58 @@ impl Timers {
/// one is silently dropped here (its `seq` was already removed from
/// `armed` by [`cancel`](Self::cancel)). Returning it removes it from
/// `armed`, so a later `cancel` of a fired timer reports `false`.
///
/// RFC 007 virtual time (feature `smarm-causal`): before an entry fires,
/// any global delay injected since it was (re-)queued is added to its
/// deadline; an entry whose *effective* deadline hasn't passed is pushed
/// back with the shifted deadline and a fresh stamp, so it keeps chasing
/// delay injected while it waits. Consequences, both benign:
/// [`peek_deadline`](Self::peek_deadline) may under-report (raw deadline
/// earlier than effective), costing at most one spurious scheduler wake
/// per injected chunk; and a shift never converts wall time — with zero
/// debt the path is byte-identical to the featureless one.
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
let mut out = Vec::new();
#[cfg(feature = "smarm-causal")]
let global = crate::causal::global_delay_cycles();
while let Some(r) = self.heap.peek() {
if r.0.deadline <= now {
let entry = match self.heap.pop() {
Some(e) => e.0,
None => panic!("smarm: timer heap pop after peek returned None (core corrupt)"),
};
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.remove(&entry.seq) {
// Cancelled before it came due: discard, do not deliver.
continue;
}
out.push(entry);
} else {
if r.0.deadline > now {
break;
}
#[allow(unused_mut)]
let mut entry = match self.heap.pop() {
Some(e) => e.0,
None => panic!("smarm: timer heap pop after peek returned None (core corrupt)"),
};
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.contains(&entry.seq) {
// Cancelled before it came due: discard, do not deliver.
// (Checked before any shift so a cancelled entry is never
// re-queued just to be discarded later.)
continue;
}
#[cfg(feature = "smarm-causal")]
{
let debt = global.saturating_sub(entry.delay_stamp);
if debt > 0 {
let shifted = entry
.deadline
.checked_add(crate::causal::cycles_to_duration(debt))
.unwrap_or(entry.deadline);
if shifted > now {
// Not due in virtual time: re-queue at the shifted
// deadline, stamped, keeping `seq` (and thus `Send`
// cancellation identity) intact.
entry.deadline = shifted;
entry.delay_stamp = global;
self.heap.push(Reverse(entry));
continue;
}
}
}
if matches!(entry.reason, Reason::Send { .. }) {
self.armed.remove(&entry.seq);
}
out.push(entry);
}
out
}