Skip to main content

artifactize_core/tools/human/
log.rs

1//! The Tool log: what Human tools print while a review is open, line by line as it arrives.
2//!
3//! A review opens a [`channel`]. Each tool run it starts is a [`Run`]: its output lines and
4//! its end reach the channel as [`Event`]s, a launched program's for as long as it runs. A
5//! run keeps draining its program's output whether or not anyone still receives; what nobody
6//! shows is discarded. Nothing is saved.
7
8use std::sync::{
9    Arc,
10    atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
11};
12
13use time::OffsetDateTime;
14use tokio::sync::mpsc;
15
16use crate::types::RequestId;
17
18/// Output bytes one run shows at most: a launched program may print for hours, and the log
19/// is held in memory for as long as the review is open.
20const RUN_BYTES: usize = 1024 * 1024;
21/// A line longer than this is shown in pieces, so output without newlines still appears.
22const LINE_BYTES: usize = 4096;
23/// Shown once, as an error line, when a run printed more than the log keeps.
24const DROPPED: &str = "[more output than the Tool log keeps; the rest is not shown]";
25
26/// Numbers every run of this process, so lines arriving later find their own entry.
27static NEXT_RUN: AtomicU64 = AtomicU64::new(1);
28
29/// One tool run, unique in this process.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct RunNo(u64);
32
33impl RunNo {
34    pub fn get(self) -> u64 {
35        self.0
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Stream {
41    Stdout,
42    Stderr,
43}
44
45/// How a run ended, as its last log line says.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum End {
48    /// The program exited: its exit code, or how it ended without one.
49    Exited {
50        code: Option<i32>,
51        text: String,
52    },
53    /// Handed to the desktop's own opener; its output is not the tool's.
54    Launched,
55    /// A builtin finished.
56    Done,
57    Cancelled,
58    TimedOut,
59    /// The run could not start or failed, and why.
60    Failed(String),
61}
62
63impl End {
64    pub(crate) fn exited(status: &std::process::ExitStatus) -> Self {
65        Self::Exited {
66            code: match crate::platform::process_end(status) {
67                crate::platform::ProcessEnd::Exited(code) => Some(code),
68                crate::platform::ProcessEnd::Signaled(_) => None,
69            },
70            text: crate::platform::exit_description(status),
71        }
72    }
73
74    /// The end line: `■ exit 0`, `■ launched`, `■ error: …`.
75    pub fn text(&self) -> String {
76        match self {
77            Self::Exited {
78                code: Some(code), ..
79            } => format!("■ exit {code}"),
80            Self::Exited { text, .. } => format!("■ {text}"),
81            Self::Launched => "■ launched".into(),
82            Self::Done => "■ done".into(),
83            Self::Cancelled => "■ cancelled".into(),
84            Self::TimedOut => "■ timed out".into(),
85            Self::Failed(error) => format!("■ error: {error}"),
86        }
87    }
88
89    /// The run did not end well.
90    pub fn error(&self) -> bool {
91        !matches!(
92            self,
93            Self::Exited { code: Some(0), .. } | Self::Launched | Self::Done
94        )
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Kind {
100    Start {
101        at: OffsetDateTime,
102        operation: String,
103        /// The Artifact that declares the tool.
104        artifact: String,
105    },
106    Line {
107        stream: Stream,
108        text: String,
109    },
110    End(End),
111}
112
113/// One thing a run logged, for the review of `request`.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Event {
116    pub request: RequestId,
117    pub run: RunNo,
118    pub kind: Kind,
119}
120
121/// Where runs send their events; cloned into each job a review starts.
122#[derive(Debug, Clone)]
123pub struct Sink(mpsc::UnboundedSender<Event>);
124
125/// The review's end of the log. Every run bounds its output, so the channel is unbounded.
126pub type Events = mpsc::UnboundedReceiver<Event>;
127
128pub fn channel() -> (Sink, Events) {
129    let (sender, receiver) = mpsc::unbounded_channel();
130    (Sink(sender), receiver)
131}
132
133impl Sink {
134    /// Start a run of `operation` of `artifact` for `request`: its start line is logged now.
135    pub fn start(&self, request: &RequestId, operation: &str, artifact: &str) -> Run {
136        let run = Run::new(Some((self.clone(), request.clone())));
137        run.shared.send(Kind::Start {
138            at: OffsetDateTime::now_utc(),
139            operation: operation.to_owned(),
140            artifact: artifact.to_owned(),
141        });
142        run
143    }
144}
145
146struct Shared {
147    /// Where the run's events go and the review they belong to; `None` when nobody watches.
148    watch: Option<(Sink, RequestId)>,
149    run: RunNo,
150    /// Output bytes this run may still show.
151    left: AtomicUsize,
152    dropped: AtomicBool,
153}
154
155impl Shared {
156    fn send(&self, kind: Kind) {
157        if let Some((sink, request)) = &self.watch {
158            // A closed review discards what its runs still print.
159            let _ = sink.0.send(Event {
160                request: request.clone(),
161                run: self.run,
162                kind,
163            });
164        }
165    }
166
167    fn line(&self, stream: Stream, bytes: &[u8]) {
168        if !self.watched() {
169            return;
170        }
171        let text =
172            String::from_utf8(crate::runtime::clean_output(bytes)).expect("clean output is UTF-8");
173        let size = text.len().max(1);
174        // A compare-and-swap loop rather than `fetch_update`, which newer toolchains
175        // deprecate under another name.
176        let mut left = self.left.load(Ordering::Relaxed);
177        let kept = loop {
178            let Some(next) = left.checked_sub(size) else {
179                break false;
180            };
181            match self
182                .left
183                .compare_exchange_weak(left, next, Ordering::Relaxed, Ordering::Relaxed)
184            {
185                Ok(_) => break true,
186                Err(current) => left = current,
187            }
188        };
189        if kept {
190            self.send(Kind::Line { stream, text });
191        } else if !self.dropped.swap(true, Ordering::Relaxed) {
192            self.send(Kind::Line {
193                stream: Stream::Stderr,
194                text: DROPPED.into(),
195            });
196        }
197    }
198}
199
200/// One tool run's log: lines while it runs, then exactly one end. A run dropped without an
201/// end was cancelled.
202pub struct Run {
203    shared: Arc<Shared>,
204    ended: bool,
205}
206
207impl std::fmt::Debug for Run {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("Run")
210            .field("run", &self.shared.run)
211            .finish()
212    }
213}
214
215impl Run {
216    fn new(watch: Option<(Sink, RequestId)>) -> Self {
217        Self {
218            shared: Arc::new(Shared {
219                watch,
220                run: RunNo(NEXT_RUN.fetch_add(1, Ordering::Relaxed)),
221                left: AtomicUsize::new(RUN_BYTES),
222                dropped: AtomicBool::new(false),
223            }),
224            ended: false,
225        }
226    }
227
228    /// A run nobody shows, such as `tool check`'s: launched programs get no pipes at all.
229    pub fn unwatched() -> Self {
230        Self::new(None)
231    }
232
233    pub(crate) fn watched(&self) -> bool {
234        self.shared.watched()
235    }
236
237    /// A splitter of one output stream into this run's lines.
238    pub(crate) fn lines(&self, stream: Stream) -> Lines {
239        Lines {
240            shared: self.shared.clone(),
241            stream,
242            pending: Vec::new(),
243        }
244    }
245
246    /// Log `text`, a finished output, line by line.
247    pub fn text(&self, stream: Stream, text: &str) {
248        let mut lines = self.lines(stream);
249        lines.push(text.as_bytes());
250        lines.finish();
251    }
252
253    pub fn end(mut self, end: End) {
254        self.ended = true;
255        self.shared.send(Kind::End(end));
256    }
257}
258
259impl Drop for Run {
260    fn drop(&mut self) {
261        if !self.ended {
262            self.shared.send(Kind::End(End::Cancelled));
263        }
264    }
265}
266
267/// Bytes of one stream cut into lines: at each newline, and in pieces of [`LINE_BYTES`].
268pub(crate) struct Lines {
269    shared: Arc<Shared>,
270    stream: Stream,
271    pending: Vec<u8>,
272}
273
274impl Lines {
275    pub(crate) fn push(&mut self, bytes: &[u8]) {
276        if !self.shared.watched() {
277            return;
278        }
279        self.pending.extend_from_slice(bytes);
280        while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') {
281            let line: Vec<u8> = self.pending.drain(..=newline).collect();
282            let line = &line[..newline];
283            self.shared
284                .line(self.stream, line.strip_suffix(b"\r").unwrap_or(line));
285        }
286        while self.pending.len() >= LINE_BYTES {
287            let end = piece(&self.pending);
288            let piece: Vec<u8> = self.pending.drain(..end).collect();
289            self.shared.line(self.stream, &piece);
290        }
291    }
292
293    /// The stream closed: its last line, if it had no newline.
294    pub(crate) fn finish(&mut self) {
295        if !self.pending.is_empty() {
296            let line = std::mem::take(&mut self.pending);
297            self.shared.line(self.stream, &line);
298        }
299    }
300}
301
302impl Shared {
303    fn watched(&self) -> bool {
304        self.watch.is_some()
305    }
306}
307
308/// Where a long line's first piece ends: [`LINE_BYTES`] in, moved back so that a character
309/// cut in two stays whole for the next piece.
310fn piece(bytes: &[u8]) -> usize {
311    let end = LINE_BYTES.min(bytes.len());
312    match std::str::from_utf8(&bytes[..end]) {
313        Err(error) if error.error_len().is_none() && error.valid_up_to() > 0 => error.valid_up_to(),
314        _ => end,
315    }
316}
317
318/// The Tool log of one review as a screen shows it: one entry per run, in the order they
319/// started, each with its output and, once known, its end.
320#[derive(Debug, Clone, Default, PartialEq, Eq)]
321pub struct ToolLog {
322    entries: Vec<Entry>,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct Entry {
327    pub run: RunNo,
328    pub at: OffsetDateTime,
329    pub operation: String,
330    pub artifact: String,
331    pub lines: Vec<(Stream, String)>,
332    pub end: Option<End>,
333}
334
335impl Entry {
336    /// The start line, `▶ HH:MM:SS · operation · Artifact`, at `offset` from UTC.
337    pub fn start_text(&self, offset: time::UtcOffset) -> String {
338        start_text(
339            self.at.to_offset(offset).time(),
340            &self.operation,
341            &self.artifact,
342        )
343    }
344}
345
346/// `▶ HH:MM:SS · operation · Artifact`.
347pub fn start_text(at: time::Time, operation: &str, artifact: &str) -> String {
348    format!(
349        "▶ {:02}:{:02}:{:02} · {operation} · {artifact}",
350        at.hour(),
351        at.minute(),
352        at.second()
353    )
354}
355
356impl ToolLog {
357    /// Take `event` in: a start opens an entry; lines and the end join their run's entry.
358    /// Lines of a run this log never saw start (it was cleared since) are dropped.
359    pub fn apply(&mut self, event: Event) {
360        match event.kind {
361            Kind::Start {
362                at,
363                operation,
364                artifact,
365            } => self.entries.push(Entry {
366                run: event.run,
367                at,
368                operation,
369                artifact,
370                lines: Vec::new(),
371                end: None,
372            }),
373            kind => {
374                let Some(entry) = self.entries.iter_mut().find(|entry| entry.run == event.run)
375                else {
376                    return;
377                };
378                match kind {
379                    Kind::Line { stream, text } => entry.lines.push((stream, text)),
380                    Kind::End(end) => entry.end = Some(end),
381                    Kind::Start { .. } => unreachable!("handled above"),
382                }
383            }
384        }
385    }
386
387    pub fn entries(&self) -> &[Entry] {
388        &self.entries
389    }
390
391    pub fn is_empty(&self) -> bool {
392        self.entries.is_empty()
393    }
394
395    pub fn clear(&mut self) {
396        self.entries.clear();
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn request() -> RequestId {
405        "req-1".parse().unwrap()
406    }
407
408    fn drain(events: &mut Events) -> Vec<Kind> {
409        std::iter::from_fn(|| events.try_recv().ok())
410            .map(|event| event.kind)
411            .collect()
412    }
413
414    fn line(stream: Stream, text: &str) -> Kind {
415        Kind::Line {
416            stream,
417            text: text.into(),
418        }
419    }
420
421    #[test]
422    fn output_arrives_as_lines_of_its_own_stream() {
423        let (sink, mut events) = channel();
424        let run = sink.start(&request(), "open", "notes");
425        let mut out = run.lines(Stream::Stdout);
426        let mut err = run.lines(Stream::Stderr);
427        out.push(b"one\r\ntw");
428        err.push(b"warn\n");
429        out.push(b"o\nthree");
430        out.finish();
431        err.finish();
432        run.end(End::Exited {
433            code: Some(0),
434            text: String::new(),
435        });
436        let kinds = drain(&mut events);
437        assert!(matches!(&kinds[0], Kind::Start { operation, artifact, .. }
438            if operation == "open" && artifact == "notes"));
439        assert_eq!(
440            kinds[1..],
441            [
442                line(Stream::Stdout, "one"),
443                line(Stream::Stderr, "warn"),
444                line(Stream::Stdout, "two"),
445                line(Stream::Stdout, "three"),
446                Kind::End(End::Exited {
447                    code: Some(0),
448                    text: String::new()
449                }),
450            ]
451        );
452    }
453
454    #[test]
455    fn a_line_without_newline_shows_in_pieces_that_keep_characters_whole() {
456        let (sink, mut events) = channel();
457        let run = sink.start(&request(), "dump", "app");
458        let mut out = run.lines(Stream::Stdout);
459        let mut text = "a".repeat(LINE_BYTES - 1);
460        text.push('é');
461        out.push(text.as_bytes());
462        let kinds = drain(&mut events);
463        assert_eq!(
464            kinds[1..],
465            [line(Stream::Stdout, &"a".repeat(LINE_BYTES - 1))]
466        );
467        out.finish();
468        assert_eq!(drain(&mut events), [line(Stream::Stdout, "é")]);
469    }
470
471    #[test]
472    fn a_run_shows_its_output_up_to_its_budget_then_says_the_rest_is_not_shown() {
473        let (sink, mut events) = channel();
474        let run = sink.start(&request(), "spew", "app");
475        let chunk = format!("{}\n", "x".repeat(LINE_BYTES - 1));
476        for _ in 0..(RUN_BYTES / (LINE_BYTES - 1) + 10) {
477            run.lines(Stream::Stdout).push(chunk.as_bytes());
478        }
479        let kinds = drain(&mut events);
480        let shown: usize = kinds
481            .iter()
482            .filter_map(|kind| match kind {
483                Kind::Line {
484                    stream: Stream::Stdout,
485                    text,
486                } => Some(text.len()),
487                _ => None,
488            })
489            .sum();
490        assert!(shown <= RUN_BYTES);
491        assert_eq!(kinds.last(), Some(&line(Stream::Stderr, DROPPED)));
492        assert_eq!(
493            kinds
494                .iter()
495                .filter(|kind| **kind == line(Stream::Stderr, DROPPED))
496                .count(),
497            1
498        );
499    }
500
501    #[test]
502    fn a_run_dropped_without_an_end_was_cancelled_and_a_closed_review_discards_lines() {
503        let (sink, mut events) = channel();
504        drop(sink.start(&request(), "slow", "app"));
505        assert_eq!(drain(&mut events).last(), Some(&Kind::End(End::Cancelled)));
506        let run = sink.start(&request(), "late", "app");
507        drop(events);
508        run.text(Stream::Stdout, "nobody reads this\n");
509        run.end(End::Done);
510    }
511
512    #[test]
513    fn the_log_groups_lines_under_their_run_and_ignores_runs_it_never_saw_start() {
514        let (sink, mut events) = channel();
515        let first = sink.start(&request(), "launch", "app");
516        let second = sink.start(&request(), "check", "lib");
517        second.text(Stream::Stdout, "checked\n");
518        second.end(End::Done);
519        first.text(Stream::Stderr, "app says\n");
520        let mut log = ToolLog::default();
521        for event in std::iter::from_fn(|| events.try_recv().ok()) {
522            log.apply(event);
523        }
524        let [launch, check] = log.entries() else {
525            panic!("two entries: {log:?}");
526        };
527        assert_eq!(launch.lines, [(Stream::Stderr, "app says".to_owned())]);
528        assert_eq!(launch.end, None);
529        assert_eq!(check.lines, [(Stream::Stdout, "checked".to_owned())]);
530        assert_eq!(check.end, Some(End::Done));
531        log.clear();
532        first.end(End::Done);
533        log.apply(events.try_recv().unwrap());
534        assert!(log.is_empty());
535    }
536
537    #[test]
538    fn end_lines_say_how_a_run_ended() {
539        let exit = |code| End::Exited {
540            code: Some(code),
541            text: String::new(),
542        };
543        assert_eq!(exit(0).text(), "■ exit 0");
544        assert!(!exit(0).error());
545        assert_eq!(exit(2).text(), "■ exit 2");
546        assert!(exit(2).error());
547        assert_eq!(End::Launched.text(), "■ launched");
548        assert_eq!(End::TimedOut.text(), "■ timed out");
549        assert_eq!(End::Failed("no".into()).text(), "■ error: no");
550        assert!(End::Cancelled.error());
551        assert_eq!(
552            start_text(time::Time::from_hms(9, 5, 7).unwrap(), "open", "notes"),
553            "▶ 09:05:07 · open · notes"
554        );
555    }
556}