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