Skip to main content

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}