1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
//! Bounded readers for backend child-process streams (and the local
//! backend's response body) — the fix for the hostile-workload finding that
//! every CLI backend accumulated unbounded stdout/stderr in memory (a
//! backend emitting a 10GB single line, or endless output with no newline,
//! could hang the engine or exhaust host memory).
//!
//! The house pattern is `command_exec::read_stream_tail`: keep the TAIL,
//! never the head, and keep draining so a full pipe never deadlocks the
//! child. Truncation is marked, and the marker is a SUFFIX so it survives
//! the last-N-chars tailing (`STDERR_TAIL_CHARS` / `BODY_TAIL_CHARS`) that
//! failure messages apply when surfacing the text.
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader};
/// Suffix appended to a retained tail when earlier bytes were dropped.
pub(crate) const TRUNCATION_MARKER: &str = "[...truncated; tail kept...]";
/// Max bytes retained from one stdout protocol line. Real stream-json event
/// lines are KiB-scale even with embedded tool output, so an 8 MiB cap only
/// trips on a pathological CLI — where the truncated tail fails JSON parsing
/// and flows through as an `AgentEvent::Other`, leaving session-outcome
/// semantics unchanged.
pub(crate) const STDOUT_LINE_CAP: usize = 8 * 1024 * 1024;
/// Max bytes retained from a backend's whole stderr stream. Failure messages
/// surface only the last `STDERR_TAIL_CHARS` (500) of it; 64 KiB leaves
/// generous headroom while bounding a hostile stream.
pub(crate) const STDERR_TAIL_CAP: usize = 64 * 1024;
/// Bounded tail-keeping window over a byte stream: memory stays ≤ 2×`cap`
/// no matter how much is pushed, and once anything is dropped the window is
/// marked truncated so the rendered string carries [`TRUNCATION_MARKER`].
/// `cap` must be non-zero (all call sites use the constants above).
///
/// Ring-by-offset, not front-drain: dropping the head advances `start` and
/// the dead prefix is compacted only once it exceeds `cap` — amortized
/// O(1) per pushed byte. The naive `drain(..excess)` per chunk shifted the
/// whole window on every push (~1 TiB of memcpy for a 1 GiB hostile line —
/// 4th-pass review).
pub(crate) struct TailWindow {
// Lazy growth, NOT `Vec::with_capacity(cap)`: a `BoundedLines` window is
// allocated per line, and pre-allocating STDOUT_LINE_CAP per line would
// waste 8 MiB on every KiB-scale line.
buf: Vec<u8>,
/// The retained tail begins here; the logical content is `buf[start..]`.
start: usize,
cap: usize,
truncated: bool,
}
impl TailWindow {
pub(crate) fn new(cap: usize) -> Self {
debug_assert!(cap > 0, "a zero-cap tail window retains nothing");
TailWindow {
buf: Vec::new(),
start: 0,
cap,
truncated: false,
}
}
pub(crate) fn push(&mut self, bytes: &[u8]) {
if bytes.len() >= self.cap {
self.buf.clear();
self.buf.extend_from_slice(&bytes[bytes.len() - self.cap..]);
self.start = 0;
self.truncated = true;
return;
}
self.buf.extend_from_slice(bytes);
let excess = (self.buf.len() - self.start).saturating_sub(self.cap);
if excess > 0 {
self.start += excess;
self.truncated = true;
// Amortized compaction: drop the dead prefix only once it alone
// exceeds the cap, so steady-state cost is O(1) per byte.
if self.start > self.cap {
self.buf.drain(..self.start);
self.start = 0;
}
}
}
/// The retained tail bytes (`buf[start..]`).
fn tail(&self) -> &[u8] {
&self.buf[self.start..]
}
fn is_empty(&self) -> bool {
self.tail().is_empty() && !self.truncated
}
/// The retained tail as text (`from_utf8_lossy` keeps a leading partial
/// UTF-8 sequence safe), with the marker appended once anything was
/// dropped. Marker on its own trailing line, so `.trim_end()` +
/// last-N-chars surfacing keeps it visible.
pub(crate) fn render(&self) -> String {
let tail = String::from_utf8_lossy(self.tail());
if self.truncated {
format!("{tail}\n{TRUNCATION_MARKER}")
} else {
tail.into_owned()
}
}
/// [`render`] for one line: the terminator (`\n`, then a trailing `\r`)
/// is stripped first — the same shape `tokio::io::Lines::next_line`
/// returns — and the marker stays on the line itself.
fn render_line(&self) -> String {
let mut bytes: &[u8] = self.tail();
if bytes.last() == Some(&b'\n') {
bytes = &bytes[..bytes.len() - 1];
}
if bytes.last() == Some(&b'\r') {
bytes = &bytes[..bytes.len() - 1];
}
let text = String::from_utf8_lossy(bytes);
if self.truncated {
format!("{text} {TRUNCATION_MARKER}")
} else {
text.into_owned()
}
}
}
/// Drain `reader` to EOF, retaining only the bounded tail. Backend stderr
/// capture runs here: the pipe must be drained to the end no matter how much
/// the child writes (a full pipe would deadlock the child), while retained
/// memory stays bounded. The tail (with marker) is returned once, at EOF —
/// the stderr-capture tasks publish it then, and every reader of that buffer
/// runs after the capture task is joined; incremental publication would
/// multiply memcpy on a hostile multi-GB stream.
pub(crate) async fn drain_to_tail<R>(mut reader: R, cap: usize) -> String
where
R: AsyncRead + Unpin,
{
let mut window = TailWindow::new(cap);
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
// A read error ends capture exactly like the old
// `while let Ok(Some(line))` loop did: keep what we have.
Ok(0) | Err(_) => break,
Ok(n) => window.push(&chunk[..n]),
}
}
window.render()
}
/// Newline-delimited reader over a child stdout that bounds per-line memory:
/// a line longer than the cap is still drained to its newline (the child
/// never blocks on a full pipe) but only its tail is retained, suffixed with
/// [`TRUNCATION_MARKER`]. Drop-in replacement for `BufReader::lines()` in the
/// CLI backend sessions.
pub(crate) struct BoundedLines<R> {
reader: BufReader<R>,
cap: usize,
}
impl<R: AsyncRead + Unpin> BoundedLines<R> {
pub(crate) fn new(inner: R) -> Self {
Self::with_cap(inner, STDOUT_LINE_CAP)
}
/// Explicit cap, separated so tests can exercise truncation without
/// streaming 8 MiB (mirrors `run_shell_command_with_timeout`).
pub(crate) fn with_cap(inner: R, cap: usize) -> Self {
BoundedLines {
reader: BufReader::new(inner),
cap,
}
}
/// The next line without its terminator (`\n`, and a trailing `\r` —
/// the same shape as `tokio::io::Lines::next_line`); `None` at EOF. A
/// final unterminated line is still returned. Unlike `Lines`, invalid
/// UTF-8 is lossy-converted rather than an error (strictly more
/// tolerant; the unparsed-line path handles it downstream).
pub(crate) async fn next_line(&mut self) -> std::io::Result<Option<String>> {
let mut window = TailWindow::new(self.cap);
loop {
let available = self.reader.fill_buf().await?;
if available.is_empty() {
// EOF: retained bytes are one last unterminated line; a
// pristine window is a clean end of stream.
return Ok(if window.is_empty() {
None
} else {
Some(window.render_line())
});
}
let (take, found_newline) = match available.iter().position(|b| *b == b'\n') {
Some(pos) => (pos + 1, true),
None => (available.len(), false),
};
window.push(&available[..take]);
self.reader.consume(take);
if found_newline {
return Ok(Some(window.render_line()));
}
}
}
}
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_window_keeps_everything_under_the_cap() {
let mut window = TailWindow::new(16);
window.push(b"hello ");
window.push(b"world");
assert_eq!(window.render(), "hello world");
}
/// 4th-pass review: many small pushes over a saturated window must not
/// grow memory unboundedly (≤ 2×cap before amortized compaction) and
/// must keep the exact tail — the shape that made front-drain
/// quadratic.
#[test]
fn tail_window_stays_bounded_and_exact_over_many_small_pushes() {
let mut window = TailWindow::new(64);
// 10k 8-byte pushes = 80 KiB through a 64-byte window.
for i in 0..10_000u32 {
window.push(format!("{i:08}").as_bytes());
}
assert!(
window.buf.len() <= 128,
"compaction must bound the buffer at 2x cap, got {}",
window.buf.len()
);
assert!(window.start <= window.buf.len());
assert_eq!(window.tail().len(), 64);
assert!(
window.tail().ends_with(b"00009999"),
"the exact last bytes are retained: {:?}",
window.tail()
);
assert!(window.truncated);
}
#[test]
fn tail_window_keeps_the_tail_with_a_marker_once_over_the_cap() {
let mut window = TailWindow::new(8);
window.push(b"0123456789");
assert_eq!(window.render(), "23456789\n[...truncated; tail kept...]");
// Rollover across several small pushes marks truncation too.
let mut window = TailWindow::new(8);
window.push(b"aaaa");
window.push(b"bbbb");
window.push(b"cc");
assert_eq!(window.render(), "aabbbbcc\n[...truncated; tail kept...]");
}
#[tokio::test]
async fn bounded_lines_reads_small_lines_like_tokio_lines() {
let input: &[u8] = b"one\n\r\nthree\r\nfour-no-newline";
let mut lines = BoundedLines::with_cap(input, 64);
assert_eq!(lines.next_line().await.unwrap(), Some("one".to_string()));
assert_eq!(lines.next_line().await.unwrap(), Some("".to_string()));
assert_eq!(lines.next_line().await.unwrap(), Some("three".to_string()));
assert_eq!(
lines.next_line().await.unwrap(),
Some("four-no-newline".to_string())
);
assert_eq!(lines.next_line().await.unwrap(), None);
}
#[tokio::test]
async fn bounded_lines_truncates_an_over_long_line_and_keeps_reading() {
// 30 x's + '\n' against a 16-byte cap: the returned line is the
// 15-byte tail plus the marker, and the NEXT line parses cleanly.
let input = format!("{}\nshort\n", "x".repeat(30));
let mut lines = BoundedLines::with_cap(input.as_bytes(), 16);
let long = lines.next_line().await.unwrap().expect("first line");
assert_eq!(
long,
format!("{} [...truncated; tail kept...]", "x".repeat(15))
);
assert_eq!(
lines.next_line().await.unwrap(),
Some("short".to_string()),
"the line after a truncated one is unaffected"
);
assert_eq!(lines.next_line().await.unwrap(), None);
}
#[tokio::test]
async fn bounded_lines_bounds_an_endless_line_with_no_newline() {
// 100 KiB with no newline (spanning many fill_buf chunks, exercising
// window rollover): one bounded line at EOF, then None.
let input = vec![b'y'; 100 * 1024];
let mut lines = BoundedLines::with_cap(input.as_slice(), 32);
let line = lines.next_line().await.unwrap().expect("the one line");
assert_eq!(
line,
format!("{} [...truncated; tail kept...]", "y".repeat(32))
);
assert_eq!(lines.next_line().await.unwrap(), None);
}
#[tokio::test]
async fn drain_to_tail_keeps_small_streams_exact() {
let tail = drain_to_tail(&b"all of it\n"[..], 1024).await;
assert_eq!(tail, "all of it\n");
}
#[tokio::test]
async fn drain_to_tail_caps_large_streams_with_a_marker() {
let mut input = vec![b'z'; 100 * 1024];
input.extend_from_slice(b"THE-END\n");
let tail = drain_to_tail(input.as_slice(), 64).await;
assert!(
tail.ends_with("THE-END\n\n[...truncated; tail kept...]"),
"{tail}"
);
assert_eq!(tail.len(), 64 + "\n[...truncated; tail kept...]".len());
}
}