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::{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}