Skip to main content

runtime/backlog/
jobs.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//! Which processes belong to which run (`crates/runtime/spec/Tools/Exec.lean` ยง8-13-3).
7//!
8//! A dispatched `cargo test` spends its memory in the `rustc` and test
9//! processes it starts, not in `cargo`. On Windows every run therefore
10//! gets one anonymous Job Object, and each command the backlog starts
11//! for that run joins it the moment the command enters the table; the
12//! processes a member of a job starts join the same job by themselves,
13//! so the job's process list is the run's whole process tree. The job
14//! is not kill-on-close: dropping it at `release` stops nothing, because
15//! stopping belongs to `release` and `halt` alone.
16//!
17//! Each job is made with the shares the backlog was given (D29 in the
18//! same part): a CPU weight, the same for every run, so a run whose build
19//! starts sixteen compilers takes one share of the processors and not
20//! sixteen, and with it, when asked, a limit on the memory the run's
21//! processes commit together.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::num::NonZeroU64;
25
26use kernel::{AxError, RunId};
27
28use super::{Backlog, BacklogId, Body, Claim, Member};
29
30/// The processes one run has in the backlog right now.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RunProcesses {
33    /// Each command's own pid, and every process its run's job holds.
34    /// A command that ended but was not yet harvested is still listed;
35    /// a reader finds no counters behind its pid.
36    pub pids: BTreeSet<u32>,
37    /// How many of the run's commands were read as themselves only,
38    /// without the processes they started: on Windows the ones that
39    /// could not join the job (all of them when the job's list could
40    /// not be read), and every one of them elsewhere, where there is
41    /// no Job Object.
42    pub unfollowed: u32,
43    /// The shares the run's processes hold right now: the ones the
44    /// backlog asked for when the platform gave them, otherwise `Unset`.
45    pub share: Shares,
46}
47
48/// How a run's processes share this machine with the other runs'
49/// processes (D29). The backlog is given the shares to ask for by the
50/// assembly, which takes them from the person's `[core] placement`
51/// (`crates/sprawling/spec/Serving/Placement.lean` D47); this crate reads
52/// no setting.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub enum Shares {
55    /// Nothing set: the processes compete thread by thread. Also what a
56    /// run reads as when the platform has no shares or refused them.
57    #[default]
58    Unset,
59    /// Every run's processes hold the same CPU weight.
60    Cpu,
61    /// The same CPU weight, and all of one run's processes together
62    /// commit at most `limit` bytes.
63    CpuAndMemory {
64        /// The bytes the run's processes may commit together.
65        limit: NonZeroU64,
66    },
67}
68
69/// Each run's job, and how many of its commands did not get into it.
70///
71/// On Linux the same work is done by a child cgroup per run
72/// ([`cgroup`](super::cgroup), D29): its parent is this process's own
73/// cgroup, and its reads and writes are plain files.
74#[derive(Default)]
75pub(super) struct Jobs {
76    #[cfg(windows)]
77    runs: BTreeMap<RunId, RunJob>,
78    #[cfg(not(windows))]
79    cgroups: super::cgroup::Cgroups,
80}
81
82impl Backlog {
83    /// The processes of every run that has a command in the table.
84    /// A command released to nobody belongs to no run.
85    ///
86    /// # Errors
87    /// `E_STORAGE_FATAL` when the table cannot be reached.
88    pub fn processes(&self) -> Result<BTreeMap<RunId, RunProcesses>, AxError> {
89        let table = self.hold()?;
90        let mut runs: BTreeMap<RunId, RunProcesses> = BTreeMap::new();
91        for member in table.members.values() {
92            if let Body::Command {
93                child,
94                claim: Claim::Window(owner) | Claim::Run(owner),
95                ..
96            } = &member.body
97            {
98                runs.entry(*owner).or_default().pids.insert(child.id());
99            }
100        }
101        table.jobs.follow(&mut runs);
102        Ok(runs)
103    }
104
105    /// Enters a member into the table, and a command into its run's job.
106    pub(super) fn enrol(&self, id: BacklogId, member: Member) -> Result<(), AxError> {
107        let mut table = self.hold()?;
108        table.jobs.enter(&member, self.shares);
109        table.members.insert(id, member);
110        Ok(())
111    }
112}
113
114impl Jobs {
115    /// Lets the run's job go; its processes keep running.
116    #[cfg(windows)]
117    pub(super) fn forget(&mut self, owner: RunId) {
118        self.runs.remove(&owner);
119    }
120
121    #[cfg(not(windows))]
122    pub(super) fn forget(&mut self, owner: RunId) {
123        self.cgroups.forget(owner);
124    }
125
126    #[cfg(windows)]
127    fn enter(&mut self, member: &Member, shares: Shares) {
128        let Body::Command {
129            child,
130            claim: Claim::Window(owner) | Claim::Run(owner),
131            ..
132        } = &member.body
133        else {
134            return;
135        };
136        let run = self.runs.entry(*owner).or_default();
137        if run.join(child, shares).is_none() {
138            run.unjoined = run.unjoined.saturating_add(1);
139        }
140    }
141
142    #[cfg(not(windows))]
143    fn enter(&mut self, member: &Member, shares: Shares) {
144        let Body::Command {
145            child,
146            claim: Claim::Window(owner) | Claim::Run(owner),
147            ..
148        } = &member.body
149        else {
150            return;
151        };
152        self.cgroups.enter(*owner, child.id(), shares);
153    }
154
155    #[cfg(windows)]
156    fn follow(&self, readings: &mut BTreeMap<RunId, RunProcesses>) {
157        for (owner, run) in &self.runs {
158            let reading = readings.entry(*owner).or_default();
159            let listed = run.job.as_ref().map(win32job::Job::query_process_id_list);
160            if let Some(Ok(pids)) = listed {
161                // A Windows pid is a DWORD; the list only widens it.
162                reading
163                    .pids
164                    .extend(pids.into_iter().filter_map(|pid| u32::try_from(pid).ok()));
165                reading.unfollowed = reading.unfollowed.saturating_add(run.unjoined);
166                reading.share = run.share;
167            } else {
168                reading.unfollowed = u32::try_from(reading.pids.len()).unwrap_or(u32::MAX);
169            }
170        }
171    }
172
173    #[cfg(not(windows))]
174    fn follow(&self, readings: &mut BTreeMap<RunId, RunProcesses>) {
175        for (owner, reading) in readings.iter_mut() {
176            reading.unfollowed = u32::try_from(reading.pids.len()).unwrap_or(u32::MAX);
177            reading.share = self.cgroups.held(*owner);
178        }
179    }
180}
181
182/// The weight every run's job holds, from 1 to 9: the same for every
183/// run, so the runs share the processors evenly among themselves.
184#[cfg(windows)]
185const RUN_CPU_WEIGHT: u32 = 5;
186
187/// One run's job, created when its first command joins.
188#[cfg(windows)]
189#[derive(Default)]
190struct RunJob {
191    job: Option<win32job::Job>,
192    unjoined: u32,
193    share: Shares,
194}
195
196#[cfg(windows)]
197impl RunJob {
198    /// Puts `child` into this run's job, creating the job on first use
199    /// with the shares asked for. `None` when the job cannot be made or
200    /// the process cannot join it.
201    fn join(&mut self, child: &std::process::Child, shares: Shares) -> Option<()> {
202        use std::os::windows::io::AsRawHandle;
203        let handle = isize::try_from(child.as_raw_handle().addr()).ok()?;
204        let job = match self.job.take() {
205            Some(job) => job,
206            None => {
207                let job = win32job::Job::create().ok()?;
208                self.share = given(&job, shares);
209                job
210            }
211        };
212        let joined = job.assign_process(handle).ok();
213        self.job = Some(job);
214        joined
215    }
216}
217
218/// The shares `job` takes of those `asked`. A job that refuses them still
219/// follows the run's processes, and the run is read as unshared; a limit
220/// past this process's address space limits nothing, so the job takes
221/// the weight alone (D29).
222#[cfg(windows)]
223fn given(job: &win32job::Job, asked: Shares) -> Shares {
224    let memory = match asked {
225        Shares::Unset => return Shares::Unset,
226        Shares::Cpu => 0,
227        Shares::CpuAndMemory { limit } => match usize::try_from(limit.get()) {
228            Ok(bytes) => bytes,
229            Err(_beyond) => return weigh(job, 0, Shares::Cpu),
230        },
231    };
232    weigh(job, memory, asked)
233}
234
235/// Sets the run weight and `memory` (none when zero) on `job`: `held`
236/// when the platform takes them, `Unset` when it refuses.
237#[cfg(windows)]
238fn weigh(job: &win32job::Job, memory: usize, held: Shares) -> Shares {
239    match desktop_ffi::cpu::job_share(job.handle(), RUN_CPU_WEIGHT, memory) {
240        Ok(()) => held,
241        Err(_refused) => Shares::Unset,
242    }
243}