Skip to main content

agent_bridle_tool_shell/
output_observer.rs

1//! Construction-time observer for bounded shell output.
2
3#[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/// The file descriptor from which an observed shell-output chunk originated.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ShellOutputStream {
15    /// Bytes captured from standard output.
16    Stdout,
17    /// Bytes captured from standard error.
18    Stderr,
19}
20
21/// Process-local identity for one shell tool invocation.
22///
23/// IDs are monotonically allocated and let a construction-time observer keep
24/// concurrent dispatches separate. They carry no authority and are not stable
25/// across process restarts.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct ShellInvocationId(u64);
28
29impl ShellInvocationId {
30    /// Return the process-local numeric identity.
31    #[must_use]
32    pub fn get(self) -> u64 {
33        self.0
34    }
35}
36
37/// Receives bounded shell output while a tool invocation is still running.
38///
39/// Chunks contain raw bytes and may split a UTF-8 code point. Calls run on a
40/// dedicated presentation thread, never a child pipe-draining thread, so a slow
41/// observer cannot stall the child or the invocation timeout. A single pipe
42/// remains in read order; ordering between stdout, stderr, and separate
43/// pipeline-stage stderr pipes follows live enqueue scheduling. Calls for one
44/// invocation are serialized on its presentation thread. Separate invocations
45/// use separate presentation threads and may call the same observer concurrently,
46/// so implementations must synchronize shared state.
47///
48/// The per-stream budget bounds live delivery, but it does not redefine the
49/// completed result. In particular, safe-shell pipeline stderr readers race for
50/// the shared live budget, while the final envelope concatenates stderr in
51/// pipeline-stage order and then applies its cap. Observed bytes can therefore
52/// differ from the final `stderr`; the envelope is authoritative.
53///
54/// Observation is presentation-only and grants no authority. A callback panic
55/// is contained and disables observation for that invocation; it never changes
56/// the shell result. Normal completion asynchronously drains chunks already
57/// queued within the output cap, then calls [`ShellOutputObserver::on_finish`].
58/// Cancellation or timeout stops accepting chunks immediately and does not call
59/// `on_finish`, but a callback already dequeued by the presentation thread may
60/// begin or finish after the invocation future returns.
61pub trait ShellOutputObserver: Send + Sync + 'static {
62    /// Observe a non-empty raw output `chunk` from `stream`.
63    fn on_output(&self, invocation: ShellInvocationId, stream: ShellOutputStream, chunk: &[u8]);
64
65    /// Report that an ordinarily completed invocation has delivered every
66    /// output callback queued before completion.
67    ///
68    /// This runs on the same presentation thread after all prior `on_output`
69    /// calls for `invocation`. Tool dispatch does not wait for it. It is not
70    /// called after cancellation, timeout, or an observer panic.
71    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/// Invocation owner. Dropping it cancels delivery without waiting for arbitrary
114/// observer code already running on the presentation thread.
115#[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    /// Preserve queued chunks on an ordinary completed invocation. Delivery is
123    /// asynchronous: this never waits for observer code.
124    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/// Cloneable handle carried into pipe-draining and confinement threads.
148#[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        // This lock protects only byte accounting. Arbitrary observer code runs
165        // on the dispatcher thread and is never called while this lock is held.
166        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        // `send` on an unbounded std channel is non-blocking. Total queued byte
186        // memory remains bounded by the per-stream accounting above.
187        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/// Drain a stream to EOF while retaining and observing at most `max_output`
250/// bytes. Bytes beyond the cap are discarded, preserving the child process's
251/// ordinary pipe behavior without allowing captured memory to grow unbounded.
252#[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}