Skip to main content

detcore/
slice_boundary.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Slice-boundary observation/reset API. Authenticated startup activates it
10//! for a typed recording or replay. Actual callback observations stay separate
11//! from expectations; reset adds no scheduler request.
12
13use anyhow::bail;
14use detcore_model::futex::MmId;
15use detcore_model::schedule::InstructionPointer;
16use detcore_model::schedule::boundary::Boundary;
17use detcore_model::schedule::boundary::Cause;
18use detcore_model::schedule::boundary::Entry;
19use detcore_model::schedule::boundary::Identity;
20use detcore_model::schedule::boundary::Owner;
21use detcore_model::schedule::boundary::Phase;
22use reverie::syscalls::Sysno;
23use serde::Deserialize;
24use serde::Serialize;
25
26use crate::ThreadState;
27use crate::config::Config;
28use crate::scheduler::Priority;
29
30/// Typed grant transport; the legacy logical-nanosecond scalar cannot carry
31/// a guest-RCB target. The live owner authenticates every lookahead.
32pub mod grant;
33
34/// Authenticated logical-thread identity across creation, exit and exec.
35pub(crate) mod lifecycle;
36
37/// Tokens for actual semantic callback entries, retained across awaits.
38pub(crate) mod callbacks;
39
40/// Counters owned by one live logical thread/address-space generation.
41/// Creation and exec must install a freshly authenticated owner; replay hints
42/// cannot initialize or advance this state.
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44pub struct Observations {
45    owner: Owner,
46    mm: MmId,
47    checkin: u64,
48    completed_slices: u64,
49    syscall_occurrence: u64,
50    signal_entry_occurrence: u64,
51    active_syscall: Option<(Sysno, u64)>,
52    current: Option<(Entry, Phase, u64)>,
53    expected_budget: Option<grant::Budget>,
54    pending_start_budget: Option<(grant::Budget, u64)>,
55    start_reset_done: bool,
56}
57
58/// One actual reset, with the completed identity captured before it. The
59/// caller must trace/validate this record immediately before its existing
60/// resource request, and only then install a replay budget. This object does
61/// not grant a turn or introduce a TraceReplay resource.
62#[derive(Debug)]
63pub struct ClosedSlice {
64    /// Evidence of the completed slice, not the newly selected one.
65    pub boundary: Boundary,
66    /// The unmodified priority choice returned by `next_timeslice`.
67    pub next_priority: Option<Priority>,
68}
69
70impl Observations {
71    /// Construct counters from an authenticated scheduler lifecycle identity.
72    /// This path never consumes an expected boundary to choose an owner.
73    fn from_owner<T>(thread: &ThreadState<T>, owner: Owner) -> anyhow::Result<Self> {
74        if owner.tid != thread.dettid
75            || owner.birth_tid.as_raw() <= 0
76            || owner.address_space_creator != thread.mm_id.creator()
77            || owner.address_space_generation != thread.mm_id.generation()
78        {
79            bail!("slice-boundary protocol refusal: lifecycle reply does not name this image");
80        }
81        let mut state = Self::new(thread, owner.thread_generation);
82        state.owner = owner;
83        Ok(state)
84    }
85
86    /// Bind a fresh observation state to the actual thread and address-space
87    /// generation. The thread-generation allocator remains a scheduler
88    /// integration obligation; this API does not infer it from host timing.
89    pub fn new<T>(thread: &ThreadState<T>, thread_generation: u64) -> Self {
90        Self {
91            owner: Owner {
92                tid: thread.dettid,
93                thread_generation,
94                birth_tid: thread.dettid,
95                birth_generation: thread_generation,
96                address_space_creator: thread.mm_id.creator(),
97                address_space_generation: thread.mm_id.generation(),
98            },
99            mm: thread.mm_id,
100            checkin: 0,
101            completed_slices: 0,
102            syscall_occurrence: 0,
103            signal_entry_occurrence: 0,
104            active_syscall: None,
105            current: None,
106            expected_budget: None,
107            pending_start_budget: None,
108            start_reset_done: false,
109        }
110    }
111
112    fn require_owner<T>(&self, thread: &ThreadState<T>) -> anyhow::Result<()> {
113        if self.owner.tid != thread.dettid || self.mm != thread.mm_id {
114            bail!(
115                "slice-boundary protocol refusal: stale observation owner {:?}",
116                self.owner
117            );
118        }
119        Ok(())
120    }
121
122    fn publish<T>(
123        &mut self,
124        thread: &ThreadState<T>,
125        entry: Entry,
126        phase: Phase,
127    ) -> anyhow::Result<()> {
128        self.require_owner(thread)?;
129        let checkin = self.checkin.checked_add(1).ok_or_else(|| {
130            anyhow::anyhow!("slice-boundary protocol refusal: semantic check-in overflow")
131        })?;
132        if self
133            .current
134            .is_some_and(|(_, _, rcbs)| thread.committed_clock_value < rcbs)
135        {
136            bail!("slice-boundary protocol refusal: committed guest RCB count moved backwards");
137        }
138        self.checkin = checkin;
139        self.current = Some((entry, phase, thread.committed_clock_value));
140        Ok(())
141    }
142
143    /// Observe an actual syscall check-in. The prehook allocates its own
144    /// occurrence; its posthook must name that same syscall. Hidden backend
145    /// stops and injected-call retries must not invoke this method.
146    pub fn syscall<T>(
147        &mut self,
148        thread: &ThreadState<T>,
149        syscall: Sysno,
150        phase: Phase,
151    ) -> anyhow::Result<()> {
152        let occurrence = match phase {
153            Phase::Prehook => self.syscall_occurrence.checked_add(1).ok_or_else(|| {
154                anyhow::anyhow!("slice-boundary protocol refusal: syscall occurrence overflow")
155            })?,
156            Phase::Posthook => match self.active_syscall {
157                Some((active, occurrence)) if active == syscall => occurrence,
158                _ => {
159                    bail!("slice-boundary protocol refusal: syscall posthook has no matching entry")
160                }
161            },
162            Phase::TimerMaximum => bail!("slice-boundary protocol refusal: syscall at timer body"),
163        };
164        self.publish(
165            thread,
166            Entry::Syscall {
167                syscall,
168                occurrence,
169            },
170            phase,
171        )?;
172        match phase {
173            Phase::Prehook => {
174                self.syscall_occurrence = occurrence;
175                self.active_syscall = Some((syscall, occurrence));
176            }
177            Phase::Posthook => self.active_syscall = None,
178            Phase::TimerMaximum => unreachable!(),
179        }
180        Ok(())
181    }
182
183    /// Observe a non-syscall callback delivered to Detcore. Timer mechanisms
184    /// must be distinguished by the actual callback, not RIP or expectations.
185    /// Signal delivery identity is the already selected logical delivery.
186    pub fn callback<T>(
187        &mut self,
188        thread: &ThreadState<T>,
189        entry: Entry,
190        phase: Phase,
191    ) -> anyhow::Result<()> {
192        match entry {
193            Entry::Syscall { .. } => {
194                bail!("slice-boundary protocol refusal: use syscall occurrence allocator")
195            }
196            Entry::Signal {
197                signal, delivery, ..
198            } if delivery == 0 || !(1..=64).contains(&signal.raw()) => {
199                bail!("slice-boundary protocol refusal: invalid logical signal delivery");
200            }
201            _ => self.publish(thread, entry, phase),
202        }
203    }
204
205    /// Snapshot the completed identity and reset the actual thread exactly
206    /// once. No replay expectation is accepted as an observation. Refusals
207    /// precede any reset, RNG draw, clock change, or occurrence advancement.
208    pub fn close_slice<T>(
209        &mut self,
210        thread: &mut ThreadState<T>,
211        config: &Config,
212        explicit_yield: bool,
213        rip: Option<InstructionPointer>,
214    ) -> anyhow::Result<ClosedSlice> {
215        let identity = self.current_identity(thread, explicit_yield)?;
216        let boundary = Boundary {
217            identity,
218            count: 1,
219            start_rip: None,
220            end_rip: rip,
221            end_time: Some(thread.thread_logical_time.as_nanos()),
222        };
223        if let Some(expected) = self.expected_budget
224            && expected.expected() != boundary.identity
225        {
226            bail!(
227                "slice-boundary protocol refusal at stored index {}: expected {:?}, observed {:?}",
228                expected.stored_index(),
229                expected.expected(),
230                boundary.identity
231            );
232        }
233        let next_priority = thread.next_timeslice(config);
234        self.completed_slices = identity.slice;
235        self.expected_budget = None;
236        Ok(ClosedSlice {
237            boundary,
238            next_priority,
239        })
240    }
241
242    fn current_identity<T>(
243        &self,
244        thread: &ThreadState<T>,
245        explicit_yield: bool,
246    ) -> anyhow::Result<Identity> {
247        self.require_owner(thread)?;
248        if thread.own_signal_turn_hold.is_some() {
249            bail!("slice-boundary protocol refusal: boundary conflicts with own-signal turn hold");
250        }
251        let Some((entry, phase, rcbs)) = self.current else {
252            bail!("slice-boundary protocol refusal: slice end has no semantic check-in");
253        };
254        if rcbs != thread.committed_clock_value {
255            bail!("slice-boundary protocol refusal: guest progressed beyond the observed check-in");
256        }
257        let cause = if explicit_yield {
258            if !matches!(
259                entry,
260                Entry::Syscall {
261                    syscall: Sysno::sched_yield,
262                    ..
263                }
264            ) {
265                bail!("slice-boundary protocol refusal: explicit yield at a different syscall");
266            }
267            Cause::ExplicitSchedYield
268        } else {
269            match entry {
270                Entry::PmuTimer => Cause::PmuTimer,
271                Entry::NoRcbTimer => Cause::NoRcbTimer,
272                _ => Cause::SoftwareCheckin,
273            }
274        };
275        let slice = self.completed_slices.checked_add(1).ok_or_else(|| {
276            anyhow::anyhow!("slice-boundary protocol refusal: slice occurrence overflow")
277        })?;
278        Ok(Identity {
279            owner: self.owner,
280            cause,
281            phase,
282            entry,
283            committed_rcbs: rcbs,
284            checkin: self.checkin,
285            slice,
286        })
287    }
288
289    /// Whether the next expected boundary belongs at this actual check-in.
290    /// Expected fields select a point, never fill the observed identity.
291    /// Explicit yield and no-RCB maximum bodies keep their existing callers.
292    fn replay_expiration<T>(&self, thread: &ThreadState<T>) -> anyhow::Result<Option<bool>> {
293        let Some(budget) = self.expected_budget else {
294            return Ok(None);
295        };
296        self.require_owner(thread)?;
297        let expected = budget.expected();
298        if self.checkin > expected.checkin || thread.committed_clock_value > expected.committed_rcbs
299        {
300            bail!(
301                "slice-boundary protocol refusal at stored index {}: passed expected {:?}; actual check-in {}, guest RCBs {}",
302                budget.stored_index(),
303                expected,
304                self.checkin,
305                thread.committed_clock_value
306            );
307        }
308        if self.checkin < expected.checkin {
309            return Ok(Some(false));
310        }
311        let observed = self.current_identity(thread, false)?;
312        // This automatic hook cannot claim an explicit yield occurred. It
313        // checks only its location; the sched_yield handler must later close
314        // with its independently known explicit cause and the full identity.
315        let mut location = expected;
316        if expected.cause == Cause::ExplicitSchedYield {
317            location.cause = Cause::SoftwareCheckin;
318        }
319        if observed != location {
320            bail!(
321                "slice-boundary protocol refusal at stored index {}: expected {:?}, observed {:?}",
322                budget.stored_index(),
323                expected,
324                observed
325            );
326        }
327        Ok(Some(expected.cause != Cause::ExplicitSchedYield))
328    }
329}
330
331pub(crate) fn replay_expiration<T>(thread: &ThreadState<T>) -> anyhow::Result<Option<bool>> {
332    match &thread.slice_boundary {
333        Some(observed) => observed.replay_expiration(thread),
334        None => Ok(None),
335    }
336}
337
338pub(crate) fn no_rcb_maximum<T>(thread: &mut ThreadState<T>) -> anyhow::Result<()> {
339    if let Some(mut observed) = thread.slice_boundary.take() {
340        let result = observed.publish(thread, Entry::NoRcbTimer, Phase::TimerMaximum);
341        thread.slice_boundary = Some(observed);
342        result?;
343    }
344    Ok(())
345}
346
347/// Adopt the owner authenticated by the same existing startup/exec grant.
348/// Repeated ordinary grants leave every counter and pending budget intact.
349/// Exec invalidates only observation state, never ThreadState's clocks,
350/// committed turns, reset statistics, PRNG or configured timer maximum.
351pub(crate) fn adopt_owner<T>(
352    thread: &mut ThreadState<T>,
353    owner: Owner,
354    starts_thread: bool,
355) -> anyhow::Result<()> {
356    let candidate = Observations::from_owner(thread, owner)?;
357    if let Some(prior) = &thread.slice_boundary {
358        if prior.owner == owner {
359            return prior.require_owner(thread);
360        }
361        if (prior.owner.birth_tid, prior.owner.birth_generation)
362            != (owner.birth_tid, owner.birth_generation)
363            || prior.owner.address_space_generation.checked_add(1)
364                != Some(owner.address_space_generation)
365            || prior.mm == thread.mm_id
366        {
367            bail!("slice-boundary protocol refusal: lifecycle reply replaced a different thread");
368        }
369    } else if !starts_thread {
370        bail!("slice-boundary protocol refusal: first owner did not arrive through startup");
371    }
372    thread.slice_boundary = Some(candidate);
373    // A previous image's precise target cannot govern the replacement image.
374    thread.replay_rcb_end = None;
375    Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380    use std::num::NonZeroU64;
381    use std::time::Duration;
382
383    use detcore_model::schedule::boundary::Record;
384    use detcore_model::schedule::boundary::Schedule;
385
386    use super::*;
387    use crate::types::DetTid;
388
389    fn fixture() -> (Config, ThreadState<()>, Observations) {
390        let cfg = Config {
391            max_timeslice: NonZeroU64::new(200_000_000),
392            ..Default::default()
393        };
394        let mut thread = ThreadState::new(DetTid::from_raw(3), &cfg, ());
395        thread.next_timeslice(&cfg);
396        let observations = Observations::new(&thread, 0);
397        (cfg, thread, observations)
398    }
399
400    #[test]
401    fn shared_and_copied_clone_address_spaces_are_distinct_in_the_stored_identity() {
402        let cfg = Config::default();
403        let parent = DetTid::from_raw(3);
404        let child = DetTid::from_raw(4);
405        let mut shared = ThreadState::new(child, &cfg, ());
406        shared.mm_id = MmId::for_clone(MmId::initial(parent), child, true);
407        let mut copied = ThreadState::new(child, &cfg, ());
408        copied.mm_id = MmId::for_clone(MmId::initial(parent), child, false);
409        let mut a = Observations::new(&shared, 0);
410        let mut b = Observations::new(&copied, 0);
411        a.callback(&shared, Entry::Cpuid, Phase::Prehook).unwrap();
412        b.callback(&copied, Entry::Cpuid, Phase::Prehook).unwrap();
413        let a = a
414            .close_slice(&mut shared, &cfg, false, None)
415            .unwrap()
416            .boundary;
417        let b = b
418            .close_slice(&mut copied, &cfg, false, None)
419            .unwrap()
420            .boundary;
421        assert_ne!(a.identity.owner, b.identity.owner);
422        let schedule = Schedule::new(vec![Record::TimesliceEnd(a)]).unwrap();
423        let mut cursor = schedule.cursor();
424        assert!(cursor.observe(&Record::TimesliceEnd(b)).is_err());
425        assert_eq!(cursor.stored_index(), 0);
426    }
427
428    #[test]
429    fn actual_reset_snapshots_completed_slice_without_moving_guest_clock() {
430        let (cfg, mut thread, mut observed) = fixture();
431        thread.committed_clock_value = 71;
432        thread.thread_logical_time.add_rcbs(71);
433        thread.stats.count_syscall(Sysno::write);
434        observed
435            .syscall(&thread, Sysno::write, Phase::Prehook)
436            .unwrap();
437        let before_clock = thread.thread_logical_time.clone();
438        let before_slices = thread.stats.timeslice_count;
439        let closed = observed
440            .close_slice(&mut thread, &cfg, false, None)
441            .unwrap();
442        assert_eq!(closed.boundary.identity.committed_rcbs, 71);
443        assert_eq!(closed.boundary.identity.slice, 1);
444        assert_eq!(closed.boundary.end_time, Some(before_clock.as_nanos()));
445        assert_eq!(
446            thread.thread_logical_time.as_nanos(),
447            before_clock.as_nanos()
448        );
449        assert_eq!(thread.committed_clock_value, 71);
450        assert_eq!(thread.stats.timeslice_count, before_slices + 1);
451        assert_eq!(thread.stats.timeslice_syscall_count, 0);
452        assert_eq!(
453            thread.max_timeslice_end,
454            Some(before_clock.as_nanos() + Duration::from_nanos(200_000_000))
455        );
456    }
457
458    #[test]
459    fn adjacent_zero_rcb_resets_and_repeated_syscalls_have_distinct_identities() {
460        let (cfg, mut thread, mut observed) = fixture();
461        observed
462            .syscall(&thread, Sysno::write, Phase::Prehook)
463            .unwrap();
464        let first = observed
465            .close_slice(&mut thread, &cfg, false, None)
466            .unwrap()
467            .boundary;
468        let second = observed
469            .close_slice(&mut thread, &cfg, false, None)
470            .unwrap()
471            .boundary;
472        assert_eq!(first.identity.checkin, second.identity.checkin);
473        assert_eq!(
474            first.identity.committed_rcbs,
475            second.identity.committed_rcbs
476        );
477        assert_ne!(first.identity.slice, second.identity.slice);
478        observed
479            .syscall(&thread, Sysno::write, Phase::Posthook)
480            .unwrap();
481        observed
482            .syscall(&thread, Sysno::write, Phase::Prehook)
483            .unwrap();
484        let third = observed
485            .close_slice(&mut thread, &cfg, false, None)
486            .unwrap()
487            .boundary;
488        assert_ne!(first.identity.entry, third.identity.entry);
489        Schedule::new(vec![
490            Record::TimesliceEnd(first),
491            Record::TimesliceEnd(second),
492            Record::TimesliceEnd(third),
493        ])
494        .unwrap();
495    }
496
497    #[test]
498    fn signal_hold_stale_exec_and_wrong_callback_refuse_before_reset() {
499        for bad in 0..5 {
500            let (cfg, mut thread, mut observed) = fixture();
501            observed
502                .syscall(&thread, Sysno::write, Phase::Prehook)
503                .unwrap();
504            match bad {
505                0 => thread.own_signal_turn_hold = Some(libc::SIGALRM),
506                1 => thread.mm_id = thread.mm_id.for_exec(thread.dettid),
507                2 => thread.committed_clock_value += 1,
508                3 => observed.completed_slices = u64::MAX,
509                4 => {}
510                _ => unreachable!(),
511            }
512            let before = observed.clone();
513            let slices = thread.stats.timeslice_count;
514            let end = thread.end_of_timeslice;
515            let max = thread.max_timeslice_end;
516            let clock = thread.thread_logical_time.as_nanos();
517            assert!(
518                observed
519                    .close_slice(&mut thread, &cfg, bad == 4, None)
520                    .is_err()
521            );
522            assert_eq!(observed, before);
523            assert_eq!(thread.stats.timeslice_count, slices);
524            assert_eq!(thread.end_of_timeslice, end);
525            assert_eq!(thread.max_timeslice_end, max);
526            assert_eq!(thread.thread_logical_time.as_nanos(), clock);
527        }
528    }
529
530    #[test]
531    fn pmu_no_rcb_and_software_callbacks_are_not_inferred_from_rip() {
532        for (entry, cause) in [
533            (Entry::Cpuid, Cause::SoftwareCheckin),
534            (Entry::PmuTimer, Cause::PmuTimer),
535            (Entry::NoRcbTimer, Cause::NoRcbTimer),
536        ] {
537            let (cfg, mut thread, mut observed) = fixture();
538            observed.callback(&thread, entry, Phase::Prehook).unwrap();
539            let closed = observed
540                .close_slice(&mut thread, &cfg, false, None)
541                .unwrap();
542            assert_eq!(closed.boundary.identity.cause, cause);
543            assert_eq!(closed.boundary.end_rip, None);
544        }
545    }
546
547    #[test]
548    fn posthook_and_counter_overflow_fail_without_advancing_observation() {
549        let (_, thread, mut observed) = fixture();
550        let before = observed.clone();
551        assert!(
552            observed
553                .syscall(&thread, Sysno::read, Phase::Posthook)
554                .is_err()
555        );
556        assert_eq!(observed, before);
557        observed.checkin = u64::MAX;
558        let before = observed.clone();
559        assert!(
560            observed
561                .syscall(&thread, Sysno::read, Phase::Prehook)
562                .is_err()
563        );
564        assert_eq!(observed, before);
565    }
566
567    #[test]
568    fn boundary_software_expiry_requires_actual_entry_phase_rcbs_and_occurrence() {
569        let (config, mut thread, mut observed) = fixture();
570        observed
571            .syscall(&thread, Sysno::poll, Phase::Prehook)
572            .unwrap();
573        let mut recorded_thread = ThreadState::new(thread.dettid, &config, ());
574        let mut recorded = observed.clone();
575        recorded
576            .syscall(&recorded_thread, Sysno::poll, Phase::Posthook)
577            .unwrap();
578        let marker = recorded
579            .close_slice(&mut recorded_thread, &config, false, None)
580            .unwrap()
581            .boundary;
582        let schedule = Schedule::new(vec![Record::TimesliceEnd(marker)]).unwrap();
583        let hint = grant::Budget::at(&schedule, 0).unwrap();
584        thread.slice_boundary = Some(observed);
585        grant::install(&mut thread, hint).unwrap();
586        let clocks = (
587            thread.committed_clock_value,
588            thread.thread_logical_time.as_nanos(),
589            thread.end_of_timeslice,
590            thread.max_timeslice_end,
591        );
592        assert_eq!(replay_expiration(&thread).unwrap(), Some(false));
593        let mut observed = thread.slice_boundary.take().unwrap();
594        observed
595            .syscall(&thread, Sysno::poll, Phase::Posthook)
596            .unwrap();
597        thread.slice_boundary = Some(observed);
598        assert_eq!(replay_expiration(&thread).unwrap(), Some(true));
599        assert_eq!(
600            clocks,
601            (
602                thread.committed_clock_value,
603                thread.thread_logical_time.as_nanos(),
604                thread.end_of_timeslice,
605                thread.max_timeslice_end
606            )
607        );
608        for field in 0..5 {
609            let mut bad = thread.slice_boundary.clone().unwrap();
610            match field {
611                0 => bad.checkin += 1,
612                1 => bad.current.as_mut().unwrap().0 = Entry::Cpuid,
613                2 => bad.current.as_mut().unwrap().1 = Phase::Prehook,
614                3 => bad.owner.birth_generation += 1,
615                4 => bad.current.as_mut().unwrap().2 += 1,
616                _ => unreachable!(),
617            }
618            assert!(bad.replay_expiration(&thread).is_err(), "field {field}");
619        }
620        thread.own_signal_turn_hold = Some(libc::SIGUSR1);
621        assert!(replay_expiration(&thread).is_err());
622    }
623
624    #[test]
625    fn boundary_explicit_yield_is_consumed_only_by_its_original_handler() {
626        let (config, mut thread, mut observed) = fixture();
627        observed
628            .syscall(&thread, Sysno::sched_yield, Phase::Prehook)
629            .unwrap();
630        let mut copy = observed.clone();
631        let mut record_thread = ThreadState::new(thread.dettid, &config, ());
632        let marker = copy
633            .close_slice(&mut record_thread, &config, true, None)
634            .unwrap()
635            .boundary;
636        let schedule = Schedule::new(vec![Record::TimesliceEnd(marker)]).unwrap();
637        thread.slice_boundary = Some(observed);
638        grant::install(&mut thread, grant::Budget::at(&schedule, 0).unwrap()).unwrap();
639        assert_eq!(replay_expiration(&thread).unwrap(), Some(false));
640        let mut observed = thread.slice_boundary.take().unwrap();
641        let before = thread.stats.timeslice_count;
642        assert!(
643            observed
644                .close_slice(&mut thread, &config, false, None)
645                .is_err()
646        );
647        assert_eq!(thread.stats.timeslice_count, before);
648        observed
649            .close_slice(&mut thread, &config, true, None)
650            .unwrap();
651        assert_eq!(thread.stats.timeslice_count, before + 1);
652    }
653
654    #[test]
655    fn boundary_no_rcb_maximum_is_distinct_from_its_earlier_prehook() {
656        let (config, mut thread, mut observed) = fixture();
657        observed
658            .callback(&thread, Entry::NoRcbTimer, Phase::Prehook)
659            .unwrap();
660        let mut copy = observed.clone();
661        let mut record_thread = ThreadState::new(thread.dettid, &config, ());
662        copy.publish(&record_thread, Entry::NoRcbTimer, Phase::TimerMaximum)
663            .unwrap();
664        let marker = copy
665            .close_slice(&mut record_thread, &config, false, None)
666            .unwrap()
667            .boundary;
668        let schedule = Schedule::new(vec![Record::TimesliceEnd(marker)]).unwrap();
669        thread.slice_boundary = Some(observed);
670        grant::install(&mut thread, grant::Budget::at(&schedule, 0).unwrap()).unwrap();
671        assert_eq!(replay_expiration(&thread).unwrap(), Some(false));
672        no_rcb_maximum(&mut thread).unwrap();
673        assert_eq!(replay_expiration(&thread).unwrap(), Some(true));
674        let mut observed = thread.slice_boundary.take().unwrap();
675        assert_eq!(
676            observed
677                .close_slice(&mut thread, &config, false, None)
678                .unwrap()
679                .boundary
680                .identity
681                .phase,
682            Phase::TimerMaximum
683        );
684    }
685}