Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-worker/src/process_table.rs
Line
Count
Source
1
// Copyright 2026 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
//! The host's processes as the action resource sampler sees them: each
16
//! process's parent and process group, the CPU time it has used, and the
17
//! memory it holds.
18
//!
19
//! Linux reads `/proc`, macOS asks `libproc`, and Windows takes a Toolhelp
20
//! snapshot. Windows has no process groups, so there every process is a
21
//! group of its own: an action's membership follows parent links, and the
22
//! groups remembered from earlier samples (see
23
//! [`action_membership`](crate::running_actions_manager::action_membership))
24
//! keep a process whose parent has exited, since Windows never reparents.
25
26
use core::time::Duration;
27
use std::sync::Arc;
28
29
use nativelink_util::spawn_blocking;
30
use tokio::time::Instant;
31
32
use crate::running_actions_manager::ProcEntry;
33
34
/// Every process with the CPU ticks it has used, as [`live_process_table`]
35
/// returns it.
36
pub type ProcessTable = Arc<[(ProcEntry, Option<u64>)]>;
37
38
/// A [`live_process_table`] snapshot shared by every action sampler on this
39
/// worker.
40
///
41
/// Each running action samples every `RESOURCE_USAGE_SAMPLE_INTERVAL`, and
42
/// finding its members means reading every process on the host. Scanning
43
/// once per action makes the cost (running actions) x (host processes) per
44
/// interval, which on a busy shared host saturates dozens of cores. Samplers
45
/// instead reuse a snapshot younger than `max_age`, one interval, so the
46
/// host is scanned at most once per interval however many actions run.
47
/// Member CPU time is still read fresh on every sample, and member memory
48
/// as [`ExactMemoryGate`](crate::running_actions_manager::ExactMemoryGate)
49
/// decides; only
50
/// membership can lag, by at most one interval, as it already could between
51
/// two samples.
52
/// Kills keep their own live scan
53
/// ([`action_membership_live`](crate::running_actions_manager::action_membership_live)).
54
#[derive(Debug)]
55
pub struct ProcessTableCache {
56
    max_age: Duration,
57
    snapshot: tokio::sync::Mutex<Option<(Instant, ProcessTable)>>,
58
}
59
60
impl ProcessTableCache {
61
85
    pub const fn new(max_age: Duration) -> Self {
62
85
        Self {
63
85
            max_age,
64
85
            snapshot: tokio::sync::Mutex::const_new(None),
65
85
        }
66
85
    }
67
68
    /// Returns a snapshot younger than `max_age`. Callers that arrive while
69
    /// a scan is running wait for it rather than starting their own.
70
171
    pub async fn current(&self) -> ProcessTable {
71
171
        let mut snapshot = self.snapshot.lock().await;
72
171
        if let Some((
taken_at126
,
table126
)) = snapshot.as_ref()
73
126
            && taken_at.elapsed() < self.max_age
74
        {
75
63
            return table.clone();
76
108
        }
77
        // The scan is thousands of blocking reads; keep it off the async workers.
78
108
        let table: ProcessTable = spawn_blocking!("process_table_scan", live_process_table)
79
108
            .await
80
108
            .map_or_else(|_| 
Arc::from0
(
Vec::new0
()), Arc::from);
81
108
        *snapshot = Some((Instant::now(), table.clone()));
82
108
        table
83
171
    }
84
}
85
86
/// Every process on the host, with the CPU ticks each has used so far (see
87
/// [`ticks_to_millis`]). Blocking; a process that cannot be read is left
88
/// out, and its ticks are `None` when only they cannot be read.
89
171
pub fn live_process_table() -> Vec<(ProcEntry, Option<u64>)> {
90
171
    platform::live_process_table()
91
171
}
92
93
/// The memory `pid` holds, in KiB, as close as the platform gets to the
94
/// process's share of its resident pages: `Pss` on Linux, the physical
95
/// footprint on macOS, the private commit charge on Windows. `None` when
96
/// the process cannot be read.
97
130
pub fn read_process_memory_kb(pid: u32) -> Option<u64> {
98
130
    platform::read_process_memory_kb(pid)
99
130
}
100
101
/// Whether [`read_process_memory_kb`] costs more than reading a counter.
102
/// Only on Linux: `smaps_rollup` walks the page tables of every mapping,
103
/// about 3 ms per resident GiB, where macOS and Windows read figures the
104
/// kernel keeps. Only there does the sampler read
105
/// [`read_process_memory_bound_kb`] first to skip the exact figure.
106
pub const EXACT_MEMORY_READ_WALKS_PAGES: bool = cfg!(target_os = "linux");
107
108
/// A figure for `pid`, in KiB, that [`read_process_memory_kb`] never
109
/// exceeds, read from a counter. On Linux `RSS` from `statm`, which counts
110
/// every resident page in full where `Pss` counts a shared one in part;
111
/// elsewhere the exact figure, itself a counter read. `None` when the
112
/// process cannot be read.
113
301
pub fn read_process_memory_bound_kb(pid: u32) -> Option<u64> {
114
    #[cfg(target_os = "linux")]
115
301
    return platform::read_process_resident_kb(pid);
116
    #[cfg(not(target_os = "linux"))]
117
    return platform::read_process_memory_kb(pid);
118
301
}
119
120
/// The CPU ticks `pid` has used so far, read now rather than from a
121
/// snapshot (see [`ticks_to_millis`]). `None` when the process cannot be
122
/// read.
123
300
pub fn read_process_cpu_ticks(pid: u32) -> Option<u64> {
124
300
    platform::read_process_cpu_ticks(pid)
125
300
}
126
127
/// Converts the CPU ticks of [`live_process_table`] into milliseconds.
128
// Only the Windows conversion, a division, could be const.
129
#[cfg_attr(target_family = "windows", allow(clippy::missing_const_for_fn))]
130
52
pub fn ticks_to_millis(ticks: u64) -> u64 {
131
52
    platform::ticks_to_millis(ticks)
132
52
}
133
134
/// Whether `pid` is still a process on the host: on unix including one that
135
/// has exited but not yet been waited for, on Windows only a running one.
136
16
pub fn process_exists(pid: u32) -> bool {
137
16
    platform::process_exists(pid)
138
16
}
139
140
/// The worker's own process group, excluded from every action's members.
141
/// On Windows, which has no process groups, the worker's own pid.
142
#[cfg(target_family = "unix")]
143
219
pub fn worker_pgid() -> u32 {
144
    // SAFETY: `getpgrp` takes no arguments and cannot fail.
145
219
    u32::try_from(unsafe { libc::getpgrp() }).unwrap_or(0)
146
219
}
147
148
#[cfg(target_family = "windows")]
149
pub fn worker_pgid() -> u32 {
150
    std::process::id()
151
}
152
153
#[cfg(target_os = "linux")]
154
mod platform {
155
    use std::path::Path;
156
157
    use crate::running_actions_manager::{
158
        ProcEntry, parse_cpu_ticks_from_stat, parse_kb_field, parse_links_from_stat,
159
    };
160
161
171
    pub(super) fn live_process_table() -> Vec<(ProcEntry, Option<u64>)> {
162
171
        let Ok(entries) = std::fs::read_dir("/proc") else {
163
0
            return Vec::new();
164
        };
165
171
        entries
166
171
            .flatten()
167
11.5k
            .
filter_map171
(|entry| {
168
11.5k
                let 
pid961
= entry.file_name().to_string_lossy().parse::<u32>().ok()
?10.6k
;
169
961
                let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()
?0
;
170
961
                let (parent, group) = parse_links_from_stat(&stat)
?0
;
171
961
                Some((
172
961
                    ProcEntry {
173
961
                        pid,
174
961
                        ppid: parent,
175
961
                        pgrp: group,
176
961
                    },
177
961
                    parse_cpu_ticks_from_stat(&stat),
178
961
                ))
179
11.5k
            })
180
171
            .collect()
181
171
    }
182
183
    /// `Pss` from `smaps_rollup` divides a page shared by N processes N
184
    /// ways, so summed over the group it counts a forked parent's pages,
185
    /// shared libraries and mapped inputs once. `VmRSS` counts them once per
186
    /// process, and a test runner that forks eight children would sample at
187
    /// several times what the cgroup charges. Falls back to `VmRSS` where
188
    /// `smaps_rollup` cannot be read (kernels before 4.14).
189
130
    pub(super) fn read_process_memory_kb(pid: u32) -> Option<u64> {
190
130
        if let Ok(
rollup103
) = std::fs::read_to_string(format!("/proc/{pid}/smaps_rollup"))
191
103
            && let Some(pss_kb) = parse_kb_field(&rollup, "Pss:")
192
        {
193
103
            return Some(pss_kb);
194
27
        }
195
27
        let 
status13
= std::fs::read_to_string(format!("/proc/{pid}/status")).ok()
?14
;
196
13
        parse_kb_field(&status, "VmRSS:")
197
130
    }
198
199
    /// The resident pages `statm` reports, in KiB. Kernels 6.2 to 6.15
200
    /// read these counters without folding in each CPU's pending updates,
201
    /// so there the figure can trail by up to a batch of pages per CPU,
202
    /// tens of MiB on a large host.
203
301
    pub(super) fn read_process_resident_kb(pid: u32) -> Option<u64> {
204
301
        let 
statm214
= std::fs::read_to_string(format!("/proc/{pid}/statm")).ok()
?87
;
205
214
        let pages: u64 = statm.split_whitespace().nth(1)
?0
.parse().ok()
?0
;
206
        // SAFETY: `sysconf` takes an int and returns a long, with no memory
207
        // safety considerations.
208
214
        let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
209
214
        let page_kb = u64::try_from(page_size).ok().filter(|&size| size > 0)
?0
/ 1024;
210
214
        Some(pages.saturating_mul(page_kb))
211
301
    }
212
213
300
    pub(super) fn read_process_cpu_ticks(pid: u32) -> Option<u64> {
214
300
        
parse_cpu_ticks_from_stat214
(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()
?86
)
215
300
    }
216
217
    /// Ticks are clock ticks from `/proc`. `_SC_CLK_TCK` is 100 on every
218
    /// Linux we run on, but it is queryable so ask rather than assume, and
219
    /// fall back to 100 if the query fails.
220
52
    pub(super) fn ticks_to_millis(ticks: u64) -> u64 {
221
        // SAFETY: `sysconf` takes an int and returns a long, with no memory
222
        // safety considerations.
223
52
        let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
224
52
        let hz = if hz > 0 { hz as u64 } else { 
1000
};
225
52
        ticks.saturating_mul(1_000) / hz
226
52
    }
227
228
16
    pub(super) fn process_exists(pid: u32) -> bool {
229
16
        Path::new(&format!("/proc/{pid}")).exists()
230
16
    }
231
}
232
233
#[cfg(target_os = "macos")]
234
mod platform {
235
    use core::ffi::c_int;
236
    use core::mem::size_of;
237
238
    use crate::running_actions_manager::ProcEntry;
239
240
    // libc deprecates its binding in favour of the mach2 crate; one call does
241
    // not warrant that dependency.
242
    #[repr(C)]
243
    struct MachTimebaseInfo {
244
        numerator: u32,
245
        denominator: u32,
246
    }
247
248
    unsafe extern "C" {
249
        fn mach_timebase_info(info: *mut MachTimebaseInfo) -> c_int;
250
    }
251
252
    pub(super) fn live_process_table() -> Vec<(ProcEntry, Option<u64>)> {
253
        // SAFETY: a null buffer asks only for the number of pids.
254
        let count = unsafe { libc::proc_listallpids(core::ptr::null_mut(), 0) };
255
        let Ok(count) = usize::try_from(count) else {
256
            return Vec::new();
257
        };
258
        // Room for the processes that start between the two calls.
259
        let mut pids: Vec<libc::pid_t> = vec![0; count + 64];
260
        let Ok(buffer_size) = c_int::try_from(pids.len() * size_of::<libc::pid_t>()) else {
261
            return Vec::new();
262
        };
263
        // SAFETY: the buffer holds `buffer_size` bytes and outlives the call.
264
        let listed = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), buffer_size) };
