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 a bounded encoder sized to the assembled-value
242    /// bound: admission chunks whatever exceeds one record, and only a
243    /// value beyond a whole capture still degrades the session.
244    fn value<T: Serialize>(&self, value: &T) -> io::Result<Value> {
245        let mut bytes = crate::bounded::Bytes::new(crate::chunk::MAX_VALUE_BYTES);
246        if serde_json::to_writer(&mut bytes, value).is_err() {
247            if self.is_replay() || self.has_fixture() {
248                return self.fail("replay value exceeds capture bound");
249            }
250            // Live capture degrades honestly: the forensic stream is marked
251            // incomplete and the session continues as a plain diagnostic log.
252            crate::mark_incomplete("forensic value exceeds cap or cannot serialize");
253            return Ok(Value::Null);
254        }
255        match serde_json::from_slice(&bytes.into_vec()) {
256            Ok(value) => Ok(value),
257            Err(_) => self.fail("encoded replay value cannot decode"),
258        }
259    }
260
261    fn decode<T: DeserializeOwned>(&self, value: Value) -> io::Result<T> {
262        serde_json::from_value(value).map_err(|_| {
263            if self.fault.get().is_none() {
264                self.fault.set(Some("replay payload does not decode"));
265            }
266            io::Error::other("replay payload does not decode")
267        })
268    }
269
270    /// Record the pure startup state. Live only, exactly once, before any
271    /// `Action`.
272    pub fn seed<S: Serialize>(&self, seed: &S) -> io::Result<()> {
273        if self.is_replay() {
274            return self.fail("live seed entered replay");
275        }
276        if self.finished.get() {
277            return self.fail("recording finished");
278        }
279        if self.captures() {
280            self.emit(&Node::Seed {
281                value: self.value(seed)?,
282            });
283        }
284        Ok(())
285    }
286
287    /// Replay-side seed consumption; must be the first record.
288    pub fn take_seed<S: DeserializeOwned>(&self) -> io::Result<S> {
289        match self.pop()? {
290            Node::Seed { value } => self.decode(value),
291            _ => self.fail("replay must start with seed"),
292        }
293    }
294
295    /// Live drivers call this immediately BEFORE reducer or render work.
296    pub fn action<A: Serialize>(&self, tick: Tick, action: &A) -> io::Result<()> {
297        if self.is_replay() {
298            return self.fail("live action entered replay");
299        }
300        if self.finished.get() {
301            return self.fail("recording finished");
302        }
303        self.set_tick(tick)?;
304        if self.captures() {
305            self.emit(&Node::Action {
306                tick,
307                value: self.value(action)?,
308            });
309        }
310        Ok(())
311    }
312
313    /// Replay driver: the next external action, or `None` at a clean `End`.
314    /// Any unconsumed observation left in place is a hard failure.
315    pub fn next<A: DeserializeOwned>(&self) -> io::Result<Option<A>> {
316        match self.pop()? {
317            Node::Action { tick, value } => {
318                self.set_tick(tick)?;
319                self.decode(value).map(Some)
320            }
321            Node::End => {
322                let empty = matches!(&*self.mode.borrow(), Mode::Replay(nodes) if nodes.is_empty());
323                if !empty {
324                    return self.fail("records after replay end");
325                }
326                Ok(None)
327            }
328            _ => self.fail("unconsumed replay observation"),
329        }
330    }
331
332    /// Asynchronous native launch admission. The identical request owner
333    /// must ALREADY be installed; `true` grants the launch (live modes),
334    /// `false` means a recorded request matched and the native side is
335    /// suppressed. Replay never falls back to the host on mismatch.
336    pub fn request<A: Serialize>(&self, operation: &str, arguments: &A) -> io::Result<bool> {
337        self.healthy()?;
338        if self.finished.get() && !self.is_replay() {
339            return self.fail("recording finished");
340        }
341        if !self.is_replay() && !self.captures() {
342            return Ok(true);
343        }
344        let arguments = self.value(arguments)?;
345        #[cfg(feature = "test-support")]
346        if self.fixture.is_some() {
347            self.emit(&Node::Request {
348                operation: operation.into(),
349                arguments,
350            });
351            return Ok(false);
352        }
353        if self.is_replay() {
354            match self.pop()? {
355                Node::Request {
356                    operation: expected,
357                    arguments: wanted,
358                } if expected == operation && wanted == arguments => Ok(false),
359                _ => self.fail("request identity or arguments diverged"),
360            }
361        } else {
362            self.emit(&Node::Request {
363                operation: operation.into(),
364                arguments,
365            });
366            Ok(true)
367        }
368    }
369
370    /// Synchronous outside observation. The closure must contain the
371    /// ENTIRE native access including preflight (config/path probes);
372    /// replay returns the recorded result, including `Err` shapes, and
373    /// never invokes it.
374    pub fn call<A: Serialize, R: Serialize + DeserializeOwned>(
375        &self,
376        operation: &str,
377        arguments: &A,
378        native: impl FnOnce() -> R,
379    ) -> io::Result<R> {
380        self.healthy()?;
381        if self.finished.get() && !self.is_replay() {
382            return self.fail("recording finished");
383        }
384        if !self.is_replay() && !self.captures() {
385            return Ok(native());
386        }
387        let arguments = self.value(arguments)?;
388        #[cfg(feature = "test-support")]
389        if let Some(fixture) = &self.fixture {
390            let result = match (fixture.respond)(operation, &arguments) {
391                Ok(result) => result,
392                Err(error) => {
393                    if self.fault.get().is_none() {
394                        self.fault.set(Some("fixture observation missing"));
395                    }
396                    return Err(error);
397                }
398            };
399            self.emit(&Node::Call {
400                operation: operation.into(),
401                arguments,
402                result: result.clone(),
403            });
404            return self.decode(result);
405        }
406        if self.is_replay() {
407            match self.pop()? {
408                Node::Call {
409                    operation: expected,
410                    arguments: wanted,
411                    result,
412                } if expected == operation && wanted == arguments => self.decode(result),
413                _ => self.fail("synchronous service call diverged"),
414            }
415        } else {
416            let result = native();
417            self.emit(&Node::Call {
418                operation: operation.into(),
419                arguments,
420                result: self.value(&result)?,
421            });
422            Ok(result)
423        }
424    }
425
426    /// Both modes must produce this observation bit-for-bit.
427    pub fn check<C: Serialize>(&self, state: &C) -> io::Result<()> {
428        self.healthy()?;
429        if self.finished.get() && !self.is_replay() {
430            return self.fail("recording finished");
431        }
432        if !self.is_replay() && !self.captures() {
433            return Ok(());
434        }
435        let value = self.value(state)?;
436        if self.is_replay() {
437            match self.pop()? {
438                Node::Check { value: expected } if value == expected => Ok(()),
439                _ => self.fail("editor state diverged"),
440            }
441        } else {
442            self.emit(&Node::Check { value });
443            Ok(())
444        }
445    }
446
447    /// Deliberate recording end. Live only; a replayed tape ends itself.
448    pub fn finish(&self) -> io::Result<()> {
449        self.healthy()?;
450        if self.is_replay() {
451            return self.fail("live finish entered replay");
452        }
453        if self.finished.get() {
454            return self.fail("recording finished");
455        }
456        self.finished.set(true);
457        self.emit(&Node::End);
458        Ok(())
459    }
460}