runnerd_core/observe/
capture.rs1use std::fmt::Write as _;
12
13use crate::store::LogPosition;
14
15pub const SCRIPT: &str = r#"
17diag=/home/runner/_diag keep=/home/runner/_diag/.runnerd-pages start_page=$1 start_offset=$2 job= record=
18declare -A off
19page_of() { local page=${1##*_}; echo "${page%.log}"; }
20while :; do
21 if [ -z "$job" ]; then
22 for w in "$diag"/Worker_*.log; do
23 [ -f "$w" ] || continue
24 id=$(grep -m1 -A1 '"k": "check_run_id"' "$w" | sed -n 's/.*"v": \([0-9]*\).*/\1/p')
25 run=$(grep -m1 -A1 '"k": "run_id"' "$w" | sed -n 's/.*"v": "\([0-9]*\)".*/\1/p')
26 record=$(grep -m1 '"jobId": "' "$w" | sed -n 's/.*"jobId": "\([0-9a-f-]*\)".*/\1/p')
27 # Only now: creating _diag before the runner does would leave it unwritable to the runner.
28 if [ -n "$id" ] && [ -n "$run" ] && [ -n "$record" ]; then
29 job=$id
30 mkdir -p -m 700 "$keep"
31 echo "@@job $id $run"
32 fi
33 done
34 fi
35 if [ -n "$job" ]; then
36 for f in "$diag"/pages/*_"$record"_*.log; do
37 [ -f "$f" ] && [ "$(page_of "$f")" -ge "$start_page" ] && [ ! -e "$keep/${f##*/}" ] \
38 && ln "$f" "$keep/${f##*/}" 2> /dev/null
39 done
40 for k in "$keep"/*_"$record"_*.log; do
41 [ -f "$k" ] || continue
42 name=${k##*/} page=$(page_of "$k") gone=
43 if [ "$page" -lt "$start_page" ]; then rm -f "$k"; continue; fi
44 [ -e "$diag/pages/$name" ] || gone=1
45 size=$(stat -c %s "$k")
46 o=${off[$name]:-0}
47 [ -n "${off[$name]}" ] || [ "$page" -ne "$start_page" ] || o=$start_offset
48 if [ "$size" -gt "$o" ]; then
49 echo "@@page $page $o $((size - o))"
50 tail -c +$((o + 1)) "$k" | head -c $((size - o))
51 fi
52 off[$name]=$size
53 if [ -n "$gone" ]; then echo "@@end $page"; rm -f "$k"; fi
54 done
55 fi
56 sleep 0.2
57done
58"#;
59
60pub fn command(position: LogPosition) -> Vec<String> {
62 vec![
63 "bash".to_owned(),
64 "-c".to_owned(),
65 SCRIPT.to_owned(),
66 "capture".to_owned(),
67 position.page.to_string(),
68 position.offset.to_string(),
69 ]
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Captured {
75 Job {
77 id: u64,
79 run_id: u64,
81 },
82 Lines {
84 lines: Vec<String>,
86 position: LogPosition,
88 },
89}
90
91#[derive(Debug, Default)]
93pub struct Parser {
94 input: Vec<u8>,
95 remaining: usize,
97 page: u64,
99 partial_offset: u64,
100 partial: Vec<u8>,
102 partial_len: u64,
104}
105
106pub const MAX_LINE: usize = 1 << 20;
108
109const BATCH: usize = 1 << 20;
111
112const BOM: &[u8] = "\u{feff}".as_bytes();
113
114fn text(line: &[u8]) -> String {
115 let line = line.strip_suffix(b"\r").unwrap_or(line);
116 String::from_utf8_lossy(line.strip_prefix(BOM).unwrap_or(line)).into_owned()
117}
118
119impl Parser {
120 pub fn feed(&mut self, chunk: &[u8]) -> Vec<Captured> {
122 self.input.extend_from_slice(chunk);
123 let mut captured = Vec::new();
124 loop {
125 if self.remaining > 0 {
126 if self.input.is_empty() {
127 break;
128 }
129 let take = self.remaining.min(self.input.len());
130 let data: Vec<u8> = self.input.drain(..take).collect();
131 self.remaining -= take;
132 self.data(&data, &mut captured);
133 continue;
134 }
135 let Some(end) = self.input.iter().position(|byte| *byte == b'\n') else {
136 break;
137 };
138 let header: Vec<u8> = self.input.drain(..=end).collect();
139 self.header(&String::from_utf8_lossy(&header[..end]), &mut captured);
140 }
141 captured
142 }
143
144 fn header(&mut self, header: &str, captured: &mut Vec<Captured>) {
145 let mut words = header.split(' ');
146 let numbers: Vec<u64> = words
147 .clone()
148 .skip(1)
149 .filter_map(|word| word.parse().ok())
150 .collect();
151 match (words.next(), numbers.as_slice()) {
152 (Some("@@job"), [id, run_id]) => captured.push(Captured::Job {
153 id: *id,
154 run_id: *run_id,
155 }),
156 (Some("@@page"), [page, offset, length]) => {
157 if *page != self.page || *offset != self.partial_offset + self.partial_len {
158 self.page = *page;
160 self.take_line();
161 self.partial_offset = *offset;
162 }
163 self.remaining = usize::try_from(*length).unwrap_or(usize::MAX);
164 }
165 (Some("@@end"), [page]) if *page == self.page && self.partial_len > 0 => {
166 let line = self.take_line();
167 self.partial_offset = 0;
168 captured.push(Captured::Lines {
169 lines: vec![line],
170 position: LogPosition {
171 page: page + 1,
172 offset: 0,
173 },
174 });
175 }
176 (Some("@@end"), [page]) if *page == self.page => {
177 self.partial_offset = 0;
178 captured.push(Captured::Lines {
179 lines: Vec::new(),
180 position: LogPosition {
181 page: page + 1,
182 offset: 0,
183 },
184 });
185 }
186 _ => {}
188 }
189 }
190
191 fn take_line(&mut self) -> String {
193 let mut line = text(&self.partial);
194 let dropped = self.partial_len - self.partial.len() as u64;
195 if dropped > 0 {
196 let _ = write!(
197 line,
198 " [runnerd: {dropped} more bytes of this line not kept]"
199 );
200 }
201 self.partial.clear();
202 self.partial_len = 0;
203 line
204 }
205
206 fn data(&mut self, data: &[u8], captured: &mut Vec<Captured>) {
207 let mut lines = Vec::new();
208 let mut bytes = 0;
209 for (index, segment) in data.split(|byte| *byte == b'\n').enumerate() {
210 if index > 0 {
212 let length = self.partial_len + 1;
213 let line = self.take_line();
214 self.partial_offset += length;
215 bytes += line.len() + 1;
216 lines.push(line);
217 if bytes >= BATCH {
218 bytes = 0;
219 captured.push(Captured::Lines {
220 lines: std::mem::take(&mut lines),
221 position: self.position(),
222 });
223 }
224 }
225 let room = MAX_LINE - self.partial.len();
226 self.partial
227 .extend_from_slice(&segment[..room.min(segment.len())]);
228 self.partial_len += segment.len() as u64;
229 }
230 if !lines.is_empty() {
231 captured.push(Captured::Lines {
232 lines,
233 position: self.position(),
234 });
235 }
236 }
237
238 fn position(&self) -> LogPosition {
240 LogPosition {
241 page: self.page,
242 offset: self.partial_offset,
243 }
244 }
245
246 #[cfg(test)]
248 pub(crate) fn pending(&self) -> usize {
249 self.partial.capacity()
250 }
251}