Skip to content

Commit 9adcf51

Browse files
author
Ralph Küpper
committed
fix(gc): ScheduleGuard's arm bookkeeping must be asymmetric — disarm_poll saturates
off()-disarm-then-Drop-rearm leaks +1 permanently when the disarm lands on a zero word (saturation loses the decrement, the paired arm does not). The leak pinned the poll armed for the rest of the test binary: the timing test slowed and the generation-gate contract took a safepoint drain mid-stage, 2/2 consistently. Only set() arms; only its own Drop releases. 3/3 full-suite runs clean after. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
1 parent 8d098fa commit 9adcf51

1 file changed

Lines changed: 19 additions & 15 deletions

File tree

crates/perry-runtime/src/gc/schedule.rs

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -300,39 +300,43 @@ pub fn gc_schedule_safepoints() -> u64 {
300300
/// RAII test override. `threshold` is taken directly so tests can pin the
301301
/// always/never arms without going through float parsing.
302302
#[cfg(test)]
303-
pub(crate) struct ScheduleGuard(Option<(u64, u64)>);
303+
pub(crate) struct ScheduleGuard {
304+
prev: Option<(u64, u64)>,
305+
armed: bool,
306+
}
304307

305308
#[cfg(test)]
306309
impl ScheduleGuard {
307310
pub(crate) fn set(seed: u64, threshold: u64) -> Self {
308311
// #7781: mirror `ZealGuard` — a schedule that cannot reach the poll
309312
// decides at six event-loop boundaries instead of thousands of
310-
// back-edges. Arm on set, release on drop, exactly the zeal pair.
313+
// back-edges. Arm on set, release on drop.
314+
//
315+
// The bookkeeping is deliberately ASYMMETRIC: only `set` arms, and only
316+
// its own `Drop` releases. `off()` must NOT disarm-then-let-Drop-rearm:
317+
// `disarm_poll` saturates at zero, so a disarm that lands on 0 is lost
318+
// while the paired re-arm is not — a permanent +1 leak that pins the
319+
// poll armed for the rest of the process. Over-arming for a guard's
320+
// lifetime costs a wasted call; a leaked arm is forever.
311321
let prev = SCHEDULE_OVERRIDE.with(|cell| cell.replace(Some((seed, threshold))));
312-
if prev.is_none() {
322+
let armed = prev.is_none();
323+
if armed {
313324
super::arm_poll();
314325
}
315-
Self(prev)
326+
Self { prev, armed }
316327
}
317328
pub(crate) fn off() -> Self {
318329
let prev = SCHEDULE_OVERRIDE.with(|cell| cell.replace(None));
319-
if prev.is_some() {
320-
super::disarm_poll();
321-
}
322-
Self(prev)
330+
Self { prev, armed: false }
323331
}
324332
}
325333

326334
#[cfg(test)]
327335
impl Drop for ScheduleGuard {
328336
fn drop(&mut self) {
329-
let restored = self.0;
330-
let current = SCHEDULE_OVERRIDE.with(|cell| cell.replace(restored));
331-
// Re-balance the arm to match the transition this drop performs.
332-
match (current.is_some(), restored.is_some()) {
333-
(true, false) => super::disarm_poll(),
334-
(false, true) => super::arm_poll(),
335-
_ => {}
337+
SCHEDULE_OVERRIDE.with(|cell| cell.set(self.prev));
338+
if self.armed {
339+
super::disarm_poll();
336340
}
337341
}
338342
}

0 commit comments

Comments
 (0)