1#[cfg(any(feature = "host-shell", feature = "brush"))]
4use std::io::Read;
5#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
6use std::panic::{catch_unwind, AssertUnwindSafe};
7#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
8use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
9#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
10use std::sync::{mpsc, Arc, Mutex};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ShellOutputStream {
15 Stdout,
17 Stderr,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct ShellInvocationId(u64);
28
29impl ShellInvocationId {
30 #[must_use]
32 pub fn get(self) -> u64 {
33 self.0
34 }
35}
36
37pub trait ShellOutputObserver: Send + Sync + 'static {
62 fn on_output(&self, invocation: ShellInvocationId, stream: ShellOutputStream, chunk: &[u8]);
64
65 fn on_finish(&self, _invocation: ShellInvocationId) {}
72}
73
74impl<F> ShellOutputObserver for F
75where
76 F: Fn(ShellInvocationId, ShellOutputStream, &[u8]) + Send + Sync + 'static,
77{
78 fn on_output(&self, invocation: ShellInvocationId, stream: ShellOutputStream, chunk: &[u8]) {
79 self(invocation, stream, chunk);
80 }
81}
82
83#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
84static NEXT_INVOCATION_ID: AtomicU64 = AtomicU64::new(1);
85
86#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
87const ACTIVE: u8 = 0;
88#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
89const FINISHED: u8 = 1;
90#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
91const CANCELLED: u8 = 2;
92
93#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
94struct Remaining {
95 stdout_remaining: usize,
96 stderr_remaining: usize,
97}
98
99#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
100struct Session {
101 phase: AtomicU8,
102 remaining: Mutex<Remaining>,
103 sender: mpsc::Sender<Dispatch>,
104}
105
106#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
107enum Dispatch {
108 Output(ShellOutputStream, Vec<u8>),
109 Finish,
110 Cancel,
111}
112
113#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
116pub(crate) struct OutputGuard {
117 session: Option<Arc<Session>>,
118}
119
120#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
121impl OutputGuard {
122 pub(crate) fn finish(mut self) {
125 if let Some(session) = self.session.take() {
126 if session
127 .phase
128 .compare_exchange(ACTIVE, FINISHED, Ordering::AcqRel, Ordering::Acquire)
129 .is_ok()
130 {
131 let _ = session.sender.send(Dispatch::Finish);
132 }
133 }
134 }
135}
136
137#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
138impl Drop for OutputGuard {
139 fn drop(&mut self) {
140 if let Some(session) = self.session.take() {
141 session.phase.store(CANCELLED, Ordering::Release);
142 let _ = session.sender.send(Dispatch::Cancel);
143 }
144 }
145}
146
147#[derive(Clone, Default)]
149#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
150pub(crate) struct OutputEmitter {
151 session: Option<Arc<Session>>,
152}
153
154#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
155impl OutputEmitter {
156 pub(crate) fn emit(&self, stream: ShellOutputStream, chunk: &[u8]) {
157 let Some(session) = &self.session else {
158 return;
159 };
160 if chunk.is_empty() || session.phase.load(Ordering::Acquire) != ACTIVE {
161 return;
162 }
163
164 let mut remaining = session
167 .remaining
168 .lock()
169 .unwrap_or_else(std::sync::PoisonError::into_inner);
170 if session.phase.load(Ordering::Acquire) != ACTIVE {
171 return;
172 }
173 let stream_remaining = match stream {
174 ShellOutputStream::Stdout => &mut remaining.stdout_remaining,
175 ShellOutputStream::Stderr => &mut remaining.stderr_remaining,
176 };
177 let len = chunk.len().min(*stream_remaining);
178 if len == 0 {
179 return;
180 }
181 *stream_remaining -= len;
182 let retained = chunk[..len].to_vec();
183 drop(remaining);
184
185 if session.phase.load(Ordering::Acquire) == ACTIVE {
188 let _ = session.sender.send(Dispatch::Output(stream, retained));
189 }
190 }
191}
192
193#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
194pub(crate) fn output_session(
195 observer: Option<Arc<dyn ShellOutputObserver>>,
196 max_output: usize,
197) -> (OutputGuard, OutputEmitter) {
198 let session = observer.and_then(|observer| {
199 let invocation = ShellInvocationId(NEXT_INVOCATION_ID.fetch_add(1, Ordering::Relaxed));
200 let (sender, receiver) = mpsc::channel();
201 let session = Arc::new(Session {
202 phase: AtomicU8::new(ACTIVE),
203 remaining: Mutex::new(Remaining {
204 stdout_remaining: max_output,
205 stderr_remaining: max_output,
206 }),
207 sender,
208 });
209 let dispatcher_session = Arc::clone(&session);
210 std::thread::Builder::new()
211 .name(format!("agent-bridle-output-{}", invocation.get()))
212 .spawn(move || {
213 while let Ok(dispatch) = receiver.recv() {
214 match dispatch {
215 Dispatch::Output(stream, chunk) => {
216 if dispatcher_session.phase.load(Ordering::Acquire) == CANCELLED {
217 break;
218 }
219 if catch_unwind(AssertUnwindSafe(|| {
220 observer.on_output(invocation, stream, &chunk);
221 }))
222 .is_err()
223 {
224 dispatcher_session.phase.store(CANCELLED, Ordering::Release);
225 break;
226 }
227 }
228 Dispatch::Finish => {
229 let _ = catch_unwind(AssertUnwindSafe(|| {
230 observer.on_finish(invocation);
231 }));
232 break;
233 }
234 Dispatch::Cancel => break,
235 }
236 }
237 })
238 .ok()
239 .map(|_| session)
240 });
241 (
242 OutputGuard {
243 session: session.clone(),
244 },
245 OutputEmitter { session },
246 )
247}
248
249#[cfg(any(feature = "host-shell", feature = "brush"))]
253pub(crate) fn drain_capped(
254 mut reader: impl Read,
255 max_output: usize,
256 output: &OutputEmitter,
257 stream: ShellOutputStream,
258) -> std::io::Result<(Vec<u8>, bool)> {
259 let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
260 let mut chunk = [0u8; 8 * 1024];
261 let mut truncated = false;
262 loop {
263 let n = match reader.read(&mut chunk) {
264 Ok(n) => n,
265 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
266 Err(error) => return Err(error),
267 };
268 if n == 0 {
269 return Ok((buf, truncated));
270 }
271 let retained = (max_output - buf.len()).min(n);
272 if retained > 0 {
273 output.emit(stream, &chunk[..retained]);
274 buf.extend_from_slice(&chunk[..retained]);
275 }
276 truncated |= retained < n;
277 }
278}
279
280#[cfg(all(
281 test,
282 any(feature = "shell", feature = "host-shell", feature = "brush")
283))]
284mod tests {
285 use super::*;
286 #[cfg(any(feature = "host-shell", feature = "brush"))]
287 use std::io::Cursor;
288 use std::sync::mpsc;
289 use std::time::Duration;
290
291 #[derive(Debug, PartialEq, Eq)]
292 enum Event {
293 Output(ShellInvocationId, ShellOutputStream, Vec<u8>),
294 Finish(ShellInvocationId),
295 }
296
297 struct ChannelObserver(mpsc::Sender<Event>);
298
299 impl ShellOutputObserver for ChannelObserver {
300 fn on_output(
301 &self,
302 invocation: ShellInvocationId,
303 stream: ShellOutputStream,
304 chunk: &[u8],
305 ) {
306 self.0
307 .send(Event::Output(invocation, stream, chunk.to_vec()))
308 .expect("test receives output");
309 }
310
311 fn on_finish(&self, invocation: ShellInvocationId) {
312 self.0
313 .send(Event::Finish(invocation))
314 .expect("test receives finish");
315 }
316 }
317
318 struct BlockingInvocationObserver {
319 events: mpsc::Sender<Event>,
320 blocked: mpsc::Sender<()>,
321 release: Mutex<mpsc::Receiver<()>>,
322 }
323
324 impl ShellOutputObserver for BlockingInvocationObserver {
325 fn on_output(
326 &self,
327 invocation: ShellInvocationId,
328 stream: ShellOutputStream,
329 chunk: &[u8],
330 ) {
331 self.events
332 .send(Event::Output(invocation, stream, chunk.to_vec()))
333 .expect("record concurrent output");
334 if chunk == b"blocked" {
335 self.blocked.send(()).expect("record blocked callback");
336 self.release
337 .lock()
338 .expect("release lock")
339 .recv()
340 .expect("test releases callback");
341 }
342 }
343
344 fn on_finish(&self, invocation: ShellInvocationId) {
345 self.events
346 .send(Event::Finish(invocation))
347 .expect("record concurrent finish");
348 }
349 }
350
351 #[test]
352 fn finish_follows_capped_streams_for_the_same_invocation() {
353 let (events_tx, events_rx) = mpsc::channel();
354 let observer = Arc::new(ChannelObserver(events_tx));
355 let (guard, emitter) = output_session(Some(observer), 3);
356
357 emitter.emit(ShellOutputStream::Stdout, b"abcd");
358 emitter.emit(ShellOutputStream::Stderr, b"wxyz");
359 guard.finish();
360 emitter.emit(ShellOutputStream::Stdout, b"late");
361
362 let first = events_rx
363 .recv_timeout(Duration::from_secs(2))
364 .expect("stdout event");
365 let invocation = match first {
366 Event::Output(id, ShellOutputStream::Stdout, bytes) => {
367 assert_eq!(bytes, b"abc");
368 id
369 }
370 other => panic!("unexpected first event: {other:?}"),
371 };
372 assert_eq!(
373 events_rx
374 .recv_timeout(Duration::from_secs(2))
375 .expect("stderr event"),
376 Event::Output(invocation, ShellOutputStream::Stderr, b"wxy".to_vec())
377 );
378 assert_eq!(
379 events_rx
380 .recv_timeout(Duration::from_secs(2))
381 .expect("finish event"),
382 Event::Finish(invocation),
383 "finish is delivered only after every queued output callback"
384 );
385 assert!(
386 events_rx.try_recv().is_err(),
387 "post-finish output is ignored"
388 );
389 }
390
391 #[test]
392 fn panic_disables_only_the_observer() {
393 let (calls_tx, calls_rx) = mpsc::channel();
394 let observer = Arc::new(move |_invocation, _stream, _chunk: &[u8]| {
395 calls_tx.send(()).expect("record observer call");
396 panic!("observer panic");
397 });
398 let (_guard, emitter) = output_session(Some(observer), 10);
399
400 emitter.emit(ShellOutputStream::Stdout, b"first");
401 emitter.emit(ShellOutputStream::Stdout, b"second");
402
403 calls_rx
404 .recv_timeout(Duration::from_secs(2))
405 .expect("first callback");
406 assert!(
407 calls_rx.recv_timeout(Duration::from_millis(50)).is_err(),
408 "the queued second callback is discarded after a panic"
409 );
410 }
411
412 #[test]
413 fn blocked_invocation_does_not_stall_another_invocation() {
414 let (events_tx, events_rx) = mpsc::channel();
415 let (blocked_tx, blocked_rx) = mpsc::channel();
416 let (release_tx, release_rx) = mpsc::channel();
417 let observer: Arc<dyn ShellOutputObserver> = Arc::new(BlockingInvocationObserver {
418 events: events_tx,
419 blocked: blocked_tx,
420 release: Mutex::new(release_rx),
421 });
422 let (guard_a, emitter_a) = output_session(Some(observer.clone()), 16);
423 let (guard_b, emitter_b) = output_session(Some(observer), 16);
424
425 emitter_a.emit(ShellOutputStream::Stdout, b"blocked");
426 blocked_rx
427 .recv_timeout(Duration::from_secs(2))
428 .expect("invocation A entered its blocking callback");
429 let invocation_a = match events_rx
430 .recv_timeout(Duration::from_secs(2))
431 .expect("invocation A output")
432 {
433 Event::Output(id, ShellOutputStream::Stdout, bytes) => {
434 assert_eq!(bytes, b"blocked");
435 id
436 }
437 other => panic!("unexpected invocation A event: {other:?}"),
438 };
439
440 emitter_b.emit(ShellOutputStream::Stdout, b"progress");
441 guard_b.finish();
442 let invocation_b = match events_rx
443 .recv_timeout(Duration::from_secs(2))
444 .expect("invocation B progresses while A is blocked")
445 {
446 Event::Output(id, ShellOutputStream::Stdout, bytes) => {
447 assert_eq!(bytes, b"progress");
448 id
449 }
450 other => panic!("unexpected invocation B event: {other:?}"),
451 };
452 assert_ne!(invocation_a, invocation_b);
453 assert_eq!(
454 events_rx
455 .recv_timeout(Duration::from_secs(2))
456 .expect("invocation B finish"),
457 Event::Finish(invocation_b)
458 );
459
460 guard_a.finish();
461 release_tx.send(()).expect("release invocation A");
462 assert_eq!(
463 events_rx
464 .recv_timeout(Duration::from_secs(2))
465 .expect("invocation A finish"),
466 Event::Finish(invocation_a)
467 );
468 }
469
470 #[test]
471 #[cfg(any(feature = "host-shell", feature = "brush"))]
472 fn capped_drain_observes_the_prefix_but_consumes_to_eof() {
473 let (seen_tx, seen_rx) = mpsc::channel();
474 let observer = Arc::new(move |_invocation, _stream, chunk: &[u8]| {
475 seen_tx.send(chunk.to_vec()).expect("record observed bytes");
476 });
477 let (guard, emitter) = output_session(Some(observer), 3);
478 let mut reader = Cursor::new(b"abcdef".to_vec());
479
480 let (captured, truncated) =
481 drain_capped(&mut reader, 3, &emitter, ShellOutputStream::Stdout).expect("drain");
482
483 assert_eq!(captured, b"abc");
484 assert!(truncated);
485 guard.finish();
486 assert_eq!(
487 seen_rx
488 .recv_timeout(Duration::from_secs(2))
489 .expect("observed prefix"),
490 b"abc"
491 );
492 assert_eq!(reader.position(), 6, "excess bytes are drained, not stored");
493 }
494
495 #[test]
496 #[cfg(any(feature = "host-shell", feature = "brush"))]
497 fn capped_drain_retries_an_interrupted_read() {
498 struct InterruptedOnce {
499 interrupted: bool,
500 inner: Cursor<Vec<u8>>,
501 }
502
503 impl Read for InterruptedOnce {
504 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
505 if !self.interrupted {
506 self.interrupted = true;
507 return Err(std::io::Error::from(std::io::ErrorKind::Interrupted));
508 }
509 self.inner.read(buf)
510 }
511 }
512
513 let reader = InterruptedOnce {
514 interrupted: false,
515 inner: Cursor::new(b"abcdef".to_vec()),
516 };
517 let (captured, truncated) = drain_capped(
518 reader,
519 4,
520 &OutputEmitter::default(),
521 ShellOutputStream::Stdout,
522 )
523 .expect("interrupted reads are retried");
524
525 assert_eq!(captured, b"abcd");
526 assert!(truncated);
527 }
528}