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 shares: Shares,
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 /// Starts a command, waits out the short window, and hands back
90 /// either its result or a handle onto it.
91 ///
92 /// The command's program, arguments, working directory and
93 /// environment are the caller's; this module owns only where the
94 /// output goes and how the waiting is done. A command that outlives
95 /// its window is owed to `owner` and to no other run.
96 ///
97 /// # Errors
98 /// `E_TOOL_UNAVAILABLE` when the program cannot be started, and
99 /// `E_STORAGE_FATAL` when the place its output goes cannot be
100 /// written.
101 pub fn run(
102 &self,
103 owner: RunId,
104 scope: &Address,
105 what: String,
106 mut command: Command,
107 ) -> Result<Started, AxError> {
108 let id = self.mint()?;
109 let dir = self.scratch.dir(id);
110 std::fs::create_dir_all(&dir).map_err(|err| storage(&dir, &err))?;
111 let out = std::fs::File::create(dir.join("out")).map_err(|err| storage(&dir, &err))?;
112 let errs = std::fs::File::create(dir.join("err")).map_err(|err| storage(&dir, &err))?;
113 command
114 .stdin(Stdio::null())
115 .stdout(Stdio::from(out))
116 .stderr(Stdio::from(errs));
117 let child = command.spawn().map_err(|err| {
118 AxError::failure(
119 AxCode::ToolUnavailable,
120 "start a command",
121 format!("{what}: {err}"),
122 )
123 .with_recovery("check the program name, or use the shell arm")
124 })?;
125 self.enrol(
126 id,
127 Member {
128 scope: scope.clone(),
129 what: what.clone(),
130 body: Body::Command {
131 child,
132 dir: dir.clone(),
133 claim: Claim::Window(owner),
134 tail: Tail::default(),
135 },
136 },
137 )?;
138 let mut tail = Tail::default();
139 for _ in 0..self.window.polls() {
140 if let Some(exit) = self.settle(id)? {
141 let (stdout, stderr) = collect(&dir);
142 return Ok(Started::Settled {
143 exit,
144 stdout,
145 stderr,
146 });
147 }
148 if let Some(sink) = &self.sink {
149 sink.deliver(tail.take(&dir, (owner, id), self.window.read_per_poll()));
150 }
151 std::thread::sleep(self.window.interval());
152 }
153 self.hand_over(id, tail)?;
154 Ok(Started::Backgrounded { id, what })
155 }
156
157 /// Enters a run a resident handed down, so a halt on its scope can
158 /// reach it. The assembly layer calls this before the drive and
159 /// [`Backlog::leave`] after it.
160 ///
161 /// # Errors
162 /// `E_STORAGE_FATAL` when the table cannot be reached.
163 pub fn enrol_run(&self, scope: &Address, what: String) -> Result<BacklogId, AxError> {
164 let id = self.mint()?;
165 self.enrol(
166 id,
167 Member {
168 scope: scope.clone(),
169 what,
170 body: Body::Run(RunState::Going),
171 },
172 )?;
173 Ok(id)
174 }
175
176 /// Whether a halt has reached this member. A run asks at each safe
177 /// point and answers `true` with `Interrupt::Cancel`; a member that
178 /// has already left the table is not stopping, it is gone.
179 ///
180 /// # Errors
181 /// `E_STORAGE_FATAL` when the table cannot be reached.
182 pub fn stopping(&self, id: BacklogId) -> Result<bool, AxError> {
183 let table = self.hold()?;
184 Ok(table
185 .members
186 .get(&id)
187 .is_some_and(|member| matches!(member.body, Body::Run(RunState::Stopping))))
188 }
189
190 /// Takes a run member out once it has frozen. A run that stayed
191 /// would be reported standing after its own `run_frozen` line.
192 ///
193 /// # Errors
194 /// `E_STORAGE_FATAL` when the table cannot be reached.
195 pub fn leave(&self, id: BacklogId) -> Result<(), AxError> {
196 let mut table = self.hold()?;
197 table.members.remove(&id);
198 Ok(())
199 }
200
201 /// Stops every member inside a scope, or every member in the city
202 /// when no scope is named. Returns how many were reached.
203 ///
204 /// A member is stopped where it stands, and the run that
205 /// started it comes back to its next boundary rather than staying
206 /// inside a system call. A run member is marked rather than killed:
207 /// it comes back to its own next safe point and cancels there.
208 ///
209 /// # Errors
210 /// `E_STORAGE_FATAL` when the table cannot be reached, which means
211 /// a thread died holding it.
212 pub fn halt(&self, scope: Option<&Address>) -> Result<usize, AxError> {
213 let mut table = self.hold()?;
214 let mut reached = 0usize;
215 for member in table.members.values_mut() {
216 let covered = scope.is_none_or(|within| member.scope.is_within(within));
217 if !covered {
218 continue;
219 }
220 let stopped = match &mut member.body {
221 Body::Command { child, .. } => child.kill().is_ok(),
222 Body::Run(state) => {
223 *state = RunState::Stopping;
224 true
225 }
226 };
227 if stopped {
228 reached = reached.saturating_add(1);
229 }
230 }
231 Ok(reached)
232 }
233
234 /// Takes every background member of `owner` that has stopped, in
235 /// table order, and lets go of every stopped member whose run has
236 /// ended.
237 ///
238 /// A member is reported once and then forgotten: this is what a
239 /// caller appends to the tail of its next tool result, and a result
240 /// delivered twice would read as the command having run twice. A
241 /// member another run started is never reported here, because that
242 /// tail goes into this run's model and onto its lines of the ledger.
243 ///
244 /// # Errors
245 /// `E_STORAGE_FATAL` when the table cannot be reached.
246 pub fn harvest(&self, owner: RunId) -> Result<Vec<Finished>, AxError> {
247 let mut table = self.hold()?;
248 let mut done = Vec::new();
249 let mut spent = Vec::new();
250 let mut live = Vec::new();
251 for (id, member) in &mut table.members {
252 let Body::Command {
253 child,
254 dir,
255 claim,
256 tail,
257 } = &mut member.body
258 else {
259 continue;
260 };
261 match claim {
262 Claim::Run(run) if *run == owner => {}
263 Claim::Nobody => {}
264 Claim::Window(_) | Claim::Run(_) => continue,
265 }
266 let Some(exit) = Exit::polled(child) else {
267 if let (Claim::Run(_), Some(_)) = (claim, &self.sink) {
268 live.extend(tail.take(dir, (owner, *id), self.window.read_per_poll()));
269 }
270 continue;
271 };
272 spent.push(*id);
273 if let Claim::Run(_) = claim {
274 let (stdout, stderr) = collect(dir);
275 done.push(Finished {
276 id: *id,
277 what: member.what.clone(),
278 exit,
279 stdout,
280 stderr,
281 });
282 } else {
283 // Output owed to nobody is not read at all. A directory
284 // that will not go costs disk, never the harvest.
285 drop(std::fs::remove_dir_all(dir));
286 }
287 }
288 for id in spent {
289 table.members.remove(&id);
290 }
291 drop(table);
292 if let Some(sink) = &self.sink {
293 sink.deliver(live);
294 }
295 Ok(done)
296 }
297
298 /// Terminates every background command `owner` started, marks each
299 /// as owed to nobody, and returns how many there were. The run has
300 /// ended, so no later tool result of its own can carry them; the
301 /// next harvest by any run lets their process handles and files go
302 /// without reading their output to anyone.
303 ///
304 /// Infallible by construction: lowering a claim and killing a
305 /// process hold on any state of the table, so a table a dead thread
306 /// left locked is still reached, while every other call keeps
307 /// answering `E_STORAGE_FATAL`. The caller is a drop, which has
308 /// nobody to hand a failure to.
309 pub fn release(&self, owner: RunId) -> usize {
310 let mut table = self
311 .table
312 .lock()
313 .unwrap_or_else(std::sync::PoisonError::into_inner);
314 table.jobs.forget(owner);
315 let mut released = 0usize;
316 for member in table.members.values_mut() {
317 if let Body::Command { claim, child, .. } = &mut member.body
318 && *claim == Claim::Run(owner)
319 {
320 *claim = Claim::Nobody;
321 // `Child::kill` answers `Ok` for a child that already
322 // exited, so an error here is a kill the system refused.
323 // The member is still owed to nobody and the next harvest
324 // reaps it once it stops; the caller is a drop with
325 // nobody to hand the refusal to.
326 drop(child.kill());
327 released = released.saturating_add(1);
328 }
329 }
330 released
331 }
332
333 /// What is still running at or under one address.
334 ///
335 /// # Errors
336 /// `E_STORAGE_FATAL` when the table cannot be reached.
337 pub fn standing(&self, scope: &Address) -> Result<Vec<Standing>, AxError> {
338 let table = self.hold()?;
339 Ok(table
340 .members
341 .iter()
342 .filter(|(_, member)| member.scope.is_within(scope))
343 .map(|(id, member)| Standing {
344 id: *id,
345 scope: member.scope.clone(),
346 what: member.what.clone(),
347 kind: member.body.kind(),
348 })
349 .collect())
350 }
351
352 fn hold(&self) -> Result<std::sync::MutexGuard<'_, Table>, AxError> {
353 self.table.lock().map_err(|_| {
354 AxError::failure(
355 AxCode::StorageFatal,
356 "reach the backlog",
357 "the table was left locked by a thread that died",
358 )
359 .with_recovery("restart this city; what was running is reported by `sprawling resume`")
360 })
361 }
362
363 fn mint(&self) -> Result<BacklogId, AxError> {
364 let mut table = self.hold()?;
365 table.next = table.next.saturating_add(1);
366 Ok(BacklogId(table.next))
367 }
368}
369
370#[cfg(test)]
371mod tests;
372
373mod cgroup;
374mod config;
375mod jobs;
376mod member;
377mod report;
378mod scratch;
379mod tail;
380pub mod waiting;
381pub use cgroup::{PlatformShares, platform_shares};
382pub use jobs::{RunProcesses, Shares};
383use member::{Body, Claim, Member, RunState, collect, storage};
384pub use report::{BacklogKind, Finished, Standing, Started};
385use scratch::Scratch;
386use tail::Tail;
387pub use tail::{Chunk, Sink, Stream};
388pub use waiting::{Exit, PollBudget, Unseen};