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;
35
36use kernel::{Address, AxCode, AxError, RunId};
37
38/// One member of the table, for as long as this process lives.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub struct BacklogId(u64);
41
42impl std::fmt::Display for BacklogId {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "bg-{}", self.0)
45    }
46}
47
48/// The table itself. Cloning gives another handle onto the same table,
49/// which is how the assembly point can stop what a tool started without
50/// either of them knowing about the other. A clone carries `scratch`
51/// too, so it keeps naming that backlog's directories.
52#[derive(Clone)]
53pub struct Backlog {
54    table: std::sync::Arc<std::sync::Mutex<Table>>,
55    scratch: Scratch,
56    window: PollBudget,
57    sink: Option<Sink>,
58    shares: Shares,
59    affinity: RunAffinity,
60}
61
62impl Default for Backlog {
63    fn default() -> Backlog {
64        Backlog::with_window(PollBudget::DEFAULT)
65    }
66}
67
68#[derive(Default)]
69struct Table {
70    next: u64,
71    members: BTreeMap<BacklogId, Member>,
72    jobs: jobs::Jobs,
73}
74
75impl Backlog {
76    /// The same table, asking these shares for every run whose command
77    /// it starts (`crates/runtime/spec/Tools/Exec.lean` D29).
78    #[must_use]
79    pub fn with_shares(self, shares: Shares) -> Backlog {
80        Backlog { shares, ..self }
81    }
82
83    /// The shares this table asks for each run.
84    #[must_use]
85    pub fn shares(&self) -> Shares {
86        self.shares
87    }
88
89    /// Enters a run a resident handed down, so a halt on its scope can
90    /// reach it. The assembly layer calls this before the drive and
91    /// [`Backlog::leave`] after it.
92    ///
93    /// # Errors
94    /// `E_STORAGE_FATAL` when the table cannot be reached.
95    pub fn enrol_run(&self, scope: &Address, what: String) -> Result<BacklogId, AxError> {
96        let id = self.mint()?;
97        self.enrol(
98            id,
99            Member {
100                scope: scope.clone(),
101                what,
102                body: Body::Run(RunState::Going),
103            },
104        )?;
105        Ok(id)
106    }
107
108    /// Whether a halt has reached this member. A run asks at each safe
109    /// point and answers `true` with `Interrupt::Cancel`; a member that
110    /// has already left the table is not stopping, it is gone.
111    ///
112    /// # Errors
113    /// `E_STORAGE_FATAL` when the table cannot be reached.
114    pub fn stopping(&self, id: BacklogId) -> Result<bool, AxError> {
115        let table = self.hold()?;
116        Ok(table
117            .members
118            .get(&id)
119            .is_some_and(|member| matches!(member.body, Body::Run(RunState::Stopping))))
120    }
121
122    /// Takes a run member out once it has frozen. A run that stayed
123    /// would be reported standing after its own `run_frozen` line.
124    ///
125    /// # Errors
126    /// `E_STORAGE_FATAL` when the table cannot be reached.
127    pub fn leave(&self, id: BacklogId) -> Result<(), AxError> {
128        let mut table = self.hold()?;
129        table.members.remove(&id);
130        Ok(())
131    }
132
133    /// Stops every member inside a scope, or every member in the city
134    /// when no scope is named. Returns how many were reached.
135    ///
136    /// A member is stopped where it stands, and the run that
137    /// started it comes back to its next boundary rather than staying
138    /// inside a system call. A run member is marked rather than killed:
139    /// it comes back to its own next safe point and cancels there.
140    ///
141    /// # Errors
142    /// `E_STORAGE_FATAL` when the table cannot be reached, which means
143    /// a thread died holding it.
144    pub fn halt(&self, scope: Option<&Address>) -> Result<usize, AxError> {
145        let mut table = self.hold()?;
146        let mut reached = 0usize;
147        let mut failure = None;
148        for member in table.members.values_mut() {
149            let covered = scope.is_none_or(|within| member.scope.is_within(within));
150            if !covered {
151                continue;
152            }
153            let stopped = match &mut member.body {
154                Body::Command { child, .. } => match child.stop() {
155                    Ok(()) => true,
156                    Err(err) => {
157                        failure = Some(err);
158                        false
159                    }
160                },
161                Body::Run(state) => {
162                    *state = RunState::Stopping;
163                    true
164                }
165            };
166            if stopped {
167                reached = reached.saturating_add(1);
168            }
169        }
170        match failure {
171            Some(err) => Err(err),
172            None => Ok(reached),
173        }
174    }
175
176    /// Takes every background member of `owner` that has stopped, in
177    /// table order, and lets go of every stopped member whose run has
178    /// ended.
179    ///
180    /// A member is reported once and then forgotten: this is what a
181    /// caller appends to the tail of its next tool result, and a result
182    /// delivered twice would read as the command having run twice. A
183    /// member another run started is never reported here, because that
184    /// tail goes into this run's model and onto its lines of the ledger.
185    ///
186    /// # Errors
187    /// `E_STORAGE_FATAL` when the table cannot be reached.
188    pub fn harvest(&self, owner: RunId) -> Result<Vec<Finished>, AxError> {
189        let mut table = self.hold()?;
190        let mut done = Vec::new();
191        let mut spent = Vec::new();
192        let mut live = Vec::new();
193        let Table { members, jobs, .. } = &mut *table;
194        for (id, member) in members.iter_mut() {
195            let Body::Command {
196                child,
197                dir,
198                claim,
199                tail,
200                ceiling,
201            } = &mut member.body
202            else {
203                continue;
204            };
205            match claim {
206                Claim::Run(run) if *run == owner => {}
207                Claim::Nobody => {}
208                Claim::Window(_) | Claim::Run(_) => continue,
209            }
210            let Some(exit) = child.poll()? else {
211                if let (Claim::Run(_), Some(_)) = (claim, &self.sink) {
212                    live.extend(tail.take(dir, (owner, *id), self.window.read_per_poll()));
213                }
214                continue;
215            };
216            spent.push(*id);
217            if let Claim::Run(_) = claim {
218                let (stdout, stderr) = collect(dir);
219                done.push(Finished {
220                    id: *id,
221                    what: member.what.clone(),
222                    exit,
223                    stdout,
224                    stderr,
225                    ceiling: jobs.ceiling(owner, *ceiling),
226                });
227            } else {
228                // Output owed to nobody is not read at all. A directory
229                // that will not go costs disk, never the harvest.
230                drop(std::fs::remove_dir_all(dir));
231            }
232        }
233        for id in spent {
234            table.members.remove(&id);
235        }
236        drop(table);
237        if let Some(sink) = &self.sink {
238            sink.deliver(live);
239        }
240        Ok(done)
241    }
242
243    /// Terminates every background command `owner` started, marks each
244    /// as owed to nobody, and returns how many there were. The run has
245    /// ended, so no later tool result of its own can carry them; the
246    /// next harvest by any run lets their process handles and files go
247    /// without reading their output to anyone.
248    ///
249    /// Infallible by construction: lowering a claim and killing a
250    /// process hold on any state of the table, so a table a dead thread
251    /// left locked is still reached, while every other call keeps
252    /// answering `E_STORAGE_FATAL`. The caller is a drop, which has
253    /// nobody to hand a failure to.
254    pub fn release(&self, owner: RunId) -> usize {
255        let mut table = self
256            .table
257            .lock()
258            .unwrap_or_else(std::sync::PoisonError::into_inner);
259        table.jobs.forget(owner);
260        let mut released = 0usize;
261        for member in table.members.values_mut() {
262            if let Body::Command { claim, child, .. } = &mut member.body
263                && matches!(*claim, Claim::Window(run) | Claim::Run(run) if run == owner)
264            {
265                *claim = Claim::Nobody;
266                // `Child::kill` answers `Ok` for a child that already
267                // exited, so an error here is a kill the system refused.
268                // The member is still owed to nobody and the next harvest
269                // reaps it once it stops; the caller is a drop with
270                // nobody to hand the refusal to.
271                if let Err(err) = child.stop() {
272                    eprintln!("backlog cleanup retained for retry: {err}");
273                }
274                released = released.saturating_add(1);
275            }
276        }
277        released
278    }
279
280    /// What is still running at or under one address.
281    ///
282    /// # Errors
283    /// `E_STORAGE_FATAL` when the table cannot be reached.
284    pub fn standing(&self, scope: &Address) -> Result<Vec<Standing>, AxError> {
285        let table = self.hold()?;
286        Ok(table
287            .members
288            .iter()
289            .filter(|(_, member)| member.scope.is_within(scope))
290            .map(|(id, member)| Standing {
291                id: *id,
292                scope: member.scope.clone(),
293                what: member.what.clone(),
294                kind: member.body.kind(),
295            })
296            .collect())
297    }
298
299    fn hold(&self) -> Result<std::sync::MutexGuard<'_, Table>, AxError> {
300        self.table.lock().map_err(|_| {
301            AxError::failure(
302                AxCode::StorageFatal,
303                "reach the backlog",
304                "the table was left locked by a thread that died",
305            )
306            .with_recovery("restart this city; what was running is reported by `sprawling resume`")
307        })
308    }
309
310    fn mint(&self) -> Result<BacklogId, AxError> {
311        let mut table = self.hold()?;
312        table.next = table.next.saturating_add(1);
313        Ok(BacklogId(table.next))
314    }
315}
316
317#[cfg(test)]
318mod tests;
319
320mod container;
321mod process;
322pub(crate) use container::ContainerRequest;
323
324mod ceiling;
325mod cgroup;
326mod config;
327mod host;
328mod jobs;
329mod member;
330#[cfg(windows)]
331mod native_windows;
332mod report;
333#[cfg(windows)]
334mod run_job;
335mod scratch;
336mod tail;
337pub mod waiting;
338pub use ceiling::{Ceiling, Unapplied};
339pub use cgroup::{PlatformShares, platform_shares};
340pub use jobs::{RunAffinity, RunProcesses, Shares};
341use member::{Body, Claim, Member, RunState, collect, storage};
342pub use report::{BacklogKind, Finished, Standing, Started};
343use scratch::Scratch;
344use tail::Tail;
345pub use tail::{Chunk, Sink, Stream};
346pub use waiting::{Exit, PollBudget, Unseen};