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}