Skip to main content

coop/
tail.rs

1use std::io::Write;
2use std::time::{Duration, Instant};
3
4use anyhow::{Result, bail};
5
6use crate::config::Host;
7use crate::errors::CoopError;
8use crate::probe::{From as ProbeFrom, State, next_interval, probe};
9use crate::transport::Transport;
10use crate::wrapper::{JobId, state_dir};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Selection {
14    LastBytes,
15    All,
16    Lines(u64),
17}
18
19pub fn once(
20    transport: &dyn Transport,
21    host: &Host,
22    id: &JobId,
23    selection: Selection,
24    out: &mut dyn Write,
25) -> Result<()> {
26    crate::errors::require_master(transport, host)?;
27    let dir = state_dir(id);
28    let read = match selection {
29        // Payload size is lock hold time. 64KB is deliberately conservative
30        // until measurements from real suite logs justify a different cap.
31        Selection::LastBytes => format!("tail -c 65536 {dir}/log"),
32        Selection::All => format!("cat {dir}/log"),
33        Selection::Lines(lines) => format!("tail -n {lines} {dir}/log"),
34    };
35    // Ask about truncation in the SAME round trip -- a second call would take
36    // the lock twice to answer a question that is one byte on disk.
37    let script = format!("{read}; printf '\\037%s' \"$(cat {dir}/truncated 2>/dev/null)\"");
38    let output = transport.run(host, &script)?;
39    if output.code != 0 {
40        bail!("tail failed: {}", output.stderr.trim());
41    }
42
43    // Split on the unit separator: log bytes are arbitrary, so the marker must
44    // be a byte the log cannot contain ambiguously at the very end.
45    let (body, truncated) = match output.stdout.iter().rposition(|&b| b == 0x1f) {
46        Some(i) => (&output.stdout[..i], output.stdout[i + 1..] == *b"1"),
47        None => (&output.stdout[..], false),
48    };
49    out.write_all(body)?;
50
51    if truncated {
52        // stderr, so it cannot corrupt `out=$(coop tail id)`.
53        // Name the merging here too. This is the ONE runtime message about the
54        // log, so it reaches a reader who never opened `--help`, and someone
55        // parsing a truncated log is exactly the reader most likely to be
56        // surprised by stderr interleaved into it.
57        eprintln!(
58            "coop: log was capped at {} bytes; the job ran to completion but \
59             later output was discarded\n  \
60             the log holds stdout and stderr merged, in the order the job \
61             wrote them; redirect inside your command to separate them",
62            host.max_log_bytes
63        );
64    }
65    Ok(())
66}
67
68pub fn follow(
69    transport: &dyn Transport,
70    host: &Host,
71    id: &JobId,
72    from: u64,
73    out: &mut dyn Write,
74) -> Result<i32> {
75    wait_loop(
76        transport,
77        host,
78        id,
79        ProbeFrom::Offset(from),
80        Some(out),
81        None,
82    )
83}
84
85pub fn follow_deferred(
86    transport: &dyn Transport,
87    host: &Host,
88    id: &JobId,
89    out: &mut dyn Write,
90) -> Result<i32> {
91    let code = wait_only(transport, host, id, None)?;
92    once(transport, host, id, Selection::All, out)?;
93    Ok(code)
94}
95
96pub fn wait_only(
97    transport: &dyn Transport,
98    host: &Host,
99    id: &JobId,
100    timeout: Option<u64>,
101) -> Result<i32> {
102    // Ask for state only: a plain wait wants rc, not output, so shipping the
103    // log to discard it would hold the lock for the transfer.
104    wait_loop(
105        transport,
106        host,
107        id,
108        ProbeFrom::StateOnly,
109        None,
110        timeout.map(Duration::from_secs),
111    )
112}
113
114fn wait_loop(
115    transport: &dyn Transport,
116    host: &Host,
117    id: &JobId,
118    mut from: ProbeFrom,
119    mut out: Option<&mut dyn Write>,
120    timeout: Option<Duration>,
121) -> Result<i32> {
122    let started = Instant::now();
123    let mut interval = Duration::from_secs(1);
124    loop {
125        let result = probe(transport, host, id, from)
126            .map_err(|error| error.context(CoopError::Dropped { id: id.to_string() }))?;
127        let new_bytes = !result.bytes.is_empty();
128        if let Some(writer) = out.as_deref_mut() {
129            writer.write_all(&result.bytes)?;
130        }
131        // Advance by bytes received, not the reported remote size: a truncated
132        // response must not create a permanent hole in streamed output. A
133        // state-only wait has no offset to advance.
134        if let ProbeFrom::Offset(offset) = from {
135            from = ProbeFrom::Offset(offset.saturating_add(result.bytes.len() as u64));
136        }
137
138        match result.state {
139            // One more read before returning. `rc` and `log` are written by
140            // different ends of a pipeline, so `rc` can land while the log's
141            // final bytes are still in flight -- measured: rc present with the
142            // log file not yet created. Returning on the first `Done` therefore
143            // dropped the output of any job short enough to finish inside one
144            // probe interval, which is most of them: `coop run --wait ls`
145            // printed the id and nothing else.
146            //
147            // A single extra round trip, only on the terminal path, and only
148            // when someone is actually reading the output.
149            State::Done(code) => {
150                if let Some(writer) = out.as_deref_mut()
151                    && let ProbeFrom::Offset(offset) = from
152                {
153                    let tail = probe(transport, host, id, ProbeFrom::Offset(offset))?;
154                    writer.write_all(&tail.bytes)?;
155                }
156                return Ok(code);
157            }
158            State::Orphan => {
159                return Err(CoopError::Orphan { id: id.to_string() }.into());
160            }
161            State::Running => {}
162        }
163        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
164            return Err(CoopError::Timeout { id: id.to_string() }.into());
165        }
166        let sleep = timeout
167            .map(|limit| interval.min(limit.saturating_sub(started.elapsed())))
168            .unwrap_or(interval);
169        std::thread::sleep(sleep);
170        interval = next_interval(interval, new_bytes);
171    }
172}