Skip to main content

strop_trace/
replay.rs

1//! The forensic tape: one ordered record stream that makes a full-content
2//! trace replayable. The tape is owned by the UI thread and never cloned
3//! into workers; the diagnostic logger stays the only disk writer.
4//!
5//! Record kinds:
6//! - `Seed` — the pure startup state a replay is reconstructed from.
7//! - `Action` — one external input, service delivery, frame or shutdown
8//!   step, stamped with a logical clock, emitted immediately before the
9//!   reducer/render work runs.
10//! - `Request` — admission of an asynchronous native launch. The owner
11//!   (ticket, stamps, loading state) is installed BEFORE the tape sees
12//!   the request; the tape only decides whether the native launch runs.
13//! - `Call` — a synchronous outside observation, including failures.
14//! - `Check` — a state observation both modes must reproduce exactly.
15//! - `End` — the deliberate end of the recording.
16//!
17//! Replay consumes `Request`/`Call`/`Check` synchronously inside the same
18//! production code that produced them, so divergence is detected at the
19//! exact producer, never patched over. Any failure is sticky: the tape
20//! refuses all further work until `healthy()` clears it (it never does).
21use std::cell::{Cell, RefCell};
22use std::collections::VecDeque;
23use std::io;
24
25use serde::{de::DeserializeOwned, Deserialize, Serialize};
26use serde_json::Value;
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum Node {
31    Seed {
32        value: Value,
33    },
34    Action {
35        tick: Tick,
36        value: Value,
37    },
38    Request {
39        operation: String,
40        arguments: Value,
41    },
42    Call {
43        operation: String,
44        arguments: Value,
45        result: Value,
46    },
47    Check {
48        value: Value,
49    },
50    End,
51}
52
53/// The logical clock actions carry. Monotonic milliseconds order events;
54/// unix seconds feed age computations so replay needs no wall clock.
55#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
56pub struct Tick {
57    pub monotonic_ms: u64,
58    pub unix_seconds: i64,
59}
60
61enum Mode {
62    Live,
63    Replay(VecDeque<Node>),
64}
65
66pub struct Tape {
67    mode: RefCell<Mode>,
68    fault: Cell<Option<&'static str>>,
69    tick: Cell<Tick>,
70    finished: Cell<bool>,
71    started: std::time::Instant,
72    #[cfg(feature = "test-support")]
73    fixture: Option<Fixture>,
74}
75
76#[cfg(feature = "test-support")]
77type FixtureResponder = dyn Fn(&str, &Value) -> io::Result<Value>;
78
79#[cfg(feature = "test-support")]
80struct Fixture {
81    nodes: RefCell<Vec<Node>>,
82    respond: Box<FixtureResponder>,
83}
84
85impl Default for Tape {
86    fn default() -> Self {
87        Self::live()
88    }
89}
90
91impl Tape {
92    /// A live tape: native launches run, and every record is emitted to the
93    /// diagnostic sink while full-content capture is on.
94    pub fn live() -> Self {
95        Self {
96            mode: RefCell::new(Mode::Live),
97            fault: Cell::new(None),
98            tick: Cell::new(Tick::default()),
99            finished: Cell::new(false),
100            started: std::time::Instant::now(),
101            #[cfg(feature = "test-support")]
102            fixture: None,
103        }
104    }
105
106    /// Alias of [`Tape::live`] matching the plain-constructor call sites
107    /// Main wires into `Editor::new_in`.
108    pub fn new() -> Self {
109        Self::live()
110    }
111
112    /// A replay tape over recorded nodes. It never consults the host.
113    pub fn replay(nodes: Vec<Node>) -> Self {
114        Self {
115            mode: RefCell::new(Mode::Replay(nodes.into())),
116            ..Self::live()
117        }
118    }
119
120    /// A hermetic fixture recorder (tests only): `request` suppresses native
121    /// launches and `call` is answered by `respond`, so recorded tapes are
122    /// built without any process, filesystem or network access.
123    #[cfg(feature = "test-support")]
124    pub fn fixture(respond: impl Fn(&str, &Value) -> io::Result<Value> + 'static) -> Self {
125        Self {
126            fixture: Some(Fixture {
127                nodes: RefCell::new(Vec::new()),
128                respond: Box::new(respond),
129            }),
130            ..Self::live()
131        }
132    }
133
134    /// The nodes a fixture recorded, for `Tape::replay` round-trips.
135    #[cfg(feature = "test-support")]
136    pub fn fixture_nodes(&self) -> Vec<Node> {
137        self.fixture
138            .as_ref()
139            .expect("fixture recorder")
140            .nodes
141            .borrow()
142            .clone()
143    }
144
145    #[cfg(feature = "test-support")]
146    fn has_fixture(&self) -> bool {
147        self.fixture.is_some()
148    }
149
150    #[cfg(not(feature = "test-support"))]
151    fn has_fixture(&self) -> bool {
152        false
153    }
154
155    pub fn is_replay(&self) -> bool {
156        matches!(&*self.mode.borrow(), Mode::Replay(_))
157    }
158
159    /// Expensive state construction is lazy when neither recording nor replaying.
160    pub fn observes(&self) -> bool {
161        self.is_replay() || self.captures()
162    }
163
164    /// Only the live adapter samples the host clock. Fixtures and replay use
165    /// their explicit tick; reducers consume now() and never consult wall time.
166    pub fn sample_tick(&self) -> Tick {
167        if self.is_replay() || self.has_fixture() {
168            return self.now();
169        }
170        Tick {
171            monotonic_ms: u64::try_from(self.started.elapsed().as_millis())
172                .unwrap_or(u64::MAX)
173                .max(self.now().monotonic_ms),
174            unix_seconds: std::time::SystemTime::now()
175                .duration_since(std::time::UNIX_EPOCH)
176                .map_or(0, |duration| {
177                    i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
178                }),
179        }
180    }
181
182    pub fn now(&self) -> Tick {
183        self.tick.get()
184    }
185
186    pub fn set_tick(&self, tick: Tick) -> io::Result<()> {
187        if tick.monotonic_ms < self.tick.get().monotonic_ms {
188            return self.fail("clock moved backwards");
189        }
190        self.tick.set(tick);
191        Ok(())
192    }
193
194    fn fail<T>(&self, message: &'static str) -> io::Result<T> {
195        if self.fault.get().is_none() {
196            self.fault.set(Some(message));
197        }
198        Err(io::Error::other(message))
199    }
200
201    /// The first failure stays put until the tape is dropped.
202    pub fn healthy(&self) -> io::Result<()> {
203        match self.fault.get() {
204            Some(message) => Err(io::Error::other(message)),
205            None => Ok(()),
206        }
207    }
208
209    fn pop(&self) -> io::Result<Node> {
210        self.healthy()?;
211        let node = match &mut *self.mode.borrow_mut() {
212            Mode::Replay(nodes) => nodes.pop_front(),
213            Mode::Live => None,
214        };
215        node.ok_or_else(|| {
216            if self.fault.get().is_none() {
217                self.fault.set(Some("unexpected end of replay"));
218            }
219            io::Error::other("unexpected end of replay")
220        })
221    }
222
223    fn captures(&self) -> bool {
224        if self.has_fixture() {
225            return true;
226        }
227        crate::capture_content()
228    }
229
230    fn emit(&self, node: &Node) {
231        #[cfg(feature = "test-support")]
232        if let Some(fixture) = &self.fixture {
233            fixture.nodes.borrow_mut().push(node.clone());
234            return;
235        }
236        if crate::capture_content() {
237            crate::record(crate::EventKind::Replay, node);
238        }
239    }
240
241    /// Serialize through the same bounded encoder admission uses, so an
242    /// oversize forensic value can never become a sliced JSON line.
243    fn value<T: Serialize>(&self, value: &T) -> io::Result<Value> {
244        let mut bytes = crate::bounded::Bytes::new(crate::MAX_RECORD_BYTES);
245        if serde_json::to_writer(&mut bytes, value).is_err() {
246            if self.is_replay() || self.has_fixture() {
247                return self.fail("replay value exceeds capture bound");
248            }
249            // Live capture degrades honestly: the forensic stream is marked
250            // incomplete and the session continues as a plain diagnostic log.
251            crate::mark_incomplete("forensic value exceeds cap or cannot serialize");
252            return Ok(Value::Null);
253        }
254        match serde_json::from_slice(&bytes.into_vec()) {
255            Ok(value) => Ok(value),
256            Err(_) => self.fail("encoded replay value cannot decode"),
257        }
258    }
259
260    fn decode<T: DeserializeOwned>(&self, value: Value) -> io::Result<T> {
261        serde_json::from_value(value).map_err(|_| {
262            if self.fault.get().is_none() {
263                self.fault.set(Some("replay payload does not decode"));
264            }
265            io::Error::other("replay payload does not decode")
266        })
267    }
268
269    /// Record the pure startup state. Live only, exactly once, before any
270    /// `Action`.
271    pub fn seed<S: Serialize>(&self, seed: &S) -> io::Result<()> {
272        if self.is_replay() {
273            return self.fail("live seed entered replay");
274        }
275        if self.finished.get() {
276            return self.fail("recording finished");
277        }
278        if self.captures() {
279            self.emit(&Node::Seed {
280                value: self.value(seed)?,
281            });
282        }
283        Ok(())
284    }
285
286    /// Replay-side seed consumption; must be the first record.
287    pub fn take_seed<S: DeserializeOwned>(&self) -> io::Result<S> {
288        match self.pop()? {
289            Node::Seed { value } => self.decode(value),
290            _ => self.fail("replay must start with seed"),
291        }
292    }
293
294    /// Live drivers call this immediately BEFORE reducer or render work.
295    pub fn action<A: Serialize>(&self, tick: Tick, action: &A) -> io::Result<()> {
296        if self.is_replay() {
297            return self.fail("live action entered replay");
298        }
299        if self.finished.get() {
300            return self.fail("recording finished");
301        }
302        self.set_tick(tick)?;
303        if self.captures() {
304            self.emit(&Node::Action {
305                tick,
306                value: self.value(action)?,
307            });
308        }
309        Ok(())
310    }
311
312    /// Replay driver: the next external action, or `None` at a clean `End`.
313    /// Any unconsumed observation left in place is a hard failure.
314    pub fn next<A: DeserializeOwned>(&self) -> io::Result<Option<A>> {
315        match self.pop()? {
316            Node::Action { tick, value } => {
317                self.set_tick(tick)?;
318                self.decode(value).map(Some)
319            }
320            Node::End => {
321                let empty = matches!(&*self.mode.borrow(), Mode::Replay(nodes) if nodes.is_empty());
322                if !empty {
323                    return self.fail("records after replay end");
324                }
325                Ok(None)
326            }
327            _ => self.fail("unconsumed replay observation"),
328        }
329    }
330
331    /// Asynchronous native launch admission. The identical request owner
332    /// must ALREADY be installed; `true` grants the launch (live modes),
333    /// `false` means a recorded request matched and the native side is
334    /// suppressed. Replay never falls back to the host on mismatch.
335    pub fn request<A: Serialize>(&self, operation: &str, arguments: &A) -> io::Result<bool> {
336        self.healthy()?;
337        if self.finished.get() && !self.is_replay() {
338            return self.fail("recording finished");
339        }
340        if !self.is_replay() && !self.captures() {
341            return Ok(true);
342        }
343        let arguments = self.value(arguments)?;
344        #[cfg(feature = "test-support")]
345        if self.fixture.is_some() {
346            self.emit(&Node::Request {
347                operation: operation.into(),
348                arguments,
349            });
350            return Ok(false);
351        }
352        if self.is_replay() {
353            match self.pop()? {
354                Node::Request {
355                    operation: expected,
356                    arguments: wanted,
357                } if expected == operation && wanted == arguments => Ok(false),
358                _ => self.fail("request identity or arguments diverged"),
359            }
360        } else {
361            self.emit(&Node::Request {
362                operation: operation.into(),
363                arguments,
364            });
365            Ok(true)
366        }
367    }
368
369    /// Synchronous outside observation. The closure must contain the
370    /// ENTIRE native access including preflight (config/path probes);
371    /// replay returns the recorded result, including `Err` shapes, and
372    /// never invokes it.
373    pub fn call<A: Serialize, R: Serialize + DeserializeOwned>(
374        &self,
375        operation: &str,
376        arguments: &A,
377        native: impl FnOnce() -> R,
378    ) -> io::Result<R> {
379        self.healthy()?;
380        if self.finished.get() && !self.is_replay() {
381            return self.fail("recording finished");
382        }
383        if !self.is_replay() && !self.captures() {
384            return Ok(native());
385        }
386        let arguments = self.value(arguments)?;
387        #[cfg(feature = "test-support")]
388        if let Some(fixture) = &self.fixture {
389            let result = match (fixture.respond)(operation, &arguments) {
390                Ok(result) => result,
391                Err(error) => {
392                    if self.fault.get().is_none() {
393                        self.fault.set(Some("fixture observation missing"));
394                    }
395                    return Err(error);
396                }
397            };
398            self.emit(&Node::Call {
399                operation: operation.into(),
400                arguments,
401                result: result.clone(),
402            });
403            return self.decode(result);
404        }
405        if self.is_replay() {
406            match self.pop()? {
407                Node::Call {
408                    operation: expected,
409                    arguments: wanted,
410                    result,
411                } if expected == operation && wanted == arguments => self.decode(result),
412                _ => self.fail("synchronous service call diverged"),
413            }
414        } else {
415            let result = native();
416            self.emit(&Node::Call {
417                operation: operation.into(),
418                arguments,
419                result: self.value(&result)?,
420            });
421            Ok(result)
422        }
423    }
424
425    /// Both modes must produce this observation bit-for-bit.
426    pub fn check<C: Serialize>(&self, state: &C) -> io::Result<()> {
427        self.healthy()?;
428        if self.finished.get() && !self.is_replay() {
429            return self.fail("recording finished");
430        }
431        if !self.is_replay() && !self.captures() {
432            return Ok(());
433        }
434        let value = self.value(state)?;
435        if self.is_replay() {
436            match self.pop()? {
437                Node::Check { value: expected } if value == expected => Ok(()),
438                _ => self.fail("editor state diverged"),
439            }
440        } else {
441            self.emit(&Node::Check { value });
442            Ok(())
443        }
444    }
445
446    /// Deliberate recording end. Live only; a replayed tape ends itself.
447    pub fn finish(&self) -> io::Result<()> {
448        self.healthy()?;
449        if self.is_replay() {
450            return self.fail("live finish entered replay");
451        }
452        if self.finished.get() {
453            return self.fail("recording finished");
454        }
455        self.finished.set(true);
456        self.emit(&Node::End);
457        Ok(())
458    }
459}