Skip to main content

runtime/
backlog.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The work still running while a run goes on, and the one place it is
7//! stopped.
8//!
9//! `runtime::turn` consumes an interruption at a phase boundary. A child
10//! process waited on inside a system call is not at a phase boundary, so
11//! a command blocked there cannot be stopped. Every command therefore
12//! enters this table before it is waited on: the wait
13//! is a bounded number of short polls against a member somebody else can
14//! reach, rather than one blocking call nobody can interrupt.
15//!
16//! Two facts about the wait are deliberate. It is counted rather than
17//! timed, because the clock is sampled at one point in this city and a
18//! module that sampled it to wait ten seconds would be the second one.
19//! And a child's output goes to files rather than to pipes, because a
20//! pipe buffer that fills stops the child inside a write — which is the
21//! same hang this module exists to remove, wearing another name.
22//!
23//! How many polls that wait is, and how an ending is read, both belong
24//! to [`waiting`]: the window is carried by the table rather than read
25//! from a constant here, so a caller can choose one short enough to
26//! observe both answers.
27//!
28//! The table has two kinds of member: a background command, which a
29//! halt kills, and a run a resident handed down, which a halt marks and
30//! which stops itself at its next safe point by asking [`Backlog::stopping`]
31//! (`crates/runtime/spec/Backlog.lean` §8-28-2). One table, one `halt`, so a stopped scope has
32//! nothing left going in it under either name.
33
34use std::collections::BTreeMap;
35use std::process::{Command, Stdio};
36
37use kernel::{Address, AxCode, AxError, RunId};
38
39/// One member of the table, for as long as this process lives.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub struct BacklogId(u64);
42
43impl std::fmt::Display for BacklogId {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "bg-{}", self.0)
46    }
47}
48
49/// The table itself. Cloning gives another handle onto the same table,
50/// which is how the assembly point can stop what a tool started without
51/// either of them knowing about the other. A clone carries `scratch`
52/// too, so it keeps naming that backlog's directories.
53#[derive(Clone)]
54pub struct Backlog {
55    table: std::sync::Arc<std::sync::Mutex<Table>>,
56    scratch: Scratch,
57    window: PollBudget,
58    sink: Option<Sink>,
59}
60
61impl Default for Backlog {
62    fn default() -> Backlog {
63        Backlog::with_window(PollBudget::DEFAULT)
64    }
65}
66
67#[derive(Default)]
68struct Table {
69    next: u64,
70    members: BTreeMap<BacklogId, Member>,
71    jobs: jobs::Jobs,
72}
73
74impl Backlog {
75    #[must_use]
76    pub fn new() -> Backlog {
77        Backlog::default()
78    }
79
80    /// A table whose callers block for this window before a command is
81    /// handed to the background.
82    #[must_use]
83    pub fn with_window(window: PollBudget) -> Backlog {
84        Backlog {
85            table: std::sync::Arc::default(),
86            scratch: Scratch::open(),
87            window,
88            sink: None,
89        }
90    }
91
92    /// The same table, handing what a command writes to `sink` while
93    /// the command is still inside its window (`crates/runtime/spec/Backlog.lean` §8-28-3).
94    #[must_use]
95    pub fn with_sink(self, sink: Sink) -> Backlog {
96        Backlog {
97            sink: Some(sink),
98            ..self
99        }
100    }
101
102    /// Starts a command, waits out the short window, and hands back
103    /// either its result or a handle onto it.
104    ///
105    /// The command's program, arguments, working directory and
106    /// environment are the caller's; this module owns only where the
107    /// output goes and how the waiting is done. A command that outlives
108    /// its window is owed to `owner` and to no other run.
109    ///
110    /// # Errors
111    /// `E_TOOL_UNAVAILABLE` when the program cannot be started, and
112    /// `E_STORAGE_FATAL` when the place its output goes cannot be
113    /// written.
114    pub fn run(
115        &self,
116        owner: RunId,
117        scope: &Address,
118        what: String,
119        mut command: Command,
120    ) -> Result<Started, AxError> {
121        let id = self.mint()?;
122        let dir = self.scratch.dir(id);
123        std::fs::create_dir_all(&dir).map_err(|err| storage(&dir, &err))?;
124        let out = std::fs::File::create(dir.join("out")).map_err(|err| storage(&dir, &err))?;
125        let errs = std::fs::File::create(dir.join("err")).map_err(|err| storage(&dir, &err))?;
126        command
127            .stdin(Stdio::null())
128            .stdout(Stdio::from(out))
129            .stderr(Stdio::from(errs));
130        let child = command.spawn().map_err(|err| {
131            AxError::failure(
132                AxCode::ToolUnavailable,
133                "start a command",
134                format!("{what}: {err}"),
135            )
136            .with_recovery("check the program name, or use the shell arm")
137        })?;
138        self.enrol(
139            id,
140            Member {
141                scope: scope.clone(),
142                what: what.clone(),
143                body: Body::Command {
144                    child,
145                    dir: dir.clone(),
146                    claim: Claim::Window(owner),
147                    tail: Tail::default(),
148                },
149            },
150        )?;
151        let mut tail = Tail::default();
152        for _ in 0..self.window.polls() {
153            if let Some(exit) = self.settle(id)? {
154                let (stdout, stderr) = collect(&dir);
155                return Ok(Started::Settled {
156                    exit,
157                    stdout,
158                    stderr,
159                });
160            }
161            if let Some(sink) = &self.sink {
162                sink.deliver(tail.take(&dir, (owner, id), self.window.read_per_poll()));
163            }
164            std::thread::sleep(self.window.interval());
165        }
166        self.hand_over(id, tail)?;
167        Ok(Started::Backgrounded { id, what })
168    }
169
170    /// Enters a run a resident handed down, so a halt on its scope can
171    /// reach it. The assembly layer calls this before the drive and
172    /// [`Backlog::leave`] after it.
173    ///
174    /// # Errors
175    /// `E_STORAGE_FATAL` when the table cannot be reached.
176    pub fn enrol_run(&self, scope: &Address, what: String) -> Result<BacklogId, AxError> {
177        let id = self.mint()?;
178        self.enrol(
179            id,
180            Member {
181                scope: scope.clone(),
182                what,
183                body: Body::Run(RunState::Going),
184            },
185        )?;
186        Ok(id)
187    }
188
189    /// Whether a halt has reached this member. A run asks at each safe
190    /// point and answers `true` with `Interrupt::Cancel`; a member that
191    /// has already left the table is not stopping, it is gone.
192    ///
193    /// # Errors
194    /// `E_STORAGE_FATAL` when the table cannot be reached.
195    pub fn stopping(&self, id: BacklogId) -> Result<bool, AxError> {
196        let table = self.hold()?;
197        Ok(table
198            .members
199            .get(&id)
200            .is_some_and(|member| matches!(member.body, Body::Run(RunState::Stopping))))
201    }
202
203    /// Takes a run member out once it has frozen. A run that stayed
204    /// would be reported standing after its own `run_frozen` line.
205    ///
206    /// # Errors
207    /// `E_STORAGE_FATAL` when the table cannot be reached.
208    pub fn leave(&self, id: BacklogId) -> Result<(), AxError> {
209        let mut table = self.hold()?;
210        table.members.remove(&id);
211        Ok(())
212    }
213
214    /// Stops every member inside a scope, or every member in the city
215    /// when no scope is named. Returns how many were reached.
216    ///
217    /// A member is stopped where it stands, and the run that
218    /// started it comes back to its next boundary rather than staying
219    /// inside a system call. A run member is marked rather than killed:
220    /// it comes back to its own next safe point and cancels there.
221    ///
222    /// # Errors
223    /// `E_STORAGE_FATAL` when the table cannot be reached, which means
224    /// a thread died holding it.
225    pub fn halt(&self, scope: Option<&Address>) -> Result<usize, AxError> {
226        let mut table = self.hold()?;
227        let mut reached = 0usize;
228        for member in table.members.values_mut() {
229            let covered = scope.is_none_or(|within| member.scope.is_within(within));
230            if !covered {
231                continue;
232            }
233            let stopped = match &mut member.body {
234                Body::Command { child, .. } => child.kill().is_ok(),
235                Body::Run(state) => {
236                    *state = RunState::Stopping;
237                    true
238                }
239            };
240            if stopped {
241                reached = reached.saturating_add(1);
242            }
243        }
244        Ok(reached)
245    }
246
247    /// Takes every background member of `owner` that has stopped, in
248    /// table order, and lets go of every stopped member whose run has
249    /// ended.
250    ///
251    /// A member is reported once and then forgotten: this is what a
252    /// caller appends to the tail of its next tool result, and a result
253    /// delivered twice would read as the command having run twice. A
254    /// member another run started is never reported here, because that
255    /// tail goes into this run's model and onto its lines of the ledger.
256    ///
257    /// # Errors
258    /// `E_STORAGE_FATAL` when the table cannot be reached.
259    pub fn harvest(&self, owner: RunId) -> Result<Vec<Finished>, AxError> {
260        let mut table = self.hold()?;
261        let mut done = Vec::new();
262        let mut spent = Vec::new();
263        let mut live = Vec::new();
264        for (id, member) in &mut table.members {
265            let Body::Command {
266                child,
267                dir,
268                claim,
269                tail,
270            } = &mut member.body
271            else {
272                continue;
273            };
274            match claim {
275                Claim::Run(run) if *run == owner => {}
276                Claim::Nobody => {}
277                Claim::Window(_) | Claim::Run(_) => continue,
278            }
279            let Some(exit) = Exit::polled(child) else {
280                if let (Claim::Run(_), Some(_)) = (claim, &self.sink) {
281                    live.extend(tail.take(dir, (owner, *id), self.window.read_per_poll()));
282                }
283                continue;
284            };
285            spent.push(*id);
286            if let Claim::Run(_) = claim {
287                let (stdout, stderr) = collect(dir);
288                done.push(Finished {
289                    id: *id,
290                    what: member.what.clone(),
291                    exit,
292                    stdout,
293                    stderr,
294                });
295            } else {
296                // Output owed to nobody is not read at all. A directory
297                // that will not go costs disk, never the harvest.
298                drop(std::fs::remove_dir_all(dir));
299            }
300        }
301        for id in spent {
302            table.members.remove(&id);
303        }
304        drop(table);
305        if let Some(sink) = &self.sink {
306            sink.deliver(live);
307        }
308        Ok(done)
309    }
310
311    /// Terminates every background command `owner` started, marks each
312    /// as owed to nobody, and returns how many there were. The run has
313    /// ended, so no later tool result of its own can carry them; the
314    /// next harvest by any run lets their process handles and files go
315    /// without reading their output to anyone.
316    ///
317    /// Infallible by construction: lowering a claim and killing a
318    /// process hold on any state of the table, so a table a dead thread
319    /// left locked is still reached, while every other call keeps
320    /// answering `E_STORAGE_FATAL`. The caller is a drop, which has
321    /// nobody to hand a failure to.
322    pub fn release(&self, owner: RunId) -> usize {
323        let mut table = self
324            .table
325            .lock()
326            .unwrap_or_else(std::sync::PoisonError::into_inner);
327        table.jobs.forget(owner);
328        let mut released = 0usize;
329        for member in table.members.values_mut() {
330            if let Body::Command { claim, child, .. } = &mut member.body
331                && *claim == Claim::Run(owner)
332            {
333                *claim = Claim::Nobody;
334                // `Child::kill` answers `Ok` for a child that already
335                // exited, so an error here is a kill the system refused.
336                // The member is still owed to nobody and the next harvest
337                // reaps it once it stops; the caller is a drop with
338                // nobody to hand the refusal to.
339                drop(child.kill());
340                released = released.saturating_add(1);
341            }
342        }
343        released
344    }
345
346    /// What is still running at or under one address.
347    ///
348    /// # Errors
349    /// `E_STORAGE_FATAL` when the table cannot be reached.
350    pub fn standing(&self, scope: &Address) -> Result<Vec<Standing>, AxError> {
351        let table = self.hold()?;
352        Ok(table
353            .members
354            .iter()
355            .filter(|(_, member)| member.scope.is_within(scope))
356            .map(|(id, member)| Standing {
357                id: *id,
358                scope: member.scope.clone(),
359                what: member.what.clone(),
360                kind: member.body.kind(),
361            })
362            .collect())
363    }
364
365    fn hold(&self) -> Result<std::sync::MutexGuard<'_, Table>, AxError> {
366        self.table.lock().map_err(|_| {
367            AxError::failure(
368                AxCode::StorageFatal,
369                "reach the backlog",
370                "the table was left locked by a thread that died",
371            )
372            .with_recovery("restart this city; what was running is reported by `sprawling resume`")
373        })
374    }
375
376    fn mint(&self) -> Result<BacklogId, AxError> {
377        let mut table = self.hold()?;
378        table.next = table.next.saturating_add(1);
379        Ok(BacklogId(table.next))
380    }
381}
382
383#[cfg(test)]
384mod tests;
385
386mod jobs;
387mod member;
388mod report;
389mod scratch;
390mod tail;
391pub mod waiting;
392pub use jobs::RunProcesses;
393use member::{Body, Claim, Member, RunState, collect, storage};
394pub use report::{BacklogKind, Finished, Standing, Started};
395use scratch::Scratch;
396use tail::Tail;
397pub use tail::{Chunk, Sink, Stream};
398pub use waiting::{Exit, PollBudget, Unseen};