Skip to main content

rootle_trace/
lib.rs

1//! rootle's opt-in diagnostic session sink: one private, bounded JSONL file
2//! per session, written by a dedicated thread.
3//!
4//! Producers never perform file I/O, never wait for the writer, and never
5//! wait for queue capacity — a full queue, an oversize record, a cap or a
6//! writer failure is an explicit capture failure surfaced through
7//! [`take_failure`] and [`TraceSession::finish`], never a silent drop.
8//! Only the writer thread touches the file, flushing every batch it
9//! receives, and every file it closes ends with a terminal `trace_end`
10//! marker stating whether the capture is complete and why. A capped,
11//! failed, panicked or abandoned capture can never look complete, and a
12//! crash that prevents the marker leaves its absence as the verdict.
13//!
14//! Envelopes carry `schema_version`, the writer-owned `seq`, `elapsed_us`,
15//! a `thread` label, the thread's optional `operation_id` correlation, the
16//! `event` kind and producer `fields`; sequence and timestamps agree with
17//! admission order under concurrent producers.
18//!
19//! Metadata capture is diagnostic, not anonymous: paths, repository
20//! identities, revisions and timing can still be sensitive — inspect files
21//! before sharing. Nothing here ever prints to stdout/stderr; the only
22//! outputs are the file and explicit `Result`s.
23
24mod bounded;
25mod context;
26mod event;
27mod writer;
28
29pub use context::{OperationId, in_operation, operation_id};
30pub use event::{
31    ContentPolicy, EventKind, Limits, MAX_CAPTURE_BYTES, MAX_CAPTURE_EVENTS, MAX_RECORD_BYTES,
32    SCHEMA_VERSION, TERMINAL_RESERVE, TraceOptions,
33};
34
35use arc_swap::ArcSwapOption;
36use parking_lot::{Condvar, Mutex, MutexGuard};
37use serde::Serialize;
38use std::fs::OpenOptions;
39use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
41use std::sync::mpsc::{SyncSender, sync_channel};
42use std::sync::{Arc, LazyLock};
43use std::thread::JoinHandle;
44use std::time::{Duration, Instant};
45
46/// A full queue is a visible capture failure, never silent loss; the
47/// writer drains whole batches, so this only trips on writer stalls.
48pub(crate) const QUEUE_CAPACITY: usize = 64;
49/// Panic durability and shutdown both wait at most this long for the
50/// writer, so a stalled disk cannot hang unwinding or exit either.
51const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
52/// Panic text is capped well below the per-record limit so admitting the
53/// panic record can itself never trip the cap.
54const PANIC_MESSAGE_CHARS: usize = 2048;
55const PANIC_BACKTRACE_CHARS: usize = 8192;
56
57static ACTIVE: LazyLock<ArcSwapOption<Recorder>> = LazyLock::new(ArcSwapOption::empty);
58/// Only startup/close mutate the slot; producers and panic hooks take atomic
59/// snapshots, never this lock.
60static LIFECYCLE: Mutex<()> = Mutex::new(());
61/// Fast disabled path: even the atomic Arc slot stays untouched without a session.
62static SESSION_PRESENT: AtomicBool = AtomicBool::new(false);
63
64/// How the session ended. Only an explicit `finish` may ever let the
65/// terminal marker claim completeness.
66mod close {
67    pub(crate) const FINISH: u8 = 1;
68    pub(crate) const ABANDON: u8 = 2;
69}
70
71#[derive(Debug, thiserror::Error)]
72pub enum TraceError {
73    #[error("a trace session is already active")]
74    AlreadyActive,
75    #[error("cannot create trace {path}: {source}")]
76    Open {
77        path: PathBuf,
78        source: std::io::Error,
79    },
80    #[error("cannot start trace writer: {0}")]
81    Spawn(std::io::Error),
82    #[error("incomplete trace: {0}")]
83    Incomplete(String),
84}
85
86/// First failure wins; later failures cannot overwrite the cause.
87#[derive(Default)]
88pub(crate) struct Failure {
89    message: Mutex<Option<String>>,
90    reported: AtomicBool,
91    failed: AtomicBool,
92}
93
94impl Failure {
95    pub(crate) fn set(&self, message: impl FnOnce() -> String) {
96        let mut failure = self.message.lock();
97        if failure.is_none() {
98            *failure = Some(message());
99        }
100        self.failed.store(true, Ordering::Release);
101    }
102    pub(crate) fn present(&self) -> bool {
103        self.failed.load(Ordering::Acquire)
104    }
105}
106
107#[derive(Default)]
108pub(crate) struct SignalState {
109    pub(crate) panic_flushed: bool,
110    pub(crate) done: bool,
111}
112
113/// Bounded writer signaling: panic admission waits here for durability and
114/// shutdown waits here instead of an unjoinable stall.
115#[derive(Default)]
116pub(crate) struct Signal {
117    state: Mutex<SignalState>,
118    ready: Condvar,
119}
120
121impl Signal {
122    pub(crate) fn mark_panic_flushed(&self) {
123        self.state.lock().panic_flushed = true;
124        self.ready.notify_all();
125    }
126    pub(crate) fn mark_done(&self) {
127        self.state.lock().done = true;
128        self.ready.notify_all();
129    }
130    /// Wait until `satisfied` holds or `limit` elapses; false on timeout.
131    pub(crate) fn wait_for(
132        &self,
133        satisfied: impl Fn(&SignalState) -> bool,
134        limit: Duration,
135    ) -> bool {
136        let mut state = self.state.lock();
137        let deadline = Instant::now() + limit;
138        while !satisfied(&state) {
139            let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
140                return false;
141            };
142            if remaining.is_zero() {
143                return false;
144            }
145            if self.ready.wait_for(&mut state, remaining).timed_out() && !satisfied(&state) {
146                return false;
147            }
148        }
149        true
150    }
151}
152
153/// State shared with the writer thread.
154#[derive(Default)]
155pub(crate) struct Shared {
156    pub(crate) failure: Failure,
157    pub(crate) close_kind: AtomicU8,
158    pub(crate) signal: Signal,
159    pub(crate) panicked: AtomicBool,
160}
161
162impl Shared {
163    pub(crate) fn close_kind(&self) -> u8 {
164        self.close_kind.load(Ordering::Relaxed)
165    }
166    /// Whether ordinary records must stop being admitted. The panic record
167    /// itself is exempt: it is the session's last word.
168    fn capture_dead(&self) -> bool {
169        self.failure.present() || self.panicked.load(Ordering::Acquire) || self.close_kind() != 0
170    }
171}
172
173pub(crate) struct Record {
174    pub(crate) kind: EventKind,
175    pub(crate) elapsed_us: u128,
176    pub(crate) thread: Arc<str>,
177    pub(crate) operation: Option<OperationId>,
178    pub(crate) fields: Vec<u8>,
179}
180
181pub(crate) struct Recorder {
182    pub(crate) sender: Mutex<Option<SyncSender<Record>>>,
183    pub(crate) shared: Arc<Shared>,
184    started: Instant,
185    max_record: usize,
186    content: ContentPolicy,
187}
188
189/// The owning lifetime of a trace. [`TraceSession::finish`] is the
190/// explicit reporting boundary; `Drop` still finalizes the file durably,
191/// but as an abandoned capture, never a complete one.
192pub struct TraceSession {
193    recorder: Arc<Recorder>,
194    worker: Option<JoinHandle<()>>,
195}
196
197/// Start the process's single trace session, creating `path` exclusively.
198///
199/// The file is new, private (0600 on Unix) and never appended to;
200/// existing files and symlinks are refused with [`TraceError::Open`], as
201/// are missing parent directories. A second concurrent session is refused
202/// with [`TraceError::AlreadyActive`], and limits outside the crate's
203/// hard bounds with [`TraceError::Incomplete`].
204pub fn start(path: &Path, options: TraceOptions) -> Result<TraceSession, TraceError> {
205    if !options.limits.valid() {
206        return Err(TraceError::Incomplete("invalid capture limits".into()));
207    }
208    let _lifecycle = LIFECYCLE.lock();
209    if ACTIVE.load().is_some() {
210        return Err(TraceError::AlreadyActive);
211    }
212    let mut open = OpenOptions::new();
213    open.write(true).create_new(true);
214    #[cfg(unix)]
215    {
216        use std::os::unix::fs::OpenOptionsExt;
217        open.mode(0o600);
218    }
219    let file = open.open(path).map_err(|source| TraceError::Open {
220        path: path.to_path_buf(),
221        source,
222    })?;
223    let (sender, receiver) = sync_channel(QUEUE_CAPACITY);
224    let shared = Arc::new(Shared::default());
225    let writer_shared = Arc::clone(&shared);
226    let limits = options.limits;
227    let worker = std::thread::Builder::new()
228        .name("rootle-trace".into())
229        .spawn(move || writer::run(file, receiver, writer_shared, limits))
230        .map_err(TraceError::Spawn)?;
231    let recorder = Arc::new(Recorder {
232        sender: Mutex::new(Some(sender)),
233        shared,
234        started: Instant::now(),
235        max_record: limits.max_record_bytes,
236        content: options.content,
237    });
238    ACTIVE.store(Some(Arc::clone(&recorder)));
239    SESSION_PRESENT.store(true, Ordering::Release);
240    Ok(TraceSession {
241        recorder,
242        worker: Some(worker),
243    })
244}
245
246#[inline]
247pub fn enabled() -> bool {
248    SESSION_PRESENT.load(Ordering::Relaxed)
249        && ACTIVE
250            .load()
251            .as_ref()
252            .is_some_and(|recorder| !recorder.shared.capture_dead())
253}
254
255/// Whether [`ContentPolicy::Full`] was selected: producers gate sensitive
256/// input/UI text and provider stderr on this.
257#[inline]
258pub fn capture_content() -> bool {
259    SESSION_PRESENT.load(Ordering::Relaxed)
260        && ACTIVE.load().as_ref().is_some_and(|recorder| {
261            !recorder.shared.capture_dead() && recorder.content == ContentPolicy::Full
262        })
263}
264
265/// Lazy producer: no payload construction or allocation when disabled.
266pub fn record_with<T: Serialize>(kind: EventKind, fields: impl FnOnce() -> T) {
267    let Some(recorder) = recording_snapshot() else {
268        return;
269    };
270    // Pin before invoking user serialization/payload construction. A finish or
271    // later start cannot redirect an in-flight Full payload into another file.
272    let fields = fields();
273    record_to(&recorder, kind, &fields);
274}
275
276pub fn record<T: Serialize>(kind: EventKind, fields: &T) {
277    let Some(recorder) = recording_snapshot() else {
278        return;
279    };
280    record_to(&recorder, kind, fields);
281}
282
283fn recording_snapshot() -> Option<Arc<Recorder>> {
284    if !SESSION_PRESENT.load(Ordering::Relaxed) {
285        return None;
286    }
287    let recorder = ACTIVE.load_full()?;
288    (!recorder.shared.capture_dead()).then_some(recorder)
289}
290
291fn record_to<T: Serialize>(recorder: &Recorder, kind: EventKind, fields: &T) {
292    let mut sender = recorder.sender.lock();
293    admit(
294        recorder,
295        &mut sender,
296        kind,
297        |out| serde_json::to_writer(out, fields),
298        false,
299    );
300}
301
302/// Stamp, cap and admit one record under the caller's admission lock.
303/// Serializing here bounds simultaneous encodings and keeps `elapsed_us`
304/// nondecreasing in the writer's receive order; the lock never covers
305/// file I/O or queue waits — `try_send` is nonblocking by construction.
306fn admit(
307    recorder: &Recorder,
308    sender: &mut MutexGuard<'_, Option<SyncSender<Record>>>,
309    kind: EventKind,
310    encode: impl FnOnce(&mut bounded::Bytes) -> serde_json::Result<()>,
311    urgent: bool,
312) -> bool {
313    if sender.is_none() {
314        return false;
315    }
316    if !urgent && recorder.shared.capture_dead() {
317        sender.take();
318        return false;
319    }
320    let mut bytes = bounded::Bytes::new(recorder.max_record);
321    if encode(&mut bytes).is_err() {
322        recorder
323            .shared
324            .failure
325            .set(|| "record exceeds cap or cannot serialize".into());
326        sender.take();
327        return false;
328    }
329    let record = Record {
330        kind,
331        elapsed_us: recorder.started.elapsed().as_micros(),
332        thread: context::thread_label(),
333        operation: context::current_operation(),
334        fields: bytes.into_vec(),
335    };
336    if sender
337        .as_ref()
338        .expect("admission checked the sender")
339        .try_send(record)
340        .is_err()
341    {
342        recorder
343            .shared
344            .failure
345            .set(|| "capture queue full or writer unavailable".into());
346        sender.take();
347        return false;
348    }
349    true
350}
351
352/// Best-effort panic capture, safe to call from a panic hook.
353///
354/// Marks the capture incomplete before touching any lock, then admits one
355/// `panic` record without ever blocking: if a producer died holding the
356/// admission lock the record is skipped, not waited for. Raw message and
357/// backtrace text are included only under [`ContentPolicy::Full`] — even
358/// when the caller supplies them — and are capped so admitting this record
359/// can never trip the record cap. On successful admission the writer gets
360/// a bounded window to flush it to disk.
361pub fn panic_record(
362    location: Option<(&str, u32, u32)>,
363    message: Option<&str>,
364    backtrace: Option<&str>,
365) {
366    if !SESSION_PRESENT.load(Ordering::Relaxed) {
367        return;
368    }
369    let Some(recorder) = ACTIVE.load_full() else {
370        return;
371    };
372    // Both policy and panic state belong to this exact session. Atomic slot
373    // reads remain available when a producer/start/close holds another lock.
374    recorder.shared.panicked.store(true, Ordering::Release);
375    let full = recorder.content == ContentPolicy::Full;
376    let message = full
377        .then_some(message)
378        .flatten()
379        .map(|text| cap_text(text, PANIC_MESSAGE_CHARS));
380    let backtrace = full
381        .then_some(backtrace)
382        .flatten()
383        .map(|text| cap_text(text, PANIC_BACKTRACE_CHARS));
384    let fields = serde_json::json!({
385        "location": location.map(|(file, line, column)| serde_json::json!({
386            "file": file,
387            "line": line,
388            "column": column,
389        })),
390        "message": message,
391        "backtrace": backtrace,
392    });
393    let admitted = match recorder.sender.try_lock() {
394        Some(mut sender) => admit(
395            &recorder,
396            &mut sender,
397            EventKind::Panic,
398            |out| serde_json::to_writer(out, &fields),
399            true,
400        ),
401        // A producer died holding the admission lock; never block unwinding.
402        None => false,
403    };
404    if admitted {
405        recorder
406            .shared
407            .signal
408            .wait_for(|state| state.panic_flushed || state.done, SHUTDOWN_TIMEOUT);
409    }
410}
411
412/// Report a capture failure once (for a status line); [`TraceSession::finish`]
413/// still returns it as an error.
414pub fn take_failure() -> Option<String> {
415    if !SESSION_PRESENT.load(Ordering::Relaxed) {
416        return None;
417    }
418    let recorder = ACTIVE.load_full()?;
419    if recorder.shared.failure.reported.load(Ordering::Relaxed) {
420        return None;
421    }
422    let message = if recorder.shared.panicked.load(Ordering::Acquire) {
423        "capture interrupted by panic".to_owned()
424    } else {
425        recorder.shared.failure.message.lock().clone()?
426    };
427    (!recorder
428        .shared
429        .failure
430        .reported
431        .swap(true, Ordering::Relaxed))
432    .then_some(message)
433}
434
435/// A producer could not observe an explicitly requested surface. Preserve the
436/// failure verdict and stop admission rather than writing a complete-looking log.
437pub fn mark_incomplete(reason: &'static str) {
438    let Some(recorder) = recording_snapshot() else {
439        return;
440    };
441    record_to(
442        &recorder,
443        EventKind::Error,
444        &serde_json::json!({"scope":"capture","reason":reason}),
445    );
446    recorder.shared.failure.set(|| reason.to_owned());
447    recorder.sender.lock().take();
448}
449
450fn cap_text(text: &str, max_chars: usize) -> String {
451    let mut chars = text.chars();
452    let mut capped: String = chars.by_ref().take(max_chars).collect();
453    if chars.next().is_some() {
454        capped.push('…');
455    }
456    capped
457}
458
459impl TraceSession {
460    /// End the capture explicitly: drain the writer (bounded), then report
461    /// the first failure, cap or panic, if any. The only path that can
462    /// leave a file marked complete.
463    pub fn finish(mut self) -> Result<(), TraceError> {
464        self.close(true)
465    }
466
467    fn close(&mut self, explicit: bool) -> Result<(), TraceError> {
468        let Some(worker) = self.worker.take() else {
469            return Ok(());
470        };
471        let shared = &self.recorder.shared;
472        {
473            let _lifecycle = LIFECYCLE.lock();
474            shared.close_kind.store(
475                if explicit {
476                    close::FINISH
477                } else {
478                    close::ABANDON
479                },
480                Ordering::Release,
481            );
482            // Close admission before another session may become visible.
483            self.recorder.sender.lock().take();
484            if ACTIVE
485                .load()
486                .as_ref()
487                .is_some_and(|active| Arc::ptr_eq(active, &self.recorder))
488            {
489                SESSION_PRESENT.store(false, Ordering::Release);
490                ACTIVE.store(None);
491            }
492        }
493        // Bounded shutdown: a stalled writer (blocked disk, dead mount) can
494        // hang a join forever, so wait on its completion signal instead.
495        // The detached thread is harmless — without the marker the file
496        // already reads as incomplete.
497        if !shared.signal.wait_for(|state| state.done, SHUTDOWN_TIMEOUT) {
498            shared.failure.set(|| {
499                format!(
500                    "trace writer did not finish within {}s",
501                    SHUTDOWN_TIMEOUT.as_secs()
502                )
503            });
504        } else if worker.join().is_err() {
505            shared.failure.set(|| "trace writer panicked".into());
506        }
507        // The writer may have observed a panic after shutdown started. Report
508        // that same verdict rather than returning Ok for an incomplete file.
509        if shared.panicked.load(Ordering::Acquire) {
510            shared.failure.set(|| "capture interrupted by panic".into());
511        }
512        match shared.failure.message.lock().clone() {
513            Some(error) => Err(TraceError::Incomplete(error)),
514            None => Ok(()),
515        }
516    }
517}
518
519impl Drop for TraceSession {
520    fn drop(&mut self) {
521        // Explicit finish is the reporting boundary. Drop still finalizes
522        // durably during unwinding or a forgotten session — but as an
523        // abandoned capture, so it can never pass for a clean one.
524        let _ = self.close(false);
525    }
526}
527
528#[cfg(test)]
529mod tests;