runtime/backlog/waiting.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//! How long a caller waits for a child, and how that child's ending is
7//! read.
8//!
9//! Both facts decide what a caller writes down. The window decides
10//! whether a command comes back as `Settled` or as `Backgrounded`, and
11//! those two answers carry different payloads; the ending decides what
12//! the `exec` result says happened. Neither may be spelled twice, so
13//! both live here (`crates/runtime/spec/Backlog.lean` §8-28-1).
14
15use std::process::ExitStatus;
16use std::time::Duration;
17
18use kernel::AxError;
19
20use super::ceiling::Ceiling;
21use super::tail::Tail;
22use super::{BacklogId, Body, Claim, Member, Table};
23
24/// The rate a watched command's output is read at, 64 KiB a second:
25/// several times what a person reads on a page, and small enough that a
26/// flooding child costs each poll a bounded copy (`crates/runtime/spec/Backlog.lean` §8-28-3).
27const READ_BYTES_PER_MS: u64 = 64;
28
29/// The short window a caller blocks for, counted in polls.
30///
31/// Injected rather than read from a constant where the waiting happens.
32/// A caller that cannot choose the window has to spend the production
33/// one to observe either answer, so the test that proves a command is
34/// handed to the background costs ten seconds - and a suite that cannot
35/// afford that instead proves nothing about the shape it ships.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct PollBudget {
38 polls: u32,
39 interval_ms: u64,
40}
41
42impl PollBudget {
43 /// What a person gets: 500 polls of 20 ms, which is ten seconds.
44 pub const DEFAULT: PollBudget = PollBudget {
45 polls: 500,
46 interval_ms: 20,
47 };
48
49 /// A window of this many polls, waiting this long between them.
50 #[must_use]
51 pub fn new(polls: u32, interval_ms: u64) -> PollBudget {
52 PollBudget { polls, interval_ms }
53 }
54
55 pub(super) fn polls(self) -> u32 {
56 self.polls
57 }
58
59 pub(super) fn interval(self) -> Duration {
60 Duration::from_millis(self.interval_ms)
61 }
62
63 /// How many bytes of a running command's output one poll reads:
64 /// proportional to the interval, so a longer interval reads more per
65 /// visit and the rate a page is fed stays the same.
66 pub(super) fn read_per_poll(self) -> usize {
67 usize::try_from(self.interval_ms.saturating_mul(READ_BYTES_PER_MS)).unwrap_or(usize::MAX)
68 }
69}
70
71impl Default for PollBudget {
72 fn default() -> PollBudget {
73 PollBudget::DEFAULT
74 }
75}
76
77/// How a child process stopped.
78///
79/// Three answers rather than one number: one integer cannot tell "it
80/// returned minus one", "a signal stopped it" and "this city never found
81/// out" apart, and a program that really does return minus one is
82/// ordinary.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Exit {
85 /// It ran to the end and returned this code.
86 Ended { code: i32 },
87 /// A signal stopped it, so there is no code to return. This is what
88 /// `halt` leaves behind on a Unix host.
89 Signalled,
90 /// This city cannot say how it stopped.
91 Unknown { why: Unseen },
92}
93
94impl Exit {
95 /// Reads an ending off the status the operating system reported.
96 pub(super) fn of(status: &ExitStatus) -> Exit {
97 match status.code() {
98 Some(code) => Exit::Ended { code },
99 None => Exit::Signalled,
100 }
101 }
102
103 /// Whether a child has stopped, and how, without waiting for it.
104 ///
105 /// A host that will not answer is not a child that is still
106 /// running: the ending is reported as unread, so the member stops
107 /// being polled for ever.
108 pub(super) fn polled(child: &mut std::process::Child) -> Option<Exit> {
109 match child.try_wait() {
110 Ok(Some(status)) => Some(Exit::of(&status)),
111 Ok(None) => None,
112 Err(_) => Some(Exit::Unknown {
113 why: Unseen::WaitRefused,
114 }),
115 }
116 }
117
118 /// What this ending is called wherever it is written down.
119 #[must_use]
120 pub fn as_str(self) -> &'static str {
121 match self {
122 Exit::Ended { .. } => "ended",
123 Exit::Signalled => "signalled",
124 Exit::Unknown { .. } => "unknown",
125 }
126 }
127}
128
129/// Why this city cannot say how a child stopped.
130///
131/// Two situations, because the caller is owed different things by them:
132/// a member that left the table was never waited on at all, while a wait
133/// that the host refused happened and gave no answer.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum Unseen {
136 /// The member was gone from the table before it could be waited on.
137 LeftTheTable,
138 /// The host refused to report the child's state.
139 WaitRefused,
140}
141
142impl Unseen {
143 /// The sentence a reader of a tool result is given.
144 #[must_use]
145 pub fn sentence(self) -> &'static str {
146 match self {
147 Unseen::LeftTheTable => {
148 "this command left the table before anything waited on it, so its \
149 ending was never read"
150 }
151 Unseen::WaitRefused => {
152 "this machine refused to report the child's state, so its ending is \
153 not known"
154 }
155 }
156 }
157}
158
159impl super::Backlog {
160 /// Whether this member has stopped, and how the memory ceiling fared
161 /// while it ran. Removing it here is what keeps
162 /// [`super::Backlog::harvest`] from reporting a result its own caller is
163 /// about to return.
164 pub(super) fn settle(&self, id: BacklogId) -> Result<Option<(Exit, Option<Ceiling>)>, AxError> {
165 let mut table = self.hold()?;
166 let Table { members, jobs, .. } = &mut *table;
167 let Some(Member {
168 body:
169 Body::Command {
170 child,
171 claim,
172 ceiling,
173 ..
174 },
175 ..
176 }) = members.get_mut(&id)
177 else {
178 return Ok(Some((
179 Exit::Unknown {
180 why: Unseen::LeftTheTable,
181 },
182 None,
183 )));
184 };
185 let stopped = match child.poll() {
186 Ok(stopped) => stopped,
187 Err(err) => {
188 if let Claim::Window(owner) = *claim {
189 *claim = Claim::Run(owner);
190 }
191 return Err(err);
192 }
193 };
194 let Some(exit) = stopped else {
195 return Ok(None);
196 };
197 let reported = match *claim {
198 Claim::Window(owner) | Claim::Run(owner) => jobs.ceiling(owner, *ceiling),
199 Claim::Nobody => None,
200 };
201 members.remove(&id);
202 Ok(Some((exit, reported)))
203 }
204
205 /// Past the window: the command is owed to its run's harvest, which
206 /// reads its output on from where the window's `read` stopped.
207 pub(super) fn hand_over(&self, id: BacklogId, read: Tail) -> Result<(), AxError> {
208 let mut table = self.hold()?;
209 if let Some(Member {
210 body: Body::Command { claim, tail, .. },
211 ..
212 }) = table.members.get_mut(&id)
213 && let Claim::Window(owner) = *claim
214 {
215 *claim = Claim::Run(owner);
216 *tail = read;
217 }
218 Ok(())
219 }
220}
221
222impl super::Backlog {
223 pub(super) fn watch(
224 &self,
225 id: BacklogId,
226 owner: kernel::RunId,
227 what: String,
228 dir: std::path::PathBuf,
229 ) -> Result<super::Started, AxError> {
230 let mut tail = Tail::default();
231 for _ in 0..self.window.polls() {
232 if let Some((exit, ceiling)) = self.settle(id)? {
233 let (stdout, stderr) = super::collect(&dir);
234 return Ok(super::Started::Settled {
235 exit,
236 stdout,
237 stderr,
238 ceiling,
239 });
240 }
241 if let Some(sink) = &self.sink {
242 sink.deliver(tail.take(&dir, (owner, id), self.window.read_per_poll()));
243 }
244 std::thread::sleep(self.window.interval());
245 }
246 self.hand_over(id, tail)?;
247 Ok(super::Started::Backgrounded { id, what })
248 }
249}
250
251#[cfg(test)]
252#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
253mod tests {
254 use super::*;
255
256 /// The three endings are three spellings, and the one that carries a
257 /// number is the only one that carries a number.
258 #[test]
259 fn every_ending_says_which_of_the_three_it_is() {
260 assert_eq!(Exit::Ended { code: -1 }.as_str(), "ended");
261 assert_eq!(Exit::Signalled.as_str(), "signalled");
262 assert_eq!(
263 Exit::Unknown {
264 why: Unseen::WaitRefused
265 }
266 .as_str(),
267 "unknown"
268 );
269 assert_ne!(
270 Exit::Ended { code: -1 },
271 Exit::Signalled,
272 "a program that returns minus one has not been signalled"
273 );
274 }
275
276 /// The window a person gets is the one the SPEC states.
277 #[test]
278 fn the_default_window_is_ten_seconds_of_short_polls() {
279 let budget = PollBudget::DEFAULT;
280 assert_eq!(budget.polls(), 500);
281 assert_eq!(budget.interval(), Duration::from_millis(20));
282 }
283}