Skip to main content

detcore/preemptions/
boundary_recording.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//! File-format boundary between legacy editors and explicit slice records.
10//! The scheduler consumes the complete stream; legacy editors must refuse it.
11//! History-only replay and read-only inspection never rewrite that stream.
12
13use std::path::Path;
14
15use anyhow::Context;
16use anyhow::bail;
17use detcore_model::schedule::boundary::Schedule;
18use serde::Deserialize;
19use serde::Serialize;
20
21use super::PreemptionRecord;
22
23/// Keep the preemption history and every existing run setting alongside the
24/// complete boundary stream. No public access returns its legacy component:
25/// doing so would let an editor silently discard the protocol and markers.
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27#[serde(try_from = "BoundaryWire", into = "BoundaryWire")]
28pub struct BoundaryRecording {
29    preemptions: PreemptionRecord,
30    schedule: Schedule,
31}
32
33#[derive(Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct BoundaryWire {
36    preemptions: PreemptionRecord,
37    schedule: Schedule,
38}
39
40/// The wrapper is intentional. Old readers require top-level `per_thread`
41/// and `global`; they therefore refuse this format even for zero markers.
42#[derive(Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct Envelope {
45    slice_boundary_recording: BoundaryRecording,
46}
47
48/// Whether a record is in the slice-boundary format. The probe builds none of
49/// the record: it skips every value, so reading the format of a schedule of
50/// millions of events does not cost a JSON tree of it, several times the
51/// size of its text.
52#[derive(Deserialize)]
53struct FormatProbe {
54    // A present null must not look like an absent protocol marker.
55    #[serde(default, deserialize_with = "marker_present")]
56    slice_boundary_recording: bool,
57    #[serde(default, deserialize_with = "marker_present")]
58    incomplete_preemption_record: bool,
59}
60
61fn marker_present<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
62    serde::de::IgnoredAny::deserialize(deserializer).map(|_| true)
63}
64
65impl FormatProbe {
66    fn is_boundary_recording(bytes: &[u8]) -> serde_json::Result<bool> {
67        serde_json::from_slice::<Self>(bytes).map(|probe| probe.slice_boundary_recording)
68    }
69}
70
71pub(super) fn read_settings<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
72    let bytes = std::fs::read(path)
73        .map_err(|e| format!("cannot read preemption record {}: {}", path.display(), e))?;
74    let format: FormatProbe = serde_json::from_slice(&bytes)
75        .map_err(|e| format!("cannot parse preemption record {}: {}", path.display(), e))?;
76    if !format.slice_boundary_recording && !format.incomplete_preemption_record {
77        // Each old metadata reader still deserializes only its own field,
78        // from the original bytes, preserving its legacy acceptance contract.
79        return serde_json::from_slice(&bytes)
80            .map_err(|e| format!("cannot parse preemption record {}: {}", path.display(), e));
81    }
82    // Settings do not depend on the record's format, which the replay itself
83    // checks and refuses (`Recording::decode`), with the refusal's exit status.
84    let recording = Recording::decode_unchecked_format(&bytes)
85        .map_err(|e| format!("cannot parse preemption record {}: {}", path.display(), e))?;
86    serde_json::from_value(
87        super::record_serde::settings_value(recording.metadata()).map_err(|e| e.to_string())?,
88    )
89    .map_err(|e| format!("cannot parse preemption record {}: {}", path.display(), e))
90}
91
92impl BoundaryRecording {
93    /// Combine a validated preemption history with the sole global record
94    /// stream. The legacy history must not contain another global stream. The
95    /// history's format is checked by [`Recording::decode`], which refuses it
96    /// as a policy refusal (`RecordFormatRefusal`): a deserializer would lose
97    /// that error's type.
98    pub fn new(preemptions: PreemptionRecord, schedule: Schedule) -> anyhow::Result<Self> {
99        preemptions
100            .validate_contents()
101            .map_err(anyhow::Error::msg)?;
102        if !preemptions.global.is_empty() {
103            bail!("slice-boundary recording has a second, legacy global event stream");
104        }
105        Ok(Self {
106            preemptions,
107            schedule,
108        })
109    }
110
111    /// A read-only copy of every recorded thread history. It contains no
112    /// global stream and cannot flatten or rewrite the boundary protocol.
113    pub fn thread_histories(
114        &self,
115    ) -> std::collections::BTreeMap<crate::types::DetTid, super::ThreadHistory> {
116        self.preemptions.extract_all()
117    }
118
119    /// The complete stored stream, including boundary indices.
120    pub fn schedule(&self) -> &Schedule {
121        &self.schedule
122    }
123}
124
125impl TryFrom<BoundaryWire> for BoundaryRecording {
126    type Error = anyhow::Error;
127
128    fn try_from(wire: BoundaryWire) -> Result<Self, Self::Error> {
129        Self::new(wire.preemptions, wire.schedule)
130    }
131}
132
133impl From<BoundaryRecording> for BoundaryWire {
134    fn from(recording: BoundaryRecording) -> Self {
135        Self {
136            preemptions: recording.preemptions,
137            schedule: recording.schedule,
138        }
139    }
140}
141
142/// A recording whose format remains explicit through file transformations.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub enum Recording {
145    /// The unchanged legacy preemption/event representation.
146    Legacy(PreemptionRecord),
147    /// A versioned boundary stream and its associated preemption history.
148    Boundaries(BoundaryRecording),
149}
150
151impl Recording {
152    /// Refuse a known omitted change point in either stored format.
153    pub fn require_faithful_replay(&self) -> anyhow::Result<()> {
154        self.metadata().require_faithful_replay()
155    }
156
157    /// A retained source observation of an omitted replay change point.
158    pub fn replay_departed_at(&self) -> Option<u64> {
159        self.metadata().replay_departed_at()
160    }
161
162    /// Require an explicit matching execution policy. The strict verifier must
163    /// not silently fall back to an incomplete legacy stream, and ordinary
164    /// recovery must not silently acquire strict EOF/desync semantics.
165    pub fn require_strict_mode(&self, strict: bool) -> anyhow::Result<()> {
166        match (self, strict) {
167            (Self::Legacy(_), false) | (Self::Boundaries(_), true) => Ok(()),
168            (Self::Legacy(_), true) => {
169                anyhow::bail!("strict schedule boundaries require a complete boundary recording")
170            }
171            (Self::Boundaries(_), false) => {
172                anyhow::bail!("complete boundary recording requires --strict-schedule-boundaries")
173            }
174        }
175    }
176
177    /// Validate and parse either format without a malformed-new-to-legacy
178    /// fallback.
179    /// A record whose format this Hermit refuses fails with a
180    /// `RecordFormatRefusal` in its chain, whichever format it is in.
181    pub fn decode(bytes: &[u8]) -> anyhow::Result<Self> {
182        let recording = Self::decode_unchecked_format(bytes)?;
183        recording
184            .metadata()
185            .validate_format()
186            .map_err(|refusal| anyhow::Error::new(super::RecordFormatRefusal(refusal)))?;
187        Ok(recording)
188    }
189
190    /// [`Self::decode`], except for the record's format.
191    fn decode_unchecked_format(bytes: &[u8]) -> anyhow::Result<Self> {
192        // Inspect only to dispatch. Deserialize the ORIGINAL bytes again so
193        // duplicate fields in the envelope are rejected. A malformed new
194        // recording never falls back to legacy.
195        if FormatProbe::is_boundary_recording(bytes)? {
196            let envelope: Envelope = serde_json::from_slice(bytes)?;
197            Ok(Self::Boundaries(envelope.slice_boundary_recording))
198        } else {
199            let legacy: PreemptionRecord = serde_json::from_slice(bytes)?;
200            legacy.validate_contents().map_err(anyhow::Error::msg)?;
201            Ok(Self::Legacy(legacy))
202        }
203    }
204
205    /// Read a complete recording, preserving errors as failures.
206    pub fn read(path: &Path) -> anyhow::Result<Self> {
207        Self::decode(&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?)
208            .with_context(|| format!("reading schedule {}", path.display()))
209    }
210
211    /// Serialize the same format with the conventional terminal newline.
212    pub fn encode(&self) -> anyhow::Result<Vec<u8>> {
213        let mut bytes = match self {
214            Self::Legacy(record) => serde_json::to_vec(record)?,
215            Self::Boundaries(record) => serde_json::to_vec(&Envelope {
216                slice_boundary_recording: record.clone(),
217            })?,
218        };
219        bytes.push(b'\n');
220        Ok(bytes)
221    }
222
223    /// Write the complete serialized recording, without changing its format.
224    pub fn write(&self, path: &Path) -> anyhow::Result<()> {
225        // Complete validation/serialization precedes opening the destination.
226        let bytes = self.encode()?;
227        std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))
228    }
229
230    /// Give a legacy-only operation its input, or refuse a boundary format
231    /// before that operation can discard its protocol or write an output.
232    pub fn into_legacy(self, operation: &'static str) -> anyhow::Result<PreemptionRecord> {
233        match self {
234            Self::Legacy(record) => Ok(record),
235            Self::Boundaries(_) => {
236                Err(detcore_model::schedule::boundary::Error::UnsupportedEdit { operation }.into())
237            }
238        }
239    }
240
241    /// Remove only optional event timestamps, retaining boundary identities,
242    /// RIP observations, preemption history and run settings.
243    pub fn strip_times(&mut self) {
244        match self {
245            Self::Legacy(record) => {
246                record.strip_event_times();
247            }
248            Self::Boundaries(record) => record.schedule.strip_times(),
249        }
250    }
251
252    /// Check the full decoded input that this loader will install. The
253    /// standalone metadata remains outside every returned ThreadHistory.
254    pub fn validate_replay_inputs(
255        &self,
256        source: &str,
257        guest_ids: detcore_model::pid::GuestIds,
258    ) -> Result<(), super::RecordAdmissionRefusal> {
259        self.metadata().validate_replay_inputs(source, guest_ids)
260    }
261
262    /// The opaque private-input metadata, without flattening this envelope.
263    pub fn reserved_input(&self) -> &super::ReservedInputMetadata {
264        self.metadata().reserved_input()
265    }
266
267    /// Numbering stored alongside either complete event representation.
268    pub fn guest_ids(&self) -> detcore_model::pid::GuestIds {
269        self.metadata().guest_ids().unwrap_or_default()
270    }
271
272    /// The recorded virtual-time epoch, if the recording states one.
273    pub fn epoch(&self) -> Option<chrono::DateTime<chrono::Utc>> {
274        self.metadata().epoch()
275    }
276
277    /// The recorded pipe-wake latency, preserving `Some(0)` versus off.
278    pub fn pipe_wake_latency(&self) -> Option<u64> {
279        self.metadata().pipe_wake_latency()
280    }
281
282    /// The recorded signal-copy setting, including the legacy unknown case.
283    pub fn signal_state_copy(&self) -> Option<bool> {
284        self.metadata().signal_state_copy()
285    }
286
287    /// The host files the recorded run's guest opened, if the recording says.
288    pub fn host_inputs(&self) -> Option<&detcore_model::host_input::HostInputSet> {
289        self.metadata().host_inputs()
290    }
291
292    /// The recorded jumps of global time (`PreemptionRecord::time_skips`).
293    pub fn time_skips(&self) -> Option<&[super::TimeSkip]> {
294        self.metadata().time_skips()
295    }
296
297    /// Whether the recording lists every jump of global time its run made
298    /// (`PreemptionRecord::lists_its_time_skips`).
299    pub fn lists_its_time_skips(&self) -> bool {
300        self.metadata().lists_its_time_skips()
301    }
302
303    /// Whether a replay of this recording charges a replayed switch-in under
304    /// which guest code runs (`PreemptionRecord::charges_replayed_switch_ins`).
305    pub fn charges_replayed_switch_ins(&self) -> bool {
306        self.metadata().charges_replayed_switch_ins()
307    }
308
309    /// The recording's format (`PreemptionRecord::format_version`).
310    pub fn format_version(&self) -> Option<u32> {
311        self.metadata().format_version()
312    }
313
314    fn metadata(&self) -> &PreemptionRecord {
315        match self {
316            Self::Legacy(record) => record,
317            Self::Boundaries(record) => &record.preemptions,
318        }
319    }
320}
321
322/// Runtime access to recorded priorities/RCB history, retaining its complete
323/// backing format. This reader cannot export a flattened legacy recording.
324#[derive(Debug)]
325pub(crate) struct HistoryReader(Recording);
326
327impl HistoryReader {
328    // Production installs only histories admitted for the actual numbering
329    // policy. Format/serialization tests also exercise the raw reader.
330    #[cfg(test)]
331    pub(crate) fn read(path: &Path) -> anyhow::Result<Self> {
332        Recording::read(path).map(Self)
333    }
334
335    /// Read and admit the same bytes used for priority replay. A policy error
336    /// retains its RecordAdmissionRefusal type for the pre-start exit-122 path.
337    pub(crate) fn read_for_replay(
338        path: &Path,
339        guest_ids: detcore_model::pid::GuestIds,
340    ) -> anyhow::Result<Self> {
341        let recording = Recording::read(path)?;
342        recording.validate_replay_inputs(
343            &format!("--replay-preemptions-from {}", path.display()),
344            guest_ids,
345        )?;
346        Ok(Self(recording))
347    }
348
349    /// The host files the recorded run's guest opened, if the recording says.
350    pub(crate) fn host_inputs(&self) -> Option<&detcore_model::host_input::HostInputSet> {
351        self.0.host_inputs()
352    }
353
354    /// History-only replay cannot honor boundary records. Admission must not
355    /// silently project a new-format recording into legacy priorities/RCBs.
356    pub(crate) fn require_legacy_mode(&self) -> anyhow::Result<()> {
357        self.0.require_strict_mode(false)
358    }
359
360    /// Only explicit edited history exploration may consume known omissions.
361    pub(crate) fn require_replay_mode(&self, edited: bool) -> anyhow::Result<()> {
362        self.require_legacy_mode()?;
363        if !edited {
364            self.0.require_faithful_replay()?;
365        }
366        Ok(())
367    }
368
369    pub(crate) fn replay_departed_at(&self) -> Option<u64> {
370        self.0.replay_departed_at()
371    }
372
373    pub(crate) fn thread_initial_priority(
374        &self,
375        tid: &crate::types::DetTid,
376    ) -> Option<crate::scheduler::runqueue::Priority> {
377        self.0
378            .metadata()
379            .per_thread
380            .get(tid)
381            .map(|history| history.initial_priority())
382    }
383
384    pub(crate) fn extract_thread_record(
385        &self,
386        tid: &crate::types::DetTid,
387    ) -> Option<super::ThreadHistory> {
388        self.0.metadata().per_thread.get(tid).cloned()
389    }
390
391    /// The validated callback checkpoints of this history, per thread, in
392    /// row order (`PreemptionRecord::validate`).
393    pub(crate) fn callback_checkpoints(
394        &self,
395    ) -> &std::collections::BTreeMap<crate::types::DetTid, Vec<super::callback_history::Checkpoint>>
396    {
397        self.0.metadata().callback_checkpoints()
398    }
399
400    /// The store-exclusives that failed in the recorded run.
401    #[cfg(target_arch = "aarch64")]
402    pub(crate) fn store_exclusive_failures(&self) -> u64 {
403        self.0.metadata().store_exclusive_failures()
404    }
405}
406
407impl std::fmt::Display for HistoryReader {
408    fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        let bytes = self.0.encode().map_err(|_| std::fmt::Error)?;
410        output.write_str(std::str::from_utf8(&bytes).map_err(|_| std::fmt::Error)?)
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use detcore_model::schedule::boundary::Boundary;
417    use detcore_model::schedule::boundary::Cause;
418    use detcore_model::schedule::boundary::Entry;
419    use detcore_model::schedule::boundary::Identity;
420    use detcore_model::schedule::boundary::Owner;
421    use detcore_model::schedule::boundary::Phase;
422    use detcore_model::schedule::boundary::Record;
423    use reverie::syscalls::Sysno;
424
425    use super::*;
426    use crate::preemptions::PreemptionReader;
427    use crate::preemptions::PreemptionWriter;
428    use crate::preemptions::strip_times_from_events_file;
429    use crate::types::DetTid;
430    use crate::types::LogicalTime;
431    use crate::types::SchedEvent;
432
433    fn sample(markers: bool) -> Recording {
434        let metadata: PreemptionRecord = serde_json::from_str(
435            &PreemptionWriter::new(None)
436                .with_epoch("2001-02-03T04:05:06Z".parse().unwrap())
437                .with_pipe_wake_latency(Some(0))
438                .with_signal_state_copy(false)
439                .with_expired_wait_runs_charged_once(true)
440                .into_string(),
441        )
442        .unwrap();
443        let records = if markers {
444            vec![Record::TimesliceEnd(Boundary {
445                identity: Identity {
446                    owner: Owner {
447                        tid: DetTid::from_raw(3),
448                        thread_generation: 0,
449                        birth_tid: DetTid::from_raw(3),
450                        birth_generation: 0,
451                        address_space_creator: DetTid::from_raw(3),
452                        address_space_generation: 0,
453                    },
454                    cause: Cause::SoftwareCheckin,
455                    phase: Phase::Posthook,
456                    entry: Entry::Syscall {
457                        syscall: Sysno::write,
458                        occurrence: 1,
459                    },
460                    committed_rcbs: 17,
461                    checkin: 1,
462                    slice: 1,
463                },
464                count: 1,
465                start_rip: None,
466                end_rip: Some(std::num::NonZeroUsize::new(0x1234).unwrap()),
467                end_time: Some(LogicalTime::from_nanos(123)),
468            })]
469        } else {
470            vec![]
471        };
472        Recording::Boundaries(
473            BoundaryRecording::new(metadata, Schedule::new(records).unwrap()).unwrap(),
474        )
475    }
476
477    #[test]
478    fn actual_legacy_reader_rejects_even_an_empty_boundary_recording() {
479        for markers in [false, true] {
480            let recording = sample(markers);
481            let bytes = recording.encode().unwrap();
482            assert!(serde_json::from_slice::<PreemptionRecord>(&bytes).is_err());
483            assert_eq!(Recording::decode(&bytes).unwrap(), recording);
484            assert!(recording.into_legacy("minimize").is_err());
485        }
486    }
487
488    #[test]
489    fn file_strip_preserves_protocol_metadata_and_mandatory_identity() {
490        let directory = tempfile::tempdir().unwrap();
491        for markers in [false, true] {
492            let input = directory.path().join(format!("{markers}.json"));
493            let original = sample(markers);
494            original.write(&input).unwrap();
495            let original_bytes = std::fs::read(&input).unwrap();
496            let output = strip_times_from_events_file(&input, None).unwrap();
497            let actual = Recording::read(&output).unwrap();
498            let mut expected = original.clone();
499            expected.strip_times();
500            assert_eq!(actual, expected);
501            assert_eq!(std::fs::read(&input).unwrap(), original_bytes);
502            assert_eq!(actual.epoch(), original.epoch());
503            assert_eq!(actual.pipe_wake_latency(), Some(0));
504            assert_eq!(actual.signal_state_copy(), Some(false));
505            assert_eq!(
506                super::super::read_recorded_epoch(&output).unwrap(),
507                original.epoch()
508            );
509            assert_eq!(
510                super::super::read_recorded_pipe_wake_latency(&output).unwrap(),
511                Some(0)
512            );
513            assert_eq!(
514                super::super::read_recorded_signal_state_copy(&output).unwrap(),
515                Some(false)
516            );
517            assert_eq!(
518                super::super::read_recorded_expired_wait_runs_charged_once(&output),
519                Ok(true)
520            );
521            assert!(PreemptionReader::try_new(&output).is_err());
522            assert!(
523                serde_json::from_slice::<PreemptionRecord>(&std::fs::read(output).unwrap())
524                    .is_err()
525            );
526        }
527    }
528
529    /// The format is the presence of the top-level marker, null included,
530    /// read without building any value.
531    #[test]
532    fn the_format_is_the_presence_of_the_top_level_marker() {
533        let probe = |text: &str| FormatProbe::is_boundary_recording(text.as_bytes()).unwrap();
534        assert!(!probe(r#"{"per_thread": {}, "global": []}"#));
535        assert!(probe(r#"{"slice_boundary_recording": {"preemptions": 1}}"#));
536        assert!(probe(r#"{"slice_boundary_recording": null}"#));
537        assert!(!probe(
538            r#"{"per_thread": {"3": {"slice_boundary_recording": 1}}, "global": []}"#
539        ));
540        assert!(
541            FormatProbe::is_boundary_recording(
542                br#"{"slice_boundary_recording": null, "slice_boundary_recording": null}"#
543            )
544            .is_err()
545        );
546        assert!(FormatProbe::is_boundary_recording(b"{\"global\": [").is_err());
547    }
548
549    #[test]
550    fn unknown_or_broken_envelope_never_falls_back_to_valid_legacy_fields() {
551        let mut value: serde_json::Value =
552            serde_json::from_slice(&sample(false).encode().unwrap()).unwrap();
553        value["per_thread"] = serde_json::json!({});
554        value["global"] = serde_json::json!([]);
555        assert!(Recording::decode(&serde_json::to_vec(&value).unwrap()).is_err());
556        value["slice_boundary_recording"] = serde_json::Value::Null;
557        assert!(Recording::decode(&serde_json::to_vec(&value).unwrap()).is_err());
558        let body = String::from_utf8(sample(false).encode().unwrap()).unwrap();
559        let duplicated = format!(
560            "{{{}, {}}}",
561            body.trim()[1..body.trim().len() - 1].to_owned(),
562            body.trim()[1..body.trim().len() - 1].to_owned()
563        );
564        assert!(Recording::decode(duplicated.as_bytes()).is_err());
565        let directory = tempfile::tempdir().unwrap();
566        let path = directory.path().join("bad.json");
567        std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
568        assert!(super::super::read_recorded_epoch(&path).is_err());
569        assert!(super::super::read_recorded_pipe_wake_latency(&path).is_err());
570        assert!(super::super::read_recorded_signal_state_copy(&path).is_err());
571    }
572
573    /// A record of a format this Hermit refuses is refused as a policy
574    /// refusal (`RecordFormatRefusal`) in either envelope, while its settings
575    /// stay readable, so the replay itself refuses it with that exit status.
576    #[test]
577    fn a_refused_format_is_a_refusal_in_either_envelope() {
578        let refused = |value: &serde_json::Value| {
579            Recording::decode(&serde_json::to_vec(value).unwrap())
580                .unwrap_err()
581                .chain()
582                .any(|cause| cause.is::<super::super::RecordFormatRefusal>())
583        };
584        let mut boundary: serde_json::Value =
585            serde_json::from_slice(&sample(false).encode().unwrap()).unwrap();
586        boundary["slice_boundary_recording"]["preemptions"]["format_version"] =
587            serde_json::json!(99);
588        assert!(refused(&boundary));
589        let directory = tempfile::tempdir().unwrap();
590        let path = directory.path().join("refused.json");
591        std::fs::write(&path, serde_json::to_vec(&boundary).unwrap()).unwrap();
592        assert!(super::super::read_recorded_epoch(&path).unwrap().is_some());
593        let mut plain = serde_json::to_value(PreemptionRecord::default()).unwrap();
594        plain["format_version"] = serde_json::json!(99);
595        assert!(refused(&plain));
596    }
597
598    #[test]
599    fn boundary_history_reader_preserves_the_complete_format_and_original_thread_histories() {
600        let dir = tempfile::tempdir().unwrap();
601        let path = dir.path().join("recording.json");
602        let tid = crate::types::DetTid::from_raw(3);
603        let mut writer = PreemptionWriter::new(Some(path.clone()))
604            .with_slice_boundaries()
605            .unwrap();
606        writer.register_thread(tid, crate::scheduler::runqueue::DEFAULT_PRIORITY);
607        let expected_history = writer.inner.per_thread.get(&tid).cloned();
608        writer.flush().unwrap();
609        let before = std::fs::read(&path).unwrap();
610        let history = HistoryReader::read(&path).unwrap();
611        assert!(history.require_legacy_mode().is_err());
612        assert_eq!(
613            history.thread_initial_priority(&tid),
614            Some(crate::scheduler::runqueue::DEFAULT_PRIORITY)
615        );
616        assert_eq!(history.extract_thread_record(&tid), expected_history);
617        assert_eq!(history.to_string().as_bytes(), before);
618        assert!(super::super::PreemptionReader::try_new(&path).is_err());
619        let mut bad: serde_json::Value = serde_json::from_slice(&before).unwrap();
620        bad["slice_boundary_recording"]["schedule"]["version"] = 999.into();
621        std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
622        assert!(HistoryReader::read(&path).is_err());
623        assert_ne!(std::fs::read(&path).unwrap(), before);
624    }
625
626    #[test]
627    fn a_second_event_stream_is_not_accepted_as_metadata() {
628        let metadata =
629            PreemptionRecord::from_sched_events(vec![SchedEvent::branches(DetTid::from_raw(3), 1)]);
630        assert!(BoundaryRecording::new(metadata, Schedule::new(vec![]).unwrap()).is_err());
631    }
632
633    #[test]
634    fn legacy_bytes_and_editor_access_remain_legacy() {
635        let legacy =
636            PreemptionRecord::from_sched_events(vec![SchedEvent::branches(DetTid::from_raw(3), 2)])
637                .with_pipe_wake_latency(Some(7))
638                .with_signal_state_copy(Some(true));
639        let mut expected = serde_json::to_vec(&legacy).unwrap();
640        expected.push(b'\n');
641        let record = Recording::Legacy(legacy.clone());
642        assert_eq!(record.encode().unwrap(), expected);
643        assert_eq!(
644            Recording::decode(&expected)
645                .unwrap()
646                .into_legacy("split")
647                .unwrap(),
648            legacy
649        );
650    }
651
652    #[test]
653    fn legacy_metadata_readers_keep_their_individual_field_contracts() {
654        let directory = tempfile::tempdir().unwrap();
655        let path = directory.path().join("legacy.json");
656        std::fs::write(&path, r#"{"epoch":null,"pipe_wake_latency":"invalid"}"#).unwrap();
657        assert_eq!(super::super::read_recorded_epoch(&path), Ok(None));
658        assert!(super::super::read_recorded_pipe_wake_latency(&path).is_err());
659        assert_eq!(
660            super::super::read_recorded_signal_state_copy(&path),
661            Ok(None)
662        );
663        std::fs::write(&path, r#"{"epoch":null,"epoch":null}"#).unwrap();
664        assert!(super::super::read_recorded_epoch(&path).is_err());
665    }
666
667    #[test]
668    fn writer_keeps_events_markers_and_all_metadata_in_one_stored_stream() {
669        let directory = tempfile::tempdir().unwrap();
670        let path = directory.path().join("recording.json");
671        let mut writer = PreemptionWriter::new(Some(path.clone()))
672            .with_epoch("2001-02-03T04:05:06Z".parse().unwrap())
673            .with_pipe_wake_latency(Some(0))
674            .with_signal_state_copy(false)
675            .with_slice_boundaries()
676            .unwrap();
677        let tid = DetTid::from_raw(3);
678        writer.register_thread(tid, crate::scheduler::runqueue::DEFAULT_PRIORITY);
679        let metadata = writer.inner.clone();
680        let Recording::Boundaries(sample) = sample(true) else {
681            unreachable!()
682        };
683        let Record::TimesliceEnd(boundary) = sample.schedule.records()[0].clone() else {
684            unreachable!()
685        };
686        let first = SchedEvent::branches(tid, 17);
687        let last = SchedEvent::syscall(tid, Sysno::write, crate::types::SyscallPhase::Posthook);
688        writer.insert_schedevent(first.clone());
689        assert_eq!(writer.insert_slice_boundary(boundary.clone()).unwrap(), 1);
690        writer.insert_schedevent(last.clone());
691        writer.flush().unwrap();
692        let bytes = std::fs::read(&path).unwrap();
693        assert_eq!(bytes.last(), Some(&b'\n'));
694        assert!(serde_json::from_slice::<PreemptionRecord>(&bytes).is_err());
695        let Recording::Boundaries(actual) = Recording::decode(&bytes).unwrap() else {
696            panic!("writer flattened the boundary protocol")
697        };
698        assert_eq!(actual.preemptions, metadata);
699        assert_eq!(
700            actual.schedule.records(),
701            &[
702                Record::Event(first),
703                Record::TimesliceEnd(boundary),
704                Record::Event(last),
705            ]
706        );
707    }
708
709    #[test]
710    fn writer_refuses_invalid_order_before_overwriting_the_destination() {
711        let directory = tempfile::tempdir().unwrap();
712        let path = directory.path().join("retained.json");
713        std::fs::write(&path, b"retained original").unwrap();
714        let Recording::Boundaries(sample) = sample(true) else {
715            unreachable!()
716        };
717        let Record::TimesliceEnd(boundary) = sample.schedule.records()[0].clone() else {
718            unreachable!()
719        };
720        let mut writer = PreemptionWriter::new(Some(path.clone()))
721            .with_slice_boundaries()
722            .unwrap();
723        writer.insert_slice_boundary(boundary.clone()).unwrap();
724        writer.insert_slice_boundary(boundary.clone()).unwrap();
725        assert!(writer.flush().is_err());
726        assert_eq!(std::fs::read(&path).unwrap(), b"retained original");
727
728        let mut legacy = PreemptionWriter::new(Some(path.clone()));
729        assert!(legacy.insert_slice_boundary(boundary).is_err());
730        legacy.insert_schedevent(SchedEvent::branches(DetTid::from_raw(3), 1));
731        assert!(legacy.with_slice_boundaries().is_err());
732        assert_eq!(std::fs::read(&path).unwrap(), b"retained original");
733    }
734
735    #[test]
736    fn writer_default_still_emits_exact_legacy_bytes() {
737        let mut writer = PreemptionWriter::new(None);
738        let event = SchedEvent::branches(DetTid::from_raw(3), 7);
739        writer.insert_schedevent(event.clone());
740        assert_eq!(
741            writer.into_string(),
742            PreemptionRecord::from_sched_events(vec![event]).to_string()
743        );
744    }
745
746    #[test]
747    fn zero_count_or_nonpositive_event_owner_is_rejected_on_new_format_read() {
748        for (tid, count) in [(3, 0), (0, 1), (-1, 1)] {
749            let event = SchedEvent::branches(DetTid::from_raw(tid), count);
750            assert!(Schedule::new(vec![Record::Event(event.clone())]).is_err());
751            let mut wire: serde_json::Value =
752                serde_json::from_slice(&sample(false).encode().unwrap()).unwrap();
753            let schedule = &mut wire["slice_boundary_recording"]["schedule"];
754            schedule["record_count"] = 1.into();
755            schedule["records"] = serde_json::json!([Record::Event(event)]);
756            assert!(Recording::decode(&serde_json::to_vec(&wire).unwrap()).is_err());
757        }
758    }
759}
760
761#[cfg(test)]
762mod explicit_mode_tests {
763    use super::*;
764
765    #[test]
766    fn explicit_mode_admission_never_falls_back_or_changes_legacy_policy() {
767        let legacy = Recording::Legacy(PreemptionRecord::default());
768        let typed = Recording::Boundaries(
769            BoundaryRecording::new(PreemptionRecord::default(), Schedule::new(vec![]).unwrap())
770                .unwrap(),
771        );
772        assert!(legacy.require_strict_mode(false).is_ok());
773        assert!(typed.require_strict_mode(true).is_ok());
774        assert!(legacy.require_strict_mode(true).is_err());
775        assert!(typed.require_strict_mode(false).is_err());
776    }
777    #[test]
778    fn ordinary_history_admits_legacy_and_retains_read_error_panics() {
779        let directory = tempfile::tempdir().unwrap();
780        let path = directory.path().join("history.json");
781        std::fs::write(
782            &path,
783            Recording::Legacy(PreemptionRecord::default())
784                .encode()
785                .unwrap(),
786        )
787        .unwrap();
788        assert!(
789            HistoryReader::read(&path)
790                .unwrap()
791                .require_legacy_mode()
792                .is_ok()
793        );
794        for contents in [None, Some("not a JSON recording")] {
795            match contents {
796                None => std::fs::remove_file(&path).unwrap(),
797                Some(value) => std::fs::write(&path, value).unwrap(),
798            }
799            let cfg = crate::Config {
800                replay_preemptions_from: Some(path.clone()),
801                ..crate::Config::default()
802            };
803            assert!(std::panic::catch_unwind(|| crate::GlobalState::for_tests(&cfg)).is_err());
804        }
805    }
806}