265
        let Ok(listed) = usize::try_from(listed) else {
266
            return Vec::new();
267
        };
268
        pids.truncate(listed);
269
        pids.into_iter()
270
            .filter_map(|pid| {
271
                let pid = u32::try_from(pid).ok()?;
272
                let info = bsd_info(pid)?;
273
                Some((
274
                    ProcEntry {
275
                        pid,
276
                        ppid: info.pbi_ppid,
277
                        pgrp: info.pbi_pgid,
278
                    },
279
                    read_process_cpu_ticks(pid),
280
                ))
281
            })
282
            .collect()
283
    }
284
285
    /// `ri_phys_footprint` is what macOS charges the process: its private
286
    /// resident and compressed pages, not the shared pages `RSS` counts once
287
    /// per process.
288
    pub(super) fn read_process_memory_kb(pid: u32) -> Option<u64> {
289
        rusage(pid).map(|usage| usage.ri_phys_footprint / 1024)
290
    }
291
292
    pub(super) fn read_process_cpu_ticks(pid: u32) -> Option<u64> {
293
        rusage(pid).map(|usage| usage.ri_user_time.saturating_add(usage.ri_system_time))
294
    }
295
296
    /// Ticks are Mach absolute time units: nanoseconds on Intel, but not on
297
    /// Apple silicon, so convert through the timebase.
