Skip to main content

byob_plugins/
backend.rs

1//! A plugin's backend: one long-lived process the engine talks to in JSON lines (the wire
2//! format is in `docs/plugins-backend.md`).
3//!
4//! [`Backend`] owns the process. It starts lazily on first use (an event or a request) on a
5//! thread of its own, so nothing here blocks the caller: messages queue until it runs. A
6//! writer thread feeds stdin, a reader thread reads stdout (lines over [`MAX_LINE`] are
7//! dropped with a fault), a third keeps the last [`STDERR_KEEP`] bytes of stderr. Replies
8//! go straight to the waiting [`Backend::request`]; everything else goes to the [`Sink`]
9//! (the engine's inbox). A backend that exits is started again after a backoff (0.5 s,
10//! doubling to 30 s), at once when someone uses it, and given up on after [`MAX_CRASHES`]
11//! crashes in a row. [`Backend::stop`] (reload, switched off, removed, dropped) closes stdin
12//! and kills it if it doesn't exit within half a second. A waiter thread notices the process
13//! exiting even when something it started still holds its stdout; on Unix it runs in a
14//! process group of its own, killed with it. Faults reach the sink at most once a second
15//! (the rest are counted). Every thread runs under `catch_unwind`.
16
17use std::collections::{BTreeMap, BTreeSet, VecDeque};
18use std::ffi::OsString;
19use std::io::{self, BufRead, BufReader, Read, Write};
20use std::panic::{AssertUnwindSafe, catch_unwind};
21use std::path::{Path, PathBuf};
22use std::process::{Child, ChildStdin, Command, Stdio};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel};
25use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
26use std::thread;
27use std::time::{Duration, Instant};
28
29use byob_tools::{Access, FileChange, Tool, ToolContext, ToolOutput, ToolSpec};
30use serde_json::{Value, json};
31
32use crate::PluginRef;
33use crate::manifest::{ManifestTool, path_inside};
34use crate::native::{NativeBackend, Outbox, dispatch, panic_text};
35
36/// The wire format's version (`init.v`).
37pub const WIRE_VERSION: u32 = 1;
38/// Longest line either way (one message).
39pub const MAX_LINE: usize = 1024 * 1024;
40/// Bytes of stderr kept.
41pub const STDERR_KEEP: usize = 16 * 1024;
42/// Faults kept (the newest).
43pub const MAX_FAULTS: usize = 8;
44/// Characters kept of one fault.
45const MAX_FAULT: usize = 2000;
46/// Messages kept while it starts, and waiting for a running one to read them; older
47/// ones are dropped while it starts, newer ones once it runs.
48const MAX_QUEUED: usize = 256;
49/// Bytes kept while it starts.
50const MAX_QUEUED_BYTES: usize = 16 * 1024 * 1024;
51/// Faults passed to the sink at most this often (the rest are counted in the next one).
52const FAULT_EVERY: Duration = Duration::from_secs(1);
53/// How often the waiter checks whether the process exited.
54const WAIT_POLL: Duration = Duration::from_millis(20);
55/// How long its output gets to be read after it exited.
56const DRAIN_GRACE: Duration = Duration::from_millis(500);
57/// How long a tool call waits for its reply.
58pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
59/// Crashes in a row after which it isn't started again (until its plugin reloads).
60pub const MAX_CRASHES: u32 = 5;
61const FIRST_BACKOFF: Duration = Duration::from_millis(500);
62const MAX_BACKOFF: Duration = Duration::from_secs(30);
63/// Running this long resets the crash count.
64const STABLE: Duration = Duration::from_secs(30);
65/// How long a stopped backend gets to exit before it is killed.
66const STOP_GRACE: Duration = Duration::from_millis(500);
67/// What the engine supports, sent in `init.caps`.
68pub const CAPS: [&str; 9] = ["event", "request", "reply", "view", "notify", "notice", "log", "fault", "web"];
69
70/// How to start the process (worked out on the start thread: it may build a venv).
71#[derive(Debug, Clone, Default)]
72pub struct Launch {
73    /// Program and arguments.
74    pub argv: Vec<OsString>,
75    pub cwd: PathBuf,
76    /// Added to the inherited environment.
77    pub env: BTreeMap<String, String>,
78    /// Removed from it.
79    pub remove_env: Vec<String>,
80}
81
82/// What a backend runs as: a process, or in-tree code on a thread ([`crate::native`]).
83pub enum Start {
84    Process(Launch),
85    Native(Box<dyn NativeBackend>),
86}
87
88impl From<Launch> for Start {
89    fn from(l: Launch) -> Self {
90        Self::Process(l)
91    }
92}
93
94/// Works out how to start it; its argument says the start was called off (the backend
95/// stopped), so a long preparation (a venv build) can give up.
96pub type Prepare = Box<dyn Fn(&dyn Fn() -> bool) -> Result<Start, String> + Send + Sync>;
97
98/// What a backend says that isn't a reply, and its lifecycle.
99#[derive(Debug, Clone, PartialEq)]
100pub enum Out {
101    /// A message (`view`, `notify`, `notice`, `log`, `fault`, or a newer type).
102    Msg(Value),
103    Started,
104    /// It exited or couldn't start: why.
105    Exited(String),
106}
107
108pub type Sink = Arc<dyn Fn(Out) + Send + Sync>;
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Phase {
112    /// Not started yet.
113    Idle,
114    Starting,
115    Running,
116    /// Exited; starts again after a backoff or on the next use.
117    Crashed,
118    /// Couldn't start, or crashed too often: stays down until its plugin reloads.
119    Failed,
120    Stopped,
121}
122
123impl Phase {
124    pub fn label(self) -> &'static str {
125        match self {
126            Self::Idle => "idle",
127            Self::Starting => "starting",
128            Self::Running => "running",
129            Self::Crashed => "crashed",
130            Self::Failed => "failed",
131            Self::Stopped => "stopped",
132        }
133    }
134}
135
136/// A snapshot for status displays.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Status {
139    pub phase: Phase,
140    pub pid: Option<u32>,
141    pub starts: u32,
142    /// Crashes in a row.
143    pub crashes: u32,
144    /// The kept stderr (and `log` lines).
145    pub stderr: String,
146    pub faults: Vec<String>,
147    /// Why it isn't running (exit, start failure).
148    pub error: Option<String>,
149    pub subscribed: Vec<String>,
150}
151
152type Reply = Result<Value, String>;
153
154struct Inner {
155    phase: Phase,
156    /// Bumped for every process started.
157    generation: u64,
158    writer: Option<SyncSender<Vec<u8>>>,
159    queued: VecDeque<Vec<u8>>,
160    queued_bytes: usize,
161    child: Option<Child>,
162    pid: Option<u32>,
163    pending: BTreeMap<u64, Sender<Reply>>,
164    next_id: u64,
165    stderr: Ring,
166    faults: VecDeque<String>,
167    subscribed: BTreeSet<String>,
168    starts: u32,
169    crashes: u32,
170    started_at: Option<Instant>,
171    error: Option<String>,
172    /// When a fault last went to the sink.
173    fault_posted: Option<Instant>,
174    /// Faults recorded since, not passed on yet.
175    fault_held: u32,
176    /// A thread will pass them on.
177    fault_flush: bool,
178}
179
180struct Shared {
181    plugin: String,
182    init: Value,
183    prepare: Prepare,
184    sink: Sink,
185    inner: Mutex<Inner>,
186}
187
188impl Shared {
189    fn lock(&self) -> MutexGuard<'_, Inner> {
190        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
191    }
192}
193
194/// One plugin backend (see the module docs). Stops its process when dropped.
195pub struct Backend {
196    shared: Arc<Shared>,
197}
198
199impl Backend {
200    /// `init` is the init message (sent first to every process, with `restart` added);
201    /// `events` the engine events it gets from the start.
202    pub fn new(plugin: &str, init: Value, events: &[String], prepare: Prepare, sink: Sink) -> Self {
203        let inner = Inner {
204            phase: Phase::Idle,
205            generation: 0,
206            writer: None,
207            queued: VecDeque::new(),
208            queued_bytes: 0,
209            child: None,
210            pid: None,
211            pending: BTreeMap::new(),
212            next_id: 1,
213            stderr: Ring::default(),
214            faults: VecDeque::new(),
215            subscribed: events.iter().cloned().collect(),
216            starts: 0,
217            crashes: 0,
218            started_at: None,
219            error: None,
220            fault_posted: None,
221            fault_held: 0,
222            fault_flush: false,
223        };
224        Self { shared: Arc::new(Shared { plugin: plugin.to_owned(), init, prepare, sink, inner: Mutex::new(inner) }) }
225    }
226
227    pub fn plugin(&self) -> &str {
228        &self.shared.plugin
229    }
230
231    /// It asked for engine event `event`.
232    pub fn wants(&self, event: &str) -> bool {
233        self.shared.lock().subscribed.contains(event)
234    }
235
236    pub fn status(&self) -> Status {
237        let g = self.shared.lock();
238        Status {
239            phase: g.phase,
240            pid: g.pid,
241            starts: g.starts,
242            crashes: g.crashes,
243            stderr: g.stderr.text(),
244            faults: g.faults.iter().cloned().collect(),
245            error: g.error.clone(),
246            subscribed: g.subscribed.iter().cloned().collect(),
247        }
248    }
249
250    /// Start it now if it isn't running (normally it starts on first use).
251    pub fn start(&self) {
252        let mut g = self.shared.lock();
253        if matches!(g.phase, Phase::Idle | Phase::Crashed) {
254            begin_start(&self.shared, &mut g);
255        }
256    }
257
258    /// Send an `event` message (`{"type": "event", "event": …, …}`), starting the backend if
259    /// needed. `false` if it can't take it (stopped, failed, too big).
260    pub fn event(&self, msg: Value) -> bool {
261        let Ok(line) = serde_json::to_vec(&msg) else { return false };
262        if line.len() > MAX_LINE {
263            log::warn!("plugin {}: an event over {MAX_LINE} bytes wasn't sent", self.shared.plugin);
264            return false;
265        }
266        let mut g = self.shared.lock();
267        if matches!(g.phase, Phase::Idle | Phase::Crashed) {
268            begin_start(&self.shared, &mut g);
269        }
270        push_line(&self.shared, &mut g, line).is_ok()
271    }
272
273    /// Ask it something and wait for the `reply` (or `timeout`, or `cancelled()`).
274    pub fn request(&self, method: &str, params: Value, timeout: Duration, cancelled: &dyn Fn() -> bool) -> Result<Value, String> {
275        let (tx, rx) = channel();
276        let id = {
277            let mut g = self.shared.lock();
278            match g.phase {
279                Phase::Stopped => return Err("the backend was stopped (its plugin changed, or was switched off or removed); try again".into()),
280                Phase::Failed => return Err(g.error.clone().unwrap_or_else(|| "the backend couldn't start".into())),
281                Phase::Idle | Phase::Crashed => begin_start(&self.shared, &mut g),
282                Phase::Starting | Phase::Running => {}
283            }
284            let id = g.next_id;
285            g.next_id = g.next_id.wrapping_add(1);
286            let line = serde_json::to_vec(&json!({"type": "request", "id": id, "method": method, "params": params})).map_err(|e| e.to_string())?;
287            if line.len() > MAX_LINE {
288                return Err(format!("the request is over {MAX_LINE} bytes"));
289            }
290            g.pending.insert(id, tx);
291            if let Err(why) = push_line(&self.shared, &mut g, line) {
292                g.pending.remove(&id);
293                return Err(why.into());
294            }
295            id
296        };
297        let started = Instant::now();
298        loop {
299            match rx.recv_timeout(Duration::from_millis(20)) {
300                Ok(r) => return r,
301                Err(RecvTimeoutError::Disconnected) => return Err("the backend went away".into()),
302                Err(RecvTimeoutError::Timeout) => {}
303            }
304            let gave_up = if cancelled() {
305                Some("Stopped by the user.".to_owned())
306            } else if started.elapsed() >= timeout {
307                Some(if timeout < Duration::from_secs(1) {
308                    format!("the backend didn't answer within {} ms", timeout.as_millis())
309                } else {
310                    format!("the backend didn't answer within {} seconds", timeout.as_secs())
311                })
312            } else {
313                None
314            };
315            if let Some(why) = gave_up {
316                let mut g = self.shared.lock();
317                g.pending.remove(&id);
318                if let Ok(line) = serde_json::to_vec(&json!({"type": "cancel", "id": id})) {
319                    let _ = push_line(&self.shared, &mut g, line);
320                }
321                return Err(why);
322            }
323        }
324    }
325
326    /// A problem byob found in what it sent (e.g. a view tree it had to fix): kept in its
327    /// status (`faults`) for the model and the user, and passed to the sink as a `fault`.
328    pub fn report_fault(&self, text: &str) {
329        fault(&self.shared, text.to_owned());
330    }
331
332    /// Hand `msg` to the sink as if the backend had sent it (the tool host uses this to pass
333    /// a tool's card on to the engine).
334    pub fn post(&self, msg: Value) {
335        (self.shared.sink)(Out::Msg(msg));
336    }
337
338    /// Stop the process for good (this object won't start it again).
339    pub fn stop(&self, why: &str) {
340        stop(&self.shared, why);
341    }
342}
343
344impl Drop for Backend {
345    fn drop(&mut self) {
346        stop(&self.shared, "the backend was stopped");
347    }
348}
349
350fn stop(shared: &Arc<Shared>, why: &str) {
351    let mut g = shared.lock();
352    if g.phase == Phase::Stopped {
353        return;
354    }
355    let was = g.phase;
356    g.phase = Phase::Stopped;
357    if let Some(tx) = g.writer.take()
358        && let Ok(line) = serde_json::to_vec(&json!({"type": "shutdown"}))
359    {
360        let _ = tx.try_send(line);
361    }
362    take_queued(&mut g);
363    g.pid = None;
364    let child = g.child.take();
365    for (_, tx) in std::mem::take(&mut g.pending) {
366        let _ = tx.send(Err(why.to_owned()));
367    }
368    drop(g);
369    if let Some(child) = child {
370        log::info!("plugin {}: stopping its backend ({why})", shared.plugin);
371        guarded("byob-backend-stop", move || {
372            reap(child, STOP_GRACE);
373        });
374    } else if was != Phase::Idle {
375        log::info!("plugin {}: backend stopped ({why})", shared.plugin);
376    }
377}
378
379/// Wait up to `grace` for `child` to exit, then kill it; either way kill what is left of
380/// its process group. Returns how it ended.
381fn reap(mut child: Child, grace: Duration) -> String {
382    let pid = child.id();
383    let deadline = Instant::now() + grace;
384    let how = loop {
385        match child.try_wait() {
386            Ok(Some(status)) => break exit_text(status),
387            Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
388            _ => {
389                // the group before the leader is reaped, so its id can't be reused yet
390                byob_sandbox::kill_group(pid);
391                let _ = child.kill();
392                break child.wait().map_or_else(|e| e.to_string(), exit_text);
393            }
394        }
395    };
396    byob_sandbox::kill_group(pid);
397    how
398}
399
400fn exit_text(status: std::process::ExitStatus) -> String {
401    match status.code() {
402        Some(c) => format!("exit code {c}"),
403        None => "killed by a signal".into(),
404    }
405}
406
407/// Queue one line: to the writer when running (dropped, with a fault, when it isn't
408/// reading), kept while starting (the oldest dropped past [`MAX_QUEUED`] or
409/// [`MAX_QUEUED_BYTES`]).
410fn push_line(shared: &Arc<Shared>, g: &mut Inner, line: Vec<u8>) -> Result<(), &'static str> {
411    const NOT_RUNNING: &str = "the backend isn't running";
412    match g.phase {
413        Phase::Running => match &g.writer {
414            Some(tx) => match tx.try_send(line) {
415                Ok(()) => Ok(()),
416                Err(TrySendError::Full(_)) => {
417                    record_fault(shared, g, format!("it isn't reading its input: {MAX_QUEUED} messages wait, so one was dropped"), false);
418                    Err("the backend isn't reading its input (too many messages wait); the message was dropped")
419                }
420                Err(TrySendError::Disconnected(_)) => Err(NOT_RUNNING),
421            },
422            None => Err(NOT_RUNNING),
423        },
424        Phase::Starting => {
425            let mut dropped = false;
426            while g.queued.len() >= MAX_QUEUED || (!g.queued.is_empty() && g.queued_bytes.saturating_add(line.len()) > MAX_QUEUED_BYTES) {
427                let Some(old) = g.queued.pop_front() else { break };
428                g.queued_bytes = g.queued_bytes.saturating_sub(old.len());
429                dropped = true;
430            }
431            if dropped {
432                log::warn!("plugin {}: its backend is slow to start; an old message was dropped", shared.plugin);
433            }
434            g.queued_bytes = g.queued_bytes.saturating_add(line.len());
435            g.queued.push_back(line);
436            Ok(())
437        }
438        Phase::Idle | Phase::Crashed | Phase::Failed | Phase::Stopped => Err(NOT_RUNNING),
439    }
440}
441
442/// Empty the start queue.
443fn take_queued(g: &mut Inner) -> VecDeque<Vec<u8>> {
444    g.queued_bytes = 0;
445    std::mem::take(&mut g.queued)
446}
447
448fn begin_start(shared: &Arc<Shared>, g: &mut Inner) {
449    g.phase = Phase::Starting;
450    let shared = Arc::clone(shared);
451    guarded("byob-backend-start", move || start_now(&shared));
452}
453
454/// What [`start_now`] got going.
455enum Running {
456    Process(Spawned),
457    Native(Box<dyn NativeBackend>),
458}
459
460fn start_now(shared: &Arc<Shared>) {
461    let called_off = || shared.lock().phase != Phase::Starting;
462    let prepared = catch_unwind(AssertUnwindSafe(|| (shared.prepare)(&called_off)));
463    let spawned = prepared.unwrap_or_else(|_| Err("preparing the backend crashed".into())).and_then(|s| match s {
464        Start::Process(l) => spawn(&l).map(Running::Process),
465        Start::Native(n) => Ok(Running::Native(n)),
466    });
467    let mut g = shared.lock();
468    if g.phase != Phase::Starting {
469        // stopped while it started
470        if let Ok(Running::Process((child, ..))) = spawned {
471            drop(g);
472            reap(child, Duration::ZERO);
473        }
474        return;
475    }
476    let running = match spawned {
477        Ok(parts) => parts,
478        Err(e) => {
479            g.phase = Phase::Failed;
480            g.error = Some(e.clone());
481            take_queued(&mut g);
482            for (_, tx) in std::mem::take(&mut g.pending) {
483                let _ = tx.send(Err(e.clone()));
484            }
485            drop(g);
486            log::warn!("plugin {}: its backend couldn't start: {e}", shared.plugin);
487            (shared.sink)(Out::Exited(e));
488            return;
489        }
490    };
491    g.generation = g.generation.wrapping_add(1);
492    let generation = g.generation;
493    // room for init, the start queue and a shutdown
494    let (tx, rx) = sync_channel::<Vec<u8>>(MAX_QUEUED + 2);
495    let mut init = shared.init.clone();
496    if let Some(o) = init.as_object_mut() {
497        o.insert("restart".into(), json!(g.starts));
498    }
499    if let Ok(line) = serde_json::to_vec(&init) {
500        let _ = tx.try_send(line);
501    }
502    for line in take_queued(&mut g) {
503        let _ = tx.try_send(line);
504    }
505    g.writer = Some(tx);
506    g.phase = Phase::Running;
507    g.starts = g.starts.saturating_add(1);
508    g.started_at = Some(Instant::now());
509    g.error = None;
510    let (child, stdin, stdout, stderr) = match running {
511        Running::Process(parts) => parts,
512        Running::Native(backend) => {
513            drop(g);
514            log::info!("plugin {}: native backend started", shared.plugin);
515            let s = Arc::clone(shared);
516            guarded("byob-backend-native", move || {
517                let how = native_loop(&s, backend, &rx);
518                exited(&s, generation, &how);
519            });
520            (shared.sink)(Out::Started);
521            return;
522        }
523    };
524    let pid = child.id();
525    g.pid = Some(pid);
526    g.child = Some(child);
527    drop(g);
528    log::info!("plugin {}: backend started", shared.plugin);
529    let out_done = Arc::new(AtomicBool::new(false));
530    let err_done = Arc::new(AtomicBool::new(false));
531    guarded("byob-backend-in", move || write_loop(stdin, &rx));
532    let (s, done) = (Arc::clone(shared), Arc::clone(&err_done));
533    guarded("byob-backend-err", move || {
534        let _ = catch_unwind(AssertUnwindSafe(|| stderr_loop(stderr, &s, generation)));
535        done.store(true, Ordering::SeqCst);
536    });
537    let (s, done) = (Arc::clone(shared), Arc::clone(&out_done));
538    guarded("byob-backend-out", move || {
539        let read = catch_unwind(AssertUnwindSafe(|| read_loop(stdout, &s, generation)));
540        if read.is_err() {
541            log::error!("plugin {}: the backend reader crashed", s.plugin);
542        }
543        done.store(true, Ordering::SeqCst);
544    });
545    let s = Arc::clone(shared);
546    guarded("byob-backend-wait", move || wait_loop(&s, generation, pid, &out_done, &err_done));
547    (shared.sink)(Out::Started);
548}
549
550/// Watch the process of `generation` until it exits (or closes its stdout, or is stopped),
551/// then let the readers finish its output and report it [`exited`]. Its process group goes
552/// too, so nothing it started keeps the pipes (and the reader threads) alive.
553fn wait_loop(shared: &Arc<Shared>, generation: u64, pid: u32, out_done: &AtomicBool, err_done: &AtomicBool) {
554    let ended = loop {
555        thread::sleep(WAIT_POLL);
556        let mut g = shared.lock();
557        if g.generation != generation || g.phase != Phase::Running {
558            return; // stopped: stop() reaps it
559        }
560        let Some(child) = g.child.as_mut() else { return };
561        match child.try_wait() {
562            Ok(Some(status)) => {
563                g.child = None;
564                break Ok(exit_text(status));
565            }
566            Ok(None) if !out_done.load(Ordering::SeqCst) => {}
567            // it closed stdout (or can't be waited for): a moment to exit, then killed
568            Ok(None) | Err(_) => break Err(g.child.take()),
569        }
570    };
571    let how = match ended {
572        Ok(how) => {
573            byob_sandbox::kill_group(pid);
574            how
575        }
576        Err(Some(child)) => reap(child, Duration::from_secs(2)),
577        Err(None) => "it ended".to_owned(),
578    };
579    let deadline = Instant::now() + DRAIN_GRACE;
580    while !(out_done.load(Ordering::SeqCst) && err_done.load(Ordering::SeqCst)) && Instant::now() < deadline {
581        thread::sleep(Duration::from_millis(5));
582    }
583    exited(shared, generation, &how);
584}
585
586type Spawned = (Child, ChildStdin, std::process::ChildStdout, std::process::ChildStderr);
587
588fn spawn(l: &Launch) -> Result<Spawned, String> {
589    let Some((program, args)) = l.argv.split_first() else { return Err("no command to start".into()) };
590    let mut cmd = Command::new(program);
591    cmd.args(args).current_dir(&l.cwd).envs(&l.env).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
592    // a group of its own, so what it starts is stopped with it
593    #[cfg(unix)]
594    std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
595    for k in &l.remove_env {
596        cmd.env_remove(k);
597    }
598    let mut child = cmd.spawn().map_err(|e| format!("couldn't start {}: {e}", Path::new(program).display()))?;
599    match (child.stdin.take(), child.stdout.take(), child.stderr.take()) {
600        (Some(i), Some(o), Some(e)) => Ok((child, i, o, e)),
601        _ => {
602            reap(child, Duration::ZERO);
603            Err("no pipes to the backend".into())
604        }
605    }
606}
607
608/// Run a native backend on the lines byob sends until `shutdown` (or the channel closes on
609/// a stop), each call under `catch_unwind`; returns how it ended. Its messages go through
610/// the same handling as a process's stdout lines.
611fn native_loop(shared: &Arc<Shared>, mut backend: Box<dyn NativeBackend>, rx: &Receiver<Vec<u8>>) -> String {
612    let s = Arc::clone(shared);
613    let out = Outbox::new(move |msg: Value| {
614        let size = serde_json::to_vec(&msg).map_or(0, |v| v.len());
615        if size > MAX_LINE {
616            fault(&s, format!("a message over {MAX_LINE} bytes was dropped"));
617        } else if msg.is_object() {
618            handle_msg(&s, msg);
619        }
620    });
621    for line in rx {
622        let Ok(msg) = serde_json::from_slice::<Value>(&line) else { continue };
623        match catch_unwind(AssertUnwindSafe(|| dispatch(backend.as_mut(), &msg, &out))) {
624            Ok(true) => {}
625            Ok(false) => return "it shut down".into(),
626            Err(p) => {
627                let text = panic_text(p.as_ref());
628                shared.lock().stderr.push(format!("(panic) {text}\n").as_bytes());
629                fault(shared, format!("the native backend panicked: {text}"));
630                return format!("it panicked: {text}");
631            }
632        }
633    }
634    "it ended".into()
635}
636
637fn write_loop(mut stdin: ChildStdin, rx: &Receiver<Vec<u8>>) {
638    for mut line in rx {
639        line.push(b'\n');
640        if stdin.write_all(&line).and_then(|()| stdin.flush()).is_err() {
641            return;
642        }
643    }
644}
645
646fn stderr_loop(mut stderr: impl Read, shared: &Shared, generation: u64) {
647    let mut buf = [0u8; 4096];
648    loop {
649        match stderr.read(&mut buf) {
650            Ok(0) => return,
651            Ok(n) => {
652                let mut g = shared.lock();
653                if g.generation != generation {
654                    return; // a newer process runs
655                }
656                g.stderr.push(buf.get(..n).unwrap_or_default());
657            }
658            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
659            Err(_) => return,
660        }
661    }
662}
663
664fn read_loop(stdout: impl Read, shared: &Arc<Shared>, generation: u64) {
665    let mut r = BufReader::new(stdout);
666    let mut buf = Vec::new();
667    loop {
668        let line = read_line(&mut r, &mut buf, MAX_LINE);
669        if shared.lock().generation != generation {
670            return; // a newer process runs: this one's leftovers are stale
671        }
672        match line {
673            Ok(LineRead::Line) => handle_line(shared, &buf),
674            Ok(LineRead::TooLong) => fault(shared, format!("a message over {MAX_LINE} bytes was dropped")),
675            Ok(LineRead::Eof) | Err(_) => return,
676        }
677    }
678}
679
680/// Keep a fault in the status and pass it to the sink, at most once per [`FAULT_EVERY`]:
681/// faults in between are passed on together after it (the newest, with how many more).
682fn fault(shared: &Arc<Shared>, text: String) {
683    let post = record_fault(shared, &mut shared.lock(), text, true);
684    if let Some(text) = post {
685        post_fault(shared, &text);
686    }
687}
688
689/// [`fault`] for a caller holding the lock: returns the text to post now (only if
690/// `may_post`; else a thread posts it).
691fn record_fault(shared: &Arc<Shared>, g: &mut Inner, text: String, may_post: bool) -> Option<String> {
692    let text: String = text.chars().take(MAX_FAULT).collect();
693    if g.faults.len() >= MAX_FAULTS {
694        g.faults.pop_front();
695    }
696    g.faults.push_back(text.clone());
697    let wait = g.fault_posted.map_or(Duration::ZERO, |t| FAULT_EVERY.saturating_sub(t.elapsed()));
698    if may_post && wait.is_zero() && !g.fault_flush {
699        g.fault_posted = Some(Instant::now());
700        return Some(text);
701    }
702    g.fault_held = g.fault_held.saturating_add(1);
703    if !g.fault_flush {
704        g.fault_flush = true;
705        let s = Arc::clone(shared);
706        guarded("byob-backend-faults", move || {
707            thread::sleep(wait);
708            flush_faults(&s);
709        });
710    }
711    None
712}
713
714/// Pass on the faults held back: the newest, and how many more there were.
715fn flush_faults(shared: &Shared) {
716    let text = {
717        let mut g = shared.lock();
718        g.fault_flush = false;
719        let held = std::mem::take(&mut g.fault_held);
720        if held == 0 {
721            return;
722        }
723        g.fault_posted = Some(Instant::now());
724        let last = g.faults.back().cloned().unwrap_or_default();
725        if held > 1 { format!("{last} ({} more)", held - 1) } else { last }
726    };
727    post_fault(shared, &text);
728}
729
730fn post_fault(shared: &Shared, text: &str) {
731    log::warn!("plugin {}: {text}", shared.plugin);
732    (shared.sink)(Out::Msg(json!({"type": "fault", "text": text})));
733}
734
735fn handle_line(shared: &Arc<Shared>, line: &[u8]) {
736    if line.iter().all(u8::is_ascii_whitespace) {
737        return;
738    }
739    let msg = match serde_json::from_slice::<Value>(line) {
740        Ok(v @ Value::Object(_)) => v,
741        _ => {
742            let text = String::from_utf8_lossy(line);
743            shared.lock().stderr.push(format!("(stdout) {text}\n").as_bytes());
744            let short: String = text.chars().take(200).collect();
745            fault(shared, format!("stdout is for protocol messages (JSON objects, one per line); this isn't one: {short}"));
746            return;
747        }
748    };
749    handle_msg(shared, msg);
750}
751
752/// One message from the backend (a process's line, or a native backend's [`Outbox`]).
753fn handle_msg(shared: &Arc<Shared>, msg: Value) {
754    let kind = msg.get("type").and_then(Value::as_str).unwrap_or_default();
755    match kind {
756        "reply" => {
757            let Some(id) = msg.get("id").and_then(Value::as_u64) else {
758                fault(shared, "a reply without a numeric id".into());
759                return;
760            };
761            let tx = shared.lock().pending.remove(&id);
762            let Some(tx) = tx else { return };
763            let result = match msg.get("error").filter(|e| !e.is_null()) {
764                Some(Value::String(e)) => Err(e.clone()),
765                Some(e) => Err(e.to_string()),
766                None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)),
767            };
768            let _ = tx.send(result);
769        }
770        "fault" => {
771            let text = msg.get("text").map(|t| t.as_str().map_or_else(|| t.to_string(), str::to_owned)).unwrap_or_default();
772            fault(shared, text);
773        }
774        "log" => {
775            let text = msg.get("text").and_then(Value::as_str).unwrap_or_default();
776            shared.lock().stderr.push(format!("(log) {text}\n").as_bytes());
777            log::info!("plugin {}: {}", shared.plugin, text.chars().take(500).collect::<String>());
778        }
779        "subscribe" | "unsubscribe" => {
780            let events: Vec<String> = msg.get("events").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
781            let unknown: Vec<&String> = events.iter().filter(|e| !crate::manifest::BACKEND_EVENTS.contains(&e.as_str())).collect();
782            if !unknown.is_empty() {
783                fault(shared, format!("no such engine events: {unknown:?} (there are {})", crate::manifest::BACKEND_EVENTS.join(", ")));
784            }
785            let mut g = shared.lock();
786            for e in events.iter().filter(|e| crate::manifest::BACKEND_EVENTS.contains(&e.as_str())) {
787                if kind == "subscribe" {
788                    g.subscribed.insert(e.clone());
789                } else {
790                    g.subscribed.remove(e);
791                }
792            }
793        }
794        _ => (shared.sink)(Out::Msg(msg)),
795    }
796}
797
798/// The backend of `generation` ended (`how`; its process, if any, is reaped).
799fn exited(shared: &Arc<Shared>, generation: u64, how: &str) {
800    let mut g = shared.lock();
801    if g.generation != generation || g.phase != Phase::Running {
802        return;
803    }
804    g.writer = None;
805    g.pid = None;
806    g.child = None;
807    let tail = g.stderr.last_lines(15);
808    let mut why = format!("the backend exited ({how})");
809    if !tail.is_empty() {
810        why.push_str(&format!("; its last output:\n{tail}"));
811    }
812    let stable = g.started_at.is_some_and(|t| t.elapsed() >= STABLE);
813    g.crashes = if stable { 1 } else { g.crashes.saturating_add(1) };
814    for (_, tx) in std::mem::take(&mut g.pending) {
815        let _ = tx.send(Err(why.clone()));
816    }
817    let crashes = g.crashes;
818    if crashes >= MAX_CRASHES {
819        g.phase = Phase::Failed;
820        why.push_str(&format!("\nIt exited {crashes} times in a row, so byob stopped starting it; fix it and reload the plugin."));
821    } else {
822        g.phase = Phase::Crashed;
823        let backoff = FIRST_BACKOFF.saturating_mul(1 << crashes.saturating_sub(1).min(8)).min(MAX_BACKOFF);
824        let s = Arc::clone(shared);
825        guarded("byob-backend-retry", move || {
826            thread::sleep(backoff);
827            let mut g = s.lock();
828            if g.phase == Phase::Crashed && g.generation == generation {
829                begin_start(&s, &mut g);
830            }
831        });
832    }
833    g.error = Some(why.clone());
834    drop(g);
835    log::warn!("plugin {}: {why}", shared.plugin);
836    (shared.sink)(Out::Exited(why));
837}
838
839fn guarded(name: &str, work: impl FnOnce() + Send + 'static) {
840    let label = name.to_owned();
841    let spawned = thread::Builder::new().name(name.into()).spawn(move || {
842        if catch_unwind(AssertUnwindSafe(work)).is_err() {
843            log::error!("{label} crashed");
844        }
845    });
846    if let Err(e) = spawned {
847        log::error!("could not start {name}: {e}");
848    }
849}
850
851/// The tail of a stream, bounded.
852#[derive(Default)]
853struct Ring(VecDeque<u8>);
854
855impl Ring {
856    fn push(&mut self, bytes: &[u8]) {
857        let bytes = bytes.get(bytes.len().saturating_sub(STDERR_KEEP)..).unwrap_or_default();
858        let overflow = (self.0.len() + bytes.len()).saturating_sub(STDERR_KEEP);
859        self.0.drain(..overflow.min(self.0.len()));
860        self.0.extend(bytes);
861    }
862
863    fn text(&self) -> String {
864        let (a, b) = self.0.as_slices();
865        let mut v = Vec::with_capacity(a.len() + b.len());
866        v.extend_from_slice(a);
867        v.extend_from_slice(b);
868        String::from_utf8_lossy(&v).into_owned()
869    }
870
871    fn last_lines(&self, n: usize) -> String {
872        last_lines(&self.text(), n)
873    }
874}
875
876/// The last `n` lines of `text`, trimmed.
877pub fn last_lines(text: &str, n: usize) -> String {
878    let lines: Vec<&str> = text.trim_end().lines().collect();
879    lines.get(lines.len().saturating_sub(n)..).unwrap_or_default().join("\n")
880}
881
882#[derive(Debug, PartialEq, Eq)]
883enum LineRead {
884    Line,
885    TooLong,
886    Eof,
887}
888
889/// Read one line into `buf` (cleared first), holding at most `max` bytes; longer lines are
890/// consumed and dropped.
891fn read_line(r: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> io::Result<LineRead> {
892    buf.clear();
893    let mut overflow = false;
894    let mut any = false;
895    loop {
896        let avail = match r.fill_buf() {
897            Ok(b) => b,
898            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
899            Err(e) => return Err(e),
900        };
901        if avail.is_empty() {
902            return Ok(match (overflow, any) {
903                (true, _) => LineRead::TooLong,
904                (false, true) => LineRead::Line,
905                (false, false) => LineRead::Eof,
906            });
907        }
908        any = true;
909        let newline = avail.iter().position(|&b| b == b'\n');
910        let (chunk, used) = match newline {
911            Some(i) => (avail.split_at(i).0, i.saturating_add(1)),
912            None => (avail, avail.len()),
913        };
914        if !overflow {
915            if buf.len().saturating_add(chunk.len()) > max {
916                overflow = true;
917                buf.clear();
918                buf.shrink_to(64 * 1024);
919            } else {
920                buf.extend_from_slice(chunk);
921            }
922        }
923        r.consume(used);
924        if newline.is_some() {
925            if buf.last() == Some(&b'\r') {
926                buf.pop();
927            }
928            return Ok(if overflow { LineRead::TooLong } else { LineRead::Line });
929        }
930    }
931}
932
933/// A tool a plugin's backend serves (`"run": "backend"`): each call is a `tool` request.
934pub struct BackendTool {
935    name: String,
936    plugin: PluginRef,
937    decl: ManifestTool,
938    backend: Arc<Backend>,
939    timeout: Duration,
940    /// The chat's artifact types, for the versions its results save.
941    types: byob_tools::ArtifactTypes,
942    /// The plugin's card views: (id, a web view).
943    cards: Vec<(String, bool)>,
944}
945
946impl BackendTool {
947    pub fn new(name: String, plugin: PluginRef, decl: ManifestTool, backend: Arc<Backend>) -> Self {
948        Self { name, plugin, decl, backend, timeout: REQUEST_TIMEOUT, types: byob_tools::ArtifactTypes::builtin(), cards: Vec::new() }
949    }
950
951    /// The card views its plugin declares (`views` with `"site": "card"`): (id, web view).
952    pub fn with_cards(mut self, cards: Vec<(String, bool)>) -> Self {
953        self.cards = cards;
954        self
955    }
956
957    /// Save the artifact versions its results carry with these types (the chat's).
958    pub fn with_types(mut self, types: byob_tools::ArtifactTypes) -> Self {
959        self.types = types;
960        self
961    }
962
963    pub fn with_timeout(mut self, timeout: Duration) -> Self {
964        self.timeout = timeout;
965        self
966    }
967
968    pub fn declared(&self) -> &ManifestTool {
969        &self.decl
970    }
971}
972
973impl Tool for BackendTool {
974    fn spec(&self) -> ToolSpec {
975        ToolSpec { name: self.name.clone(), description: self.decl.description.clone(), parameters: self.decl.params.clone() }
976    }
977
978    fn title(&self, args: &Value) -> String {
979        match &self.decl.title {
980            Some(t) => crate::python::fill_title(t, args),
981            None => format!("Used {}: {}", self.plugin.name, self.decl.name),
982        }
983    }
984
985    fn access(&self, _args: &Value) -> Access {
986        self.decl.access.unwrap_or(Access::Execute)
987    }
988
989    fn always_ask(&self, args: &Value) -> Option<String> {
990        self.decl.confirm.as_ref()?.applies(args)
991    }
992
993    fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
994        let args = if args.is_object() { args.clone() } else { Value::Object(serde_json::Map::new()) };
995        let params = json!({
996            "name": self.decl.name,
997            "args": args,
998            "workspace": ctx.workspace.display().to_string(),
999            "chat": ctx.chat(),
1000        });
1001        match self.backend.request("tool", params, self.timeout, &|| ctx.cancelled()) {
1002            Ok(v) => {
1003                self.attach_card(&v, ctx);
1004                let mut out = tool_output(v.clone(), ctx.workspace);
1005                save_artifacts(&v, &self.types, ctx, &mut out);
1006                out
1007            }
1008            Err(e) if e.starts_with("Stopped by the user") => ToolOutput::err(e),
1009            Err(e) => ToolOutput::err(format!("{} (backend) ran into a problem:\n{e}", self.plugin.name)),
1010        }
1011    }
1012}
1013
1014impl BackendTool {
1015    /// A tool reply's card (`{"text": …, "card": <view tree>, "card_view"?: id, "card_key"?:
1016    /// k}`) goes onto the call's step (through `ctx`, so it lands on this call's step and
1017    /// nowhere else). A tree card is also posted to the engine (`CARD_MSG`) to be cached as
1018    /// the live view it owns; a web card's page gets the card's data.
1019    fn attach_card(&self, v: &Value, ctx: &ToolContext<'_>) {
1020        let Some(o) = v.as_object() else { return };
1021        let Some(tree) = o.get("card").filter(|c| c.is_object()) else { return };
1022        let card = match o.get("card_view").and_then(Value::as_str) {
1023            Some(want) => match self.cards.iter().find(|(id, _)| id == want) {
1024                Some(c) => c,
1025                None => {
1026                    let ids: Vec<&str> = self.cards.iter().map(|(id, _)| id.as_str()).collect();
1027                    self.backend.report_fault(&format!("card_view {want:?} isn't a card view in byob-plugin.json (card views: {})", ids.join(", ")));
1028                    return;
1029                }
1030            },
1031            None => match self.cards.first() {
1032                Some(c) => c,
1033                None => {
1034                    self.backend.report_fault("a tool returned a \"card\", but byob-plugin.json declares no view with \"site\": \"card\"");
1035                    return;
1036                }
1037            },
1038        };
1039        let (view, web) = (card.0.clone(), card.1);
1040        let key = match o.get("card_key").and_then(Value::as_str) {
1041            Some(k) if !k.is_empty() => k.chars().take(byob_protocol::sites::MAX_VIEW_KEY_CHARS).collect(),
1042            _ => format!("{}-{}", self.name, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos())),
1043        };
1044        if web {
1045            let text = o.get("text").and_then(Value::as_str).unwrap_or_default().to_owned();
1046            let card = byob_protocol::web::WebCard { plugin: self.plugin.id.clone(), view, key, data: tree.clone(), text };
1047            ctx.attach_view(byob_protocol::View::of(&card));
1048            return;
1049        }
1050        let (node, _faults) = byob_protocol::tree::decode(tree.clone());
1051        let mut shown = byob_protocol::View::of(&node);
1052        shown.owner = Some(byob_protocol::ViewRef::new(&self.plugin.id, &view, &key));
1053        // Only while a step takes it: a call from the plugin's own page shows no card.
1054        if ctx.attach_view(shown) {
1055            self.backend.post(json!({"type": CARD_MSG, "view": view, "key": key, "tree": tree}));
1056        }
1057    }
1058}
1059
1060/// The sink message type of a tree card a backend tool attached to its step (`{"type":
1061/// "_tool_card", "view", "key", "tree"}`): the engine caches it as a live view.
1062pub const CARD_MSG: &str = "_tool_card";
1063
1064/// Most artifact versions one tool result saves.
1065const MAX_SAVED: usize = 4;
1066
1067/// A tool reply's `artifact` (or `artifacts`, a list): `{"id"?, "title"?, "filename"?,
1068/// "file", "note"?}` — `file` is a path (absolute, or in the workspace) whose bytes become a
1069/// new version of artifact `id` (a new artifact without one). Each saved version is a file
1070/// change of the call (so the step shows the artifact and clients hear of it) and a line
1071/// for the model; a failure is a line too.
1072fn save_artifacts(v: &Value, types: &byob_tools::ArtifactTypes, ctx: &ToolContext<'_>, out: &mut ToolOutput) {
1073    let Some(o) = v.as_object() else { return };
1074    let items: Vec<&Value> = match (o.get("artifact"), o.get("artifacts")) {
1075        (Some(a), _) if a.is_object() => vec![a],
1076        (_, Some(Value::Array(list))) => list.iter().filter(|a| a.is_object()).take(MAX_SAVED).collect(),
1077        _ => return,
1078    };
1079    let chat = ctx.chat().unwrap_or_default();
1080    for a in items {
1081        let s = |k: &str| a.get(k).and_then(Value::as_str);
1082        let result = (|| {
1083            let file = s("file").filter(|f| !f.is_empty()).ok_or("the artifact has no \"file\" (the path of its bytes)")?;
1084            let path = if Path::new(file).is_absolute() { PathBuf::from(file) } else { ctx.workspace.join(file) };
1085            let bytes = std::fs::metadata(&path)
1086                .map_err(|e| format!("{file}: {e}"))
1087                .and_then(|m| if m.len() > byob_tools::artifact::MAX_FILE_BYTES { Err(format!("{file} is too big")) } else { Ok(()) })
1088                .and_then(|()| std::fs::read(&path).map_err(|e| format!("{file}: {e}")))?;
1089            let save = byob_tools::artifact::SaveBytes {
1090                chat,
1091                id: s("id"),
1092                title: s("title"),
1093                filename: s("filename"),
1094                bytes: &bytes,
1095                note: s("note").unwrap_or_default(),
1096                replace: None,
1097            };
1098            byob_tools::artifact::save_bytes(types, ctx.workspace, &save)
1099        })();
1100        match result {
1101            Ok(saved) => {
1102                out.text.push_str(&format!(
1103                    "\nSaved: artifact {}, version {} ({}){}.",
1104                    saved.artifact.id,
1105                    saved.version,
1106                    saved.artifact.filename,
1107                    if saved.created { "; a new artifact, use this id from now on" } else { "" }
1108                ));
1109                out.changes.push(FileChange::Created(saved.file));
1110            }
1111            Err(e) => {
1112                out.text.push_str(&format!("\nThe result couldn't be saved as an artifact: {e}"));
1113                out.failed = true;
1114            }
1115        }
1116    }
1117}
1118
1119/// A tool reply as tool output: text, `{"text": …, "files": […]}`, or any JSON.
1120fn tool_output(v: Value, workspace: &Path) -> ToolOutput {
1121    let (text, files) = match v {
1122        Value::String(s) => (s, Vec::new()),
1123        Value::Null => (String::new(), Vec::new()),
1124        Value::Object(o) if ["text", "files", "card", "artifact", "artifacts"].iter().any(|k| o.contains_key(*k)) => {
1125            let text = o.get("text").map(|t| t.as_str().map_or_else(|| t.to_string(), str::to_owned)).unwrap_or_default();
1126            let files = o.get("files").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
1127            (text, files)
1128        }
1129        other => (other.to_string(), Vec::new()),
1130    };
1131    let changes = files.iter().filter(|f| path_inside(f)).map(|f| workspace.join(f)).filter(|p| p.is_file()).map(FileChange::Modified).collect();
1132    let text = if text.trim().is_empty() { "(no result)".to_owned() } else { text };
1133    ToolOutput { text, failed: false, changes, images: Vec::new() }
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    fn lines(input: &[u8], max: usize) -> Vec<Result<String, LineRead>> {
1141        let mut r = io::BufReader::with_capacity(3, input);
1142        let mut buf = Vec::new();
1143        let mut out = Vec::new();
1144        loop {
1145            match read_line(&mut r, &mut buf, max).unwrap() {
1146                LineRead::Line => out.push(Ok(String::from_utf8(buf.clone()).unwrap())),
1147                LineRead::Eof => return out,
1148                other => out.push(Err(other)),
1149            }
1150        }
1151    }
1152
1153    #[test]
1154    fn long_lines_are_dropped_not_fatal() {
1155        assert_eq!(lines(b"ab\r\nwaaaaaaaaaaay too long\nok", 8), vec![Ok("ab".into()), Err(LineRead::TooLong), Ok("ok".into())]);
1156    }
1157
1158    #[test]
1159    fn ring_keeps_the_tail() {
1160        let mut r = Ring::default();
1161        r.push(&vec![b'a'; STDERR_KEEP]);
1162        r.push(b"\nlast line\n");
1163        assert_eq!(r.0.len(), STDERR_KEEP);
1164        assert_eq!(r.last_lines(1), "last line");
1165    }
1166
1167    #[test]
1168    fn tool_replies() {
1169        let ws = std::env::temp_dir();
1170        assert_eq!(tool_output(json!("hi"), &ws).text, "hi");
1171        assert_eq!(tool_output(json!({"text": "t", "files": ["../x"]}), &ws).text, "t");
1172        assert_eq!(tool_output(json!({"n": 1}), &ws).text, "{\"n\":1}");
1173        assert_eq!(tool_output(Value::Null, &ws).text, "(no result)");
1174    }
1175
1176    #[test]
1177    fn a_backend_that_cannot_start_fails_its_requests() {
1178        let sink: Sink = Arc::new(|_| {});
1179        let b = Backend::new("x", json!({"type": "init"}), &[], Box::new(|_: &dyn Fn() -> bool| Err("no python".into())), sink);
1180        let e = b.request("tool", json!({}), Duration::from_secs(5), &|| false).unwrap_err();
1181        assert_eq!(e, "no python");
1182        assert_eq!(b.status().phase, Phase::Failed);
1183        assert!(!b.event(json!({"type": "event"})));
1184    }
1185
1186    #[test]
1187    fn faults_reach_the_sink_at_most_once_a_second() {
1188        let posted = Arc::new(Mutex::new(Vec::new()));
1189        let p = Arc::clone(&posted);
1190        let sink: Sink = Arc::new(move |o| {
1191            if let Out::Msg(m) = o {
1192                p.lock().unwrap().push(m["text"].as_str().unwrap_or_default().to_owned());
1193            }
1194        });
1195        let b = Backend::new("x", json!({"type": "init"}), &[], Box::new(|_: &dyn Fn() -> bool| Err("-".into())), sink);
1196        for i in 0..100 {
1197            b.report_fault(&format!("not json {i}"));
1198        }
1199        // every one is in the status (the newest), the sink heard of the first only
1200        assert_eq!(b.status().faults.last().map(String::as_str), Some("not json 99"));
1201        assert_eq!(b.status().faults.len(), MAX_FAULTS);
1202        assert_eq!(*posted.lock().unwrap(), ["not json 0"]);
1203        // then of the rest, once
1204        let deadline = Instant::now() + Duration::from_secs(5);
1205        while posted.lock().unwrap().len() < 2 && Instant::now() < deadline {
1206            thread::sleep(Duration::from_millis(10));
1207        }
1208        thread::sleep(Duration::from_millis(200));
1209        assert_eq!(*posted.lock().unwrap(), ["not json 0", "not json 99 (98 more)"]);
1210    }
1211
1212    /// A backend that stays starting until stopped; `true` in the flag once its
1213    /// preparation saw that.
1214    fn never_starts() -> (Backend, Arc<std::sync::atomic::AtomicBool>) {
1215        let saw = Arc::new(AtomicBool::new(false));
1216        let s = Arc::clone(&saw);
1217        let prepare = Box::new(move |called_off: &dyn Fn() -> bool| {
1218            while !called_off() {
1219                thread::sleep(Duration::from_millis(5));
1220            }
1221            s.store(true, Ordering::SeqCst);
1222            Err("called off".to_owned())
1223        });
1224        (Backend::new("x", json!({"type": "init"}), &[], prepare, Arc::new(|_| {})), saw)
1225    }
1226
1227    #[test]
1228    fn a_stop_calls_off_a_long_preparation() {
1229        let (b, saw) = never_starts();
1230        b.start();
1231        thread::sleep(Duration::from_millis(50));
1232        assert_eq!(b.status().phase, Phase::Starting);
1233        b.stop("reloaded");
1234        let deadline = Instant::now() + Duration::from_secs(5);
1235        while !saw.load(Ordering::SeqCst) {
1236            assert!(Instant::now() < deadline, "the preparation went on");
1237            thread::sleep(Duration::from_millis(5));
1238        }
1239    }
1240
1241    #[test]
1242    fn a_slow_start_keeps_a_bounded_number_of_bytes() {
1243        let (b, _saw) = never_starts();
1244        let big = "x".repeat(MAX_LINE - 100);
1245        for _ in 0..40 {
1246            assert!(b.event(json!({"type": "event", "event": "client", "msg": big})));
1247        }
1248        let g = b.shared.lock();
1249        assert!(g.queued_bytes <= MAX_QUEUED_BYTES, "{}", g.queued_bytes);
1250        assert_eq!(g.queued_bytes, g.queued.iter().map(Vec::len).sum::<usize>());
1251        assert!(g.queued.len() < 40);
1252    }
1253}