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 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 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 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 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 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 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 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}