298
    pub(super) fn ticks_to_millis(ticks: u64) -> u64 {
299
        let mut timebase = MachTimebaseInfo {
300
            numerator: 0,
301
            denominator: 0,
302
        };
303
        // SAFETY: fills the struct it is given, which outlives the call.
304
        let status = unsafe { mach_timebase_info(&raw mut timebase) };
305
        let (numerator, denominator) = if status == 0 && timebase.denominator != 0 {
306
            (
307
                u128::from(timebase.numerator),
308
                u128::from(timebase.denominator),
309
            )
310
        } else {
311
            (1, 1)
312
        };
313
        let millis = u128::from(ticks) * numerator / denominator / 1_000_000;
314
        u64::try_from(millis).unwrap_or(u64::MAX)
315
    }
316
317
    pub(super) fn process_exists(pid: u32) -> bool {
318
        let Ok(pid) = libc::pid_t::try_from(pid) else {
319
            return false;
320
        };
321
        // SAFETY: signal 0 only checks that the process exists.
322
        let delivered = unsafe { libc::kill(pid, 0) } == 0;
323
        delivered || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
324
    }
325
326
    fn bsd_info(pid: u32) -> Option<libc::proc_bsdinfo> {
327
        let pid = c_int::try_from(pid).ok()?;
328
        let size = c_int::try_from(size_of::<libc::proc_bsdinfo>()).ok()?;
329
        // SAFETY: an all-zero proc_bsdinfo is valid; it is plain integers.
330
        let mut info: libc::proc_bsdinfo = unsafe { core::mem::zeroed() };
331
        // SAFETY: the buffer is a proc_bsdinfo of `size` bytes that outlives
332
        // the call.
333
        let read = unsafe {
334
            libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast(), size)
335
        };
336
        (read == size).then_some(info)
337
    }
338
339
    fn rusage(pid: u32) -> Option<libc::rusage_info_v2> {
340
        let pid = c_int::try_from(pid).ok()?;
341
        // SAFETY: an all-zero rusage_info_v2 is valid; it is plain integers.
342
        let mut usage: libc::rusage_info_v2 = unsafe { core::mem::zeroed() };
343
        // SAFETY: RUSAGE_INFO_V2 fills a rusage_info_v2, which outlives the
344
        // call.
345
        let status =
346
            unsafe { libc::proc_pid_rusage(pid, libc::RUSAGE_INFO_V2, (&raw mut usage).cast()) };
347
        (status == 0).then_some(usage)
348
    }
349
}
350
351
#[cfg(target_family = "windows")]
352
mod platform {
353
    use core::mem::size_of;
354
    use std::collections::HashMap;
355
    use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
356
357
    use windows_sys::Win32::Foundation::{FILETIME, INVALID_HANDLE_VALUE, STILL_ACTIVE};
358
    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
359
        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
360
        TH32CS_SNAPPROCESS,
361
    };
362
    use windows_sys::Win32::System::ProcessStatus::{
363
        GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
364
    };
365
    use windows_sys::Win32::System::Threading::{
366
        GetExitCodeProcess, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
367
    };
368
369
    use crate::running_actions_manager::ProcEntry;
370
371
    pub(super) fn live_process_table() -> Vec<(ProcEntry, Option<u64>)> {
372
        let links = process_links();
373
        let times: HashMap<u32, ProcessTimes> = links
374
            .iter()
375
            .filter_map(|&(pid, _)| Some((pid, process_times(pid)?)))
376
            .collect();
377
        links
378
            .into_iter()
379
            .map(|(pid, ppid)| {
380
                let own = times.get(&pid);
381
                // Windows never reparents, and reuses ids, so a parent id can
382
                // name a process that started after the child: not its parent.
383
                let ppid = match (own, times.get(&ppid)) {
384
                    (Some(own), Some(parent)) if parent.created > own.created => 0,
385
                    _ => ppid,
386
                };
387
                (
388
                    ProcEntry {
389
                        pid,
390
                        ppid,
391
                        pgrp: pid,
392
                    },
393
                    own.map(|times| times.cpu),
394
                )
395
            })
396
            .collect()
397
    }
398
399
    /// The private commit charge: memory the process committed that no
400
    /// other process shares, which is also what a job object's memory limit
401
    /// counts. The private working set would be closer to `Pss`, but
402
    /// Windows Server 2022 reports it as 0.
403
    pub(super) fn read_process_memory_kb(pid: u32) -> Option<u64> {
404
        let process = open(pid)?;
405
        let size = u32::try_from(size_of::<PROCESS_MEMORY_COUNTERS_EX>()).ok()?;
406
        let mut counters = PROCESS_MEMORY_COUNTERS_EX {
407
            cb: size,
408
            ..Default::default()
409
        };
410
        // SAFETY: the handle is open and the buffer holds `size` bytes.
411
        let ok = unsafe {
412
            GetProcessMemoryInfo(
413
                process.as_raw_handle(),
414
                (&raw mut counters).cast::<PROCESS_MEMORY_COUNTERS>(),
415
                size,
416
            )
417
        };
418
        (ok != 0).then_some(counters.PrivateUsage as u64 / 1024)
419
    }
420
421
    pub(super) fn read_process_cpu_ticks(pid: u32) -> Option<u64> {
422
        process_times(pid).map(|times| times.cpu)
423
    }
424
425
    /// Ticks are the 100 ns units of `GetProcessTimes`.
426
    pub(super) const fn ticks_to_millis(ticks: u64) -> u64 {
427
        ticks / 10_000
428
    }
429
430
    /// The process object outlives the process for as long as anyone holds
431
    /// a handle to it, which an antivirus scanner may, so ask whether it is
432
    /// still running instead.
433
    pub(super) fn process_exists(pid: u32) -> bool {
434
        let Some(process) = open(pid) else {
435
            return false;
436
        };
437
        let mut exit_code = 0;
438
        // SAFETY: the handle is open and `exit_code` outlives the call.
439
        let ok = unsafe { GetExitCodeProcess(process.as_raw_handle(), &raw mut exit_code) };
440
        ok != 0 && exit_code == STILL_ACTIVE.cast_unsigned()
441
    }
442
443
    struct ProcessTimes {
444
        created: u64,
445
        cpu: u64,
446
    }
447
448
    /// Every process's id and its parent's id, from one snapshot.
449
    fn process_links() -> Vec<(u32, u32)> {
450
        let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
451
            return Vec::new();
452
        };
453
        // SAFETY: takes no pointers; the result is checked before use.
454
        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
455
        if snapshot == INVALID_HANDLE_VALUE {
456
            return Vec::new();
457
        }
458
        // SAFETY: a valid handle this function now owns.
459
        let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) };
460
        // SAFETY: an all-zero entry is valid; it is plain integers.
461
        let mut entry: PROCESSENTRY32W = unsafe { core::mem::zeroed() };
462
        entry.dwSize = entry_size;
463
        let mut links = Vec::new();
464
        // SAFETY: the snapshot is open and `entry` has its size set.
465
        let mut more = unsafe { Process32FirstW(snapshot.as_raw_handle(), &raw mut entry) } != 0;
466
        while more {
467
            links.push((entry.th32ProcessID, entry.th32ParentProcessID));
468
            // SAFETY: as above.
469
            more = unsafe { Process32NextW(snapshot.as_raw_handle(), &raw mut entry) } != 0;
470
        }
471
        links
472
    }
473
474
    fn process_times(pid: u32) -> Option<ProcessTimes> {
475
        let process = open(pid)?;
476
        let mut created = FILETIME::default();
477
        let mut exited = FILETIME::default();
478
        let mut kernel = FILETIME::default();
479
        let mut user = FILETIME::default();
480
        // SAFETY: the handle is open and every out pointer outlives the call.
481
        let ok = unsafe {
482
            GetProcessTimes(
483
                process.as_raw_handle(),
484
                &raw mut created,
485
                &raw mut exited,
486
                &raw mut kernel,
487
                &raw mut user,
488
            )
489
        };
490
        (ok != 0).then(|| ProcessTimes {
491
            created: filetime_ticks(created),
492
            cpu: filetime_ticks(kernel).saturating_add(filetime_ticks(user)),
493
        })
494
    }
495
496
    fn filetime_ticks(time: FILETIME) -> u64 {
497
        (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)
498
    }
499
500
    fn open(pid: u32) -> Option<OwnedHandle> {
501
        // SAFETY: takes no pointers; a null result is checked before use.
502
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
503
        // SAFETY: a non-null handle from OpenProcess is open and ours to close.
504
        (!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
505
    }
506
}