From cd979f351bce98b66280ef6b4c5ba27280cd5d23 Mon Sep 17 00:00:00 2001 From: Lalit Maganti Date: Wed, 16 Sep 2026 02:22:24 +0100 Subject: [PATCH] macos: record the process tree with the kernel trace facility macOS has no unprivileged way to follow a process tree. Configuring the kernel trace facility needs root. After that, the kernel keeps letting the process that owns the session read from it. Recording therefore needs root only while it starts. Buildprof sets up the session, gives up root for good, and then runs the build, reads events and writes the trace as the person who ran it. Buildprof gets root by running itself again through sudo. It first checks whether sudo would let it in without a password. If not, it asks for one only when stdin is a terminal. Without a terminal it stops with an error, so a script or CI job never hangs. A refused password is reported as an error, not as the build's exit status. sudo resets the environment. Buildprof therefore saves its own to a file that only the person running it can read. The copy that sudo runs restores it after giving up root, so the build sees the environment it was started with. Running Buildprof under sudo directly still works, with a hint that the environment was reset. This records the process tree. It shows which process started which, what program each ran, and how each exited. The kernel also reports an exit when a process starts a new program. An exit is therefore held briefly, and anything further from that pid cancels it. Events are read every millisecond. Once the build has started, the reading thread asks to be scheduled in real time. Ordinary threads have their timers batched to save power, and during a busy build one wait in a hundred took 10 ms or more. A short-lived program could exit in that time before its command line was read. Command lines, file events and compiler traces follow. --- README.md | 29 ++- dist-workspace.toml | 2 +- docs/internals.md | 14 ++ src/args.rs | 29 +++ src/macos.rs | 577 +++++++++++++++++++++++++++++++++++++++++++ src/macos/event.rs | 31 +++ src/macos/kdebug.rs | 227 +++++++++++++++++ src/macos/recon.rs | 359 +++++++++++++++++++++++++++ src/macos/tracker.rs | 425 +++++++++++++++++++++++++++++++ src/main.rs | 49 +++- 10 files changed, 1726 insertions(+), 16 deletions(-) create mode 100644 src/macos.rs create mode 100644 src/macos/event.rs create mode 100644 src/macos/kdebug.rs create mode 100644 src/macos/recon.rs create mode 100644 src/macos/tracker.rs diff --git a/README.md b/README.md index e1cdd2e..38773a1 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ ## What is Buildprof? -Buildprof traces **every process** a Linux build launches and turns the -recording into an **interactive timeline** you can explore in the browser. Put +Buildprof traces **every process** a build launches and turns the recording +into an **interactive timeline** you can explore in the browser. Put `buildprof --` in front of your build command to get started. - **See the whole build.** Every command the build ran, however it was @@ -38,8 +38,9 @@ recording into an **interactive timeline** you can explore in the browser. Put work, as do the shell scripts, code generators and wrapper scripts they launch. -Recording requires Linux. You can explore recordings on any platform in the -[web UI](https://buildprof.lalitm.com); trace data stays in your browser. +Recording requires Linux or macOS. You can explore recordings on any platform +in the [web UI](https://buildprof.lalitm.com), and trace data never leaves your +browser. ## Try it in your browser @@ -53,7 +54,7 @@ follow the [guided tour](docs/ripgrep-tutorial.md). ### 1. Install Buildprof -On the Linux machine that runs your build: +On the machine that runs your build: ```bash curl -fsSL https://buildprof.lalitm.com/install.sh | sh @@ -72,7 +73,10 @@ buildprof -- make -j8 ``` Replace `make -j8` with your build command, such as `cargo build` or -`ninja -C out`. Bazel, Gradle and Docker hand work to a daemon, which needs +`ninja -C out`. On macOS, kernel tracing needs root, so Buildprof asks for +your password through `sudo`. It gives up root as soon as tracing starts. Your +build then runs as you, with your own environment, and you own the recording. +Bazel, Gradle and Docker hand work to a daemon, which needs [a little more care](docs/troubleshooting.md). ### 3. Explore the recording @@ -128,7 +132,7 @@ land in one timeline, and you can see how their work fits together. ## Install -Install on your Linux build machine using whichever method you prefer. +Install on your build machine using whichever method you prefer.
@@ -182,11 +186,20 @@ musl. The musl build is the one to mount into a container. ### Requirements -- **Recording needs Linux**, with a kernel and container configuration that +- **Recording on Linux** needs a kernel and container configuration that permits tracing child processes: - Docker needs `--cap-add SYS_PTRACE`. - `kernel.yama.ptrace_scope` must be below 3. - gVisor-style sandboxes cannot trace at all. +- **Recording on macOS** needs macOS 13 or newer, and root to start kernel + tracing: + - Buildprof asks for your password through `sudo` when it starts, and + gives up root as soon as tracing starts. + - It asks only when it runs in a terminal. In CI or a script it never + prompts, so `sudo` must work there without a password. GitHub's macOS + runners already allow that. + - Only one program can use kernel tracing at a time. If Instruments or a + similar tool is using it, Buildprof tells you. - **Viewing needs nothing.** Recordings open on any platform in the [web UI](https://buildprof.lalitm.com), whatever they were recorded on, with Buildprof installed or not. diff --git a/dist-workspace.toml b/dist-workspace.toml index 2a55bf9..ac0009a 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -18,7 +18,7 @@ publish-jobs = ["homebrew"] # what triggers the UI deploy, the crates.io publish, and the Linux packages # (a release published by GITHUB_TOKEN would not trigger them). allow-dirty = ["ci"] -# Recording is Linux-only; macOS builds exist for `buildprof open`. +# Recording works on Linux and macOS; other platforms can view recordings. targets = [ "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", diff --git a/docs/internals.md b/docs/internals.md index bbb5a61..76a05e0 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -5,6 +5,20 @@ creation, execution, and exit through the complete descendant tree. A seccomp filter lets it stop only for the filesystem operations it records instead of paying the cost of intercepting every system call. +On macOS, Buildprof reads the kernel's trace events instead. Starting a trace +session needs root. Once the session exists, though, the kernel keeps letting +the process that owns it read from it. Buildprof therefore holds root only +while it starts. It sets up the session and gives up root for good. Only then +does it run the build and write the trace, as the person who started it. + +Buildprof gets root by running itself again through `sudo`. It first checks +whether sudo would let it in without a password. If not, it asks for one only +when stdin is a terminal, and otherwise stops with an error. The recording then +runs with `sudo -n`, so it never stops to ask. sudo resets the +environment, so Buildprof first saves its own to a file that only you can +read. The copy that sudo runs reads that file after it has given up root, and +deletes it. + The recorder writes a Perfetto protobuf trace directly. Perfetto provides the storage format, query engine, and core timeline interactions; Buildprof adds the build-specific view on top, including process ancestry, command types, diff --git a/src/args.rs b/src/args.rs index 487ad90..5979012 100644 --- a/src/args.rs +++ b/src/args.rs @@ -56,6 +56,12 @@ struct Cli { #[usage(long, global)] no_open: bool, + /// Where a recording started without root saved its environment. Buildprof + /// passes this to the copy of itself that sudo runs. It is hidden from + /// help. + #[usage(long, value_name = "PATH", hide = true, global)] + buildprof_saved_environment: Option, + /// Seconds to wait for a browser to fetch the trace; 0 waits forever. /// [default: 600] #[usage( @@ -158,6 +164,8 @@ pub enum Args { /// `None` leaves the trace on disk and prints where to open it. handoff: Option, wait: Wait, + /// The environment to restore, when sudo started this recording. + saved_environment: Option, }, Open { source: Source, @@ -194,6 +202,7 @@ fn from_cli(cli: Cli) -> Result { file_events: !cli.no_file_events, handoff: if cli.no_open { None } else { default_handoff() }, wait, + saved_environment: cli.buildprof_saved_environment.clone(), }; Ok(match cli.action { Some(Action::Open { @@ -287,6 +296,25 @@ mod tests { wait: Wait, } + #[test] + fn the_saved_environment_flag_reaches_the_recording() { + let Args::Record { + saved_environment, + command, + .. + } = parse_strings(&[ + "buildprof", + "--buildprof-saved-environment=/tmp/saved", + "--", + "make", + ]) + else { + panic!("expected record arguments") + }; + assert_eq!(saved_environment, Some(PathBuf::from("/tmp/saved"))); + assert_eq!(command, ["make"]); + } + fn recording(args: &[&str]) -> Recording { let Args::Record { output, @@ -295,6 +323,7 @@ mod tests { file_events, handoff, wait, + .. } = parse_strings(args) else { panic!("expected record arguments") diff --git a/src/macos.rs b/src/macos.rs new file mode 100644 index 0000000..6b981e4 --- /dev/null +++ b/src/macos.rs @@ -0,0 +1,577 @@ +// Copyright 2026 The Buildprof Authors. +// SPDX-License-Identifier: Apache-2.0 + +//! Recording on macOS, through the kernel trace facility. +//! +//! macOS has no unprivileged way to follow a process tree. Configuring kernel +//! tracing needs root. After that, the kernel keeps letting the process that +//! owns the session read from it. Recording therefore needs root only while it +//! starts. +//! +//! 0. Started as yourself, save the environment and run again through sudo. +//! 1. As root, set up and enable the trace session. +//! 2. Permanently drop privileges to the person who ran the recording. +//! 3. Put back the saved environment. +//! 4. Start the build, read events and write the trace as that person. +//! +//! The build never runs as root, and it sees the environment it was started +//! with. The trace file belongs to the person who ran the recording. Nothing +//! privileged is left running. + +use self::event::Message; +use self::tracker::{Clock, TraceSink, Tracker}; +use crate::blind_spots::BlindSpots; +use crate::perfetto::Writer; +use crate::report; +use std::ffi::{CString, OsStr, OsString}; +use std::fs; +use std::io::{self, IsTerminal, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +mod event; +mod kdebug; +mod recon; +mod tracker; + +/// Size of the kernel buffer, in events. A parallel build produces a few +/// million events a second. The buffer holds about a hundred times what the +/// recorder collects in one read, so a stall in reading does not lose events. +const BUFFER_EVENTS: usize = 512 * 1024; +/// The longest wait for the kernel to fill a batch. A short wait means a +/// process's command line is still readable when its exec is seen. A longer one +/// costs less CPU. +const READ_INTERVAL: Duration = Duration::from_millis(1); +/// How often to look for the end of a build whose last exits were not seen. +const LIVENESS_INTERVAL: Duration = Duration::from_millis(250); +/// How long to keep reading after the build, to collect trailing events. +const DRAIN: Duration = Duration::from_millis(50); + +/// A trace session, owned and enabled, with privileges already dropped. +pub struct Prepared { + session: kdebug::Session, + clock: Clock, + threads: Vec<(u64, i32)>, +} + +/// Passes the saved environment's path to the copy of Buildprof that sudo runs. +/// sudo drops environment variables and open descriptors, so the path goes in a +/// hidden argument. +const SAVED_ENVIRONMENT_ARGUMENT: &str = "--buildprof-saved-environment="; + +pub enum Start { + Recording(Prepared), + /// The recording ran through sudo, and finished with this status. + Finished(ExitCode), +} + +/// Starts collecting and drops privileges. It runs before the recorder creates +/// any files, so the files belong to the person who started the recording. +pub fn prepare(saved_environment: Option<&Path>) -> io::Result { + // SAFETY: reading the process's own credentials. + if unsafe { libc::geteuid() } != 0 { + return escalate().map(Start::Finished); + } + let (uid, gid) = target_user()?; + let session = kdebug::Session::start(BUFFER_EVENTS)?; + let threads = session.thread_map()?; + let clock = Clock::now(); + let started_by_sudo = std::env::var_os("SUDO_UID").is_some(); + drop_privileges(uid, gid)?; + match saved_environment { + Some(path) => restore_saved_environment(path, uid)?, + None => { + restore_user_environment(uid); + if started_by_sudo { + hint!( + "sudo reset the environment, so the build may miss variables you set, like CC \ + or RUSTFLAGS; run {} without sudo to keep them", + report::emph("buildprof") + ); + } + } + } + Ok(Start::Recording(Prepared { + session, + clock, + threads, + })) +} + +/// Runs this recording again as root, through sudo, with the environment it +/// was started with saved for it. +fn escalate() -> io::Result { + authorize()?; + let saved = save_environment()?; + let mut argument = OsString::from(SAVED_ENVIRONMENT_ARGUMENT); + argument.push(&saved); + // authorize() already got access, so this sudo never prompts. + let child = Command::new("sudo") + .args(["-n", "--"]) + .arg(std::env::current_exe()?) + .arg(argument) + .args(std::env::args_os().skip(1)) + .spawn(); + let status = child.and_then(|mut child| { + // Ctrl-C reaches this process as well as the build. The build decides + // what it means, so this process ignores it once sudo has started. + // SAFETY: changing this process's own signal dispositions. + unsafe { + libc::signal(libc::SIGINT, libc::SIG_IGN); + libc::signal(libc::SIGQUIT, libc::SIG_IGN); + } + child.wait() + }); + // The recording deletes the file once it has read it. This removes it when + // the recording stopped before that. + let _ = fs::remove_file(&saved); + let status = status + .map_err(|error| io::Error::new(error.kind(), format!("could not run sudo: {error}")))?; + Ok(ExitCode::from(exit_status_code(status))) +} + +/// Makes sure sudo will start the recording without a password prompt. It asks +/// for a password only when stdin is a terminal. Without a terminal, sudo must +/// already allow access, or the recording fails at once. A refusal is reported +/// as an error, not as the build's exit status. +fn authorize() -> io::Result<()> { + let granted = |status: io::Result| { + status + .map(|status| status.success()) + .map_err(|error| io::Error::new(error.kind(), format!("could not run sudo: {error}"))) + }; + let already = Command::new("sudo") + .args(["-n", "-v"]) + .stderr(Stdio::null()) + .status(); + if granted(already)? { + return Ok(()); + } + if !io::stdin().is_terminal() { + return Err(io::Error::other( + "kernel tracing needs administrator access, and there is no terminal to ask for a \ + password; allow sudo without a password, or run in a terminal", + )); + } + hint!( + "kernel tracing needs administrator access; asking {} for your password", + report::emph("sudo") + ); + if !granted(Command::new("sudo").arg("-v").status())? { + return Err(io::Error::other("sudo did not grant administrator access")); + } + Ok(()) +} + +/// Writes the environment to a file that only its owner can read, in their own +/// temporary directory. +fn save_environment() -> io::Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "buildprof-environment-{}-{nonce}", + std::process::id() + )); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path)?; + file.write_all(&encode_environment(std::env::vars_os()))?; + Ok(path) +} + +/// Replaces the environment with the one saved before sudo ran. It runs after +/// privileges are dropped, so root never reads the file. +fn restore_saved_environment(path: &Path, uid: u32) -> io::Result<()> { + check_saved_environment(path, uid)?; + let contents = fs::read(path)?; + fs::remove_file(path)?; + let names: Vec = std::env::vars_os().map(|(name, _)| name).collect(); + // SAFETY: single-threaded, before the build starts. + unsafe { + for name in names { + std::env::remove_var(name); + } + for (name, value) in decode_environment(&contents) { + std::env::set_var(name, value); + } + } + Ok(()) +} + +/// The saved environment must be a plain file that its owner wrote and that +/// nobody else could have read or changed. +fn check_saved_environment(path: &Path, uid: u32) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 { + return Err(io::Error::other(format!( + "{} is not a private file of yours, so the environment was not restored", + path.display() + ))); + } + Ok(()) +} + +/// Each variable as `name=value` followed by a NUL. Neither a name nor a value +/// can contain a NUL. +fn encode_environment(variables: impl IntoIterator) -> Vec { + let mut contents = Vec::new(); + for (name, value) in variables { + contents.extend_from_slice(name.as_bytes()); + contents.push(b'='); + contents.extend_from_slice(value.as_bytes()); + contents.push(0); + } + contents +} + +fn decode_environment(contents: &[u8]) -> Vec<(OsString, OsString)> { + contents + .split(|byte| *byte == 0) + .filter_map(|entry| { + let equals = entry.iter().position(|byte| *byte == b'=')?; + Some(( + OsStr::from_bytes(&entry[..equals]).to_owned(), + OsStr::from_bytes(&entry[equals + 1..]).to_owned(), + )) + }) + .collect() +} + +pub fn record( + prepared: Prepared, + command: &[OsString], + writer: &mut Writer, + blind_spots: &mut BlindSpots, +) -> io::Result { + let Prepared { + mut session, + clock, + threads, + } = prepared; + let program = command.first().expect("validated command"); + + let mut build = Command::new(program) + .args(&command[1..]) + .spawn() + .map_err(|error| { + io::Error::new( + error.kind(), + format!("could not run {}: {error}", program.to_string_lossy()), + ) + })?; + let root_pid = build.id() as i32; + let mut collector = recon::Collector::new(root_pid, clock); + collector.seed_threads(threads); + + let mut sink = TraceSink { writer }; + let mut tracker = Tracker::new( + &mut sink, + blind_spots, + clock, + root_pid, + executable_name(program), + command + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + std::env::current_dir()?.to_string_lossy().into_owned(), + ); + + keep_up(&clock); + let mut root_status = None; + let mut last_check = Instant::now(); + let mut ended: Option = None; + loop { + collect(&mut session, &mut collector, &mut |message| { + tracker.handle(message) + })?; + if let Some(ended) = ended + && ended.elapsed() > DRAIN + { + break; + } + if ended.is_none() && last_check.elapsed() >= LIVENESS_INTERVAL { + last_check = Instant::now(); + if root_status.is_none() { + root_status = build.try_wait()?; + } + // The recording ends when the build's command and everything it + // started have exited. Events arrive after the processes act, so + // this asks the system whether each process still exists. Once the + // command has been reaped, its pid can go to an unrelated process, + // so only its descendants are checked. + if root_status.is_some() + && collector + .live_pids() + .filter(|pid| *pid != root_pid) + .all(|pid| !process_exists(pid)) + { + ended = Some(Instant::now()); + } + } + // Pace the reads. A read costs about the same however much it returns, + // so reading nonstop wastes a core. The kernel ends the wait early when + // a batch is ready. + let _ = session.wait(READ_INTERVAL.as_millis() as u64); + } + collector.flush(&mut |message| tracker.handle(message))?; + if collector.dropped > 0 { + let times = match collector.dropped { + 1 => "once".to_owned(), + n => format!("{n} times"), + }; + note!( + "the kernel dropped events {times} while the build ran; the trace may be missing \ + processes or files" + ); + } + let exit_code = tracker.root_exit_code(); + tracker.finish()?; + + let status = match root_status { + Some(status) => status, + None => build.wait()?, + }; + Ok(exit_code.unwrap_or_else(|| exit_status_code(status))) +} + +/// Reads and interprets whatever the kernel has buffered. +fn collect( + session: &mut kdebug::Session, + collector: &mut recon::Collector, + out: &mut impl FnMut(Message) -> io::Result<()>, +) -> io::Result<()> { + // Reading borrows the session's buffer, so the batch is taken first. + let events: Vec = session.read()?.collect(); + for event in &events { + collector.observe(event, out)?; + } + Ok(()) +} + +/// The user to run the build as. This is whoever started the recording, never +/// root. +fn target_user() -> io::Result<(u32, u32)> { + // SAFETY: reading the process's own credentials. + let uid = unsafe { libc::getuid() }; + // A setuid copy of Buildprof runs the build as the real user. This is not + // documented, and it needs nothing beyond dropping privileges. + if uid != 0 { + return Ok((uid, unsafe { libc::getgid() })); + } + let number = |name: &str| { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + }; + match (number("SUDO_UID"), number("SUDO_GID")) { + (Some(uid), Some(gid)) if uid != 0 => Ok((uid, gid)), + _ => Err(io::Error::other( + "refusing to run the build as root; start the recording as yourself, not from a \ + root shell", + )), + } +} + +/// Gives up root for good, so the build and the trace belong to their owner. +fn drop_privileges(uid: u32, gid: u32) -> io::Result<()> { + let name = user_name(uid); + // SAFETY: plain credential calls, made while the recorder has one thread. + unsafe { + // The supplementary groups are still root's, including wheel and admin. + // Replace them with the person's own groups. If those cannot be looked + // up, keep only the group they record as. + let own_groups = name + .as_ref() + .is_some_and(|name| libc::initgroups(name.as_ptr(), gid as i32) == 0); + if !own_groups && libc::setgroups(1, &(gid as libc::gid_t)) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::setgid(gid) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::setuid(uid) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::setuid(0) == 0 { + return Err(io::Error::other("privileges could not be dropped")); + } + } + Ok(()) +} + +/// Puts back what sudo replaced, so the build sees its own environment. +fn restore_user_environment(uid: u32) { + let entry = unsafe { libc::getpwuid(uid) }; + if entry.is_null() { + return; + } + let text = |value: *const libc::c_char| { + (!value.is_null()).then(|| { + unsafe { std::ffi::CStr::from_ptr(value) } + .to_string_lossy() + .into_owned() + }) + }; + let (name, home, shell) = unsafe { + ( + text((*entry).pw_name), + text((*entry).pw_dir), + text((*entry).pw_shell), + ) + }; + for (key, value) in [ + ("USER", name.clone()), + ("LOGNAME", name), + ("HOME", std::env::var("SUDO_HOME").ok().or(home)), + ("SHELL", shell), + ] { + if let Some(value) = value { + // SAFETY: single-threaded, before the build starts. + unsafe { std::env::set_var(key, value) }; + } + } + // The per-user temporary directory depends on the current credentials. This + // is only right after privileges are dropped. + let mut buffer = [0_i8; libc::PATH_MAX as usize]; + let length = unsafe { + libc::confstr( + libc::_CS_DARWIN_USER_TEMP_DIR, + buffer.as_mut_ptr(), + buffer.len(), + ) + }; + if length > 1 { + let bytes: Vec = buffer[..length - 1] + .iter() + .map(|byte| *byte as u8) + .collect(); + if let Ok(path) = String::from_utf8(bytes) { + unsafe { std::env::set_var("TMPDIR", path) }; + } + } +} + +fn user_name(uid: u32) -> Option { + let entry = unsafe { libc::getpwuid(uid) }; + if entry.is_null() { + return None; + } + let name = unsafe { (*entry).pw_name }; + (!name.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(name) }.to_owned()) +} + +fn exit_status_code(status: std::process::ExitStatus) -> u8 { + use std::os::unix::process::ExitStatusExt; + + crate::model::exit_code(status.into_raw()).min(u32::from(u8::MAX)) as u8 +} + +fn executable_name(command: &OsString) -> String { + Path::new(command) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| command.to_string_lossy().into_owned()) +} + +/// Asks the scheduler to wake the recorder on time. Ordinary threads have their +/// timers batched with others to save power. During a busy build, that +/// stretches one wait in a hundred to 10ms or more. A compiler's version check +/// can exit in that time, before its command line is read. The build has +/// already started, so it does not inherit this. If the kernel refuses, or +/// later demotes a thread that overruns its share, reading continues at normal +/// priority. +fn keep_up(clock: &Clock) { + let ticks = |interval: Duration| (interval.as_nanos() as f64 * clock.ticks_per_ns()) as u32; + let mut policy = libc::thread_time_constraint_policy { + period: ticks(READ_INTERVAL), + computation: ticks(READ_INTERVAL / 2), + constraint: ticks(READ_INTERVAL), + preemptible: 1, + }; + // SAFETY: `policy` is a filled-in struct of the flavor named. + unsafe { + libc::thread_policy_set( + libc::pthread_mach_thread_np(libc::pthread_self()), + libc::THREAD_TIME_CONSTRAINT_POLICY as libc::thread_policy_flavor_t, + (&raw mut policy).cast(), + libc::THREAD_TIME_CONSTRAINT_POLICY_COUNT, + ); + } +} + +fn process_exists(pid: i32) -> bool { + unsafe { libc::kill(pid, 0) == 0 || *libc::__error() != libc::ESRCH } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_saved_environment_reads_back_exactly() { + let variables = vec![ + (OsString::from("PATH"), OsString::from("/usr/bin:/bin")), + ( + OsString::from("RUSTFLAGS"), + OsString::from("-C opt-level=3 -Z x=y"), + ), + (OsString::from("EMPTY"), OsString::new()), + ( + OsString::from("UNICODE"), + OsString::from("caf\u{e9} \u{1f980}"), + ), + ]; + assert_eq!( + decode_environment(&encode_environment(variables.clone())), + variables + ); + } + + #[test] + fn only_a_private_file_of_ours_is_restored() { + let directory = std::env::temp_dir().join(format!( + "buildprof-environment-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&directory).unwrap(); + let uid = unsafe { libc::getuid() }; + let private = directory.join("private"); + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&private) + .unwrap(); + assert!(check_saved_environment(&private, uid).is_ok()); + + let readable = directory.join("readable"); + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .open(&readable) + .unwrap(); + assert!(check_saved_environment(&readable, uid).is_err()); + + let link = directory.join("link"); + std::os::unix::fs::symlink(&private, &link).unwrap(); + assert!(check_saved_environment(&link, uid).is_err()); + + // The check sees this as someone else's file. + assert!(check_saved_environment(&private, uid + 1).is_err()); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/macos/event.rs b/src/macos/event.rs new file mode 100644 index 0000000..411659c --- /dev/null +++ b/src/macos/event.rs @@ -0,0 +1,31 @@ +// Copyright 2026 The Buildprof Authors. +// SPDX-License-Identifier: Apache-2.0 + +//! What the collector observed. The tracker turns these into the trace's +//! process segments. + +/// One thing a followed process did. +#[derive(Debug, PartialEq)] +pub struct Message { + /// `mach_absolute_time()` when it happened. + pub mach_time: u64, + pub pid: i32, + /// The parent, or 0 when the collector does not know it. + pub ppid: i32, + pub event: Event, +} + +#[derive(Debug, PartialEq)] +pub enum Event { + Fork { + child: i32, + }, + /// The program a process is now running. + Exec { + executable: String, + }, + /// A `wait(2)` status. + Exit { + stat: i32, + }, +} diff --git a/src/macos/kdebug.rs b/src/macos/kdebug.rs new file mode 100644 index 0000000..d8e702f --- /dev/null +++ b/src/macos/kdebug.rs @@ -0,0 +1,227 @@ +// Copyright 2026 The Buildprof Authors. +// SPDX-License-Identifier: Apache-2.0 + +//! The kernel trace facility. It reports every process and file event on the +//! machine as compact binary records. +//! +//! Configuring it needs root. After that, the kernel lets the process that owns +//! the session keep reading. The recorder therefore sets up the session and +//! then drops privileges. +//! +//! The interface is `sysctl(KERN_KDEBUG, …)`. It is private to Apple and can +//! change between releases. `fs_usage(1)` and `ktrace(1)` use it too. + +use std::io; + +const CTL_KERN: i32 = 1; +const KERN_KDEBUG: i32 = 24; + +const KERN_KDENABLE: i32 = 3; +const KERN_KDSETBUF: i32 = 4; +const KERN_KDSETUP: i32 = 6; +const KERN_KDREMOVE: i32 = 7; +const KERN_KDREADTR: i32 = 10; +const KERN_KDTHRMAP: i32 = 12; +const KERN_KDSET_TYPEFILTER: i32 = 22; +const KERN_KDBUFWAIT: i32 = 23; +const KDEBUG_ENABLE_TRACE: i32 = 1; +const TYPEFILTER_BYTES: usize = (256 * 256) / 8; + +/// The most events one read returns. This covers a few milliseconds of even a +/// very parallel build. +const READ_EVENTS: usize = 128 * 1024; +const EVENT_BYTES: usize = 64; + +/// Subclasses the recorder subscribes to. The kernel always enables class 7, +/// `DBG_TRACE`, which reports process lifecycle. +const SUBSCRIPTIONS: &[(u16, u16)] = &[ + (0x04, 0x01), // BSD process: proc_exit, with its wait status + (0x04, 0x0c), // BSD syscalls: the open, rename, chdir and fd family + (0x03, 0x01), // File system: VFS_LOOKUP path records +]; + +/// One kernel trace record. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Event { + /// `mach_absolute_time()` when the event happened. + pub timestamp: u64, + pub args: [u64; 4], + pub thread: u64, + pub debugid: u32, +} + +impl Event { + /// The event kind, without the start/end bits. + pub fn code(&self) -> u32 { + self.debugid & !3 + } + + pub fn is_end(&self) -> bool { + self.debugid & 2 != 0 + } +} + +fn sysctl(mib: &mut [i32], buffer: *mut libc::c_void, length: &mut usize) -> io::Result<()> { + // SAFETY: `mib` and `length` are valid for the call, and `buffer` either is + // null or points at `*length` writable bytes. + let result = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + buffer, + length, + std::ptr::null_mut(), + 0, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +fn control( + op: i32, + value: Option, + buffer: *mut libc::c_void, + length: &mut usize, +) -> io::Result<()> { + match value { + Some(value) => sysctl(&mut [CTL_KERN, KERN_KDEBUG, op, value], buffer, length), + None => sysctl(&mut [CTL_KERN, KERN_KDEBUG, op], buffer, length), + } +} + +fn command(op: i32, value: Option) -> io::Result<()> { + control(op, value, std::ptr::null_mut(), &mut 0) +} + +/// An enabled trace session. Dropping it stops tracing and frees the buffers. +pub struct Session { + buffer: Vec, +} + +impl Session { + /// Configures and enables tracing. This must run as root. Afterwards the + /// recorder can drop privileges and keep reading. + pub fn start(buffer_events: usize) -> io::Result { + // Take over any session left behind by an earlier run. + let _ = command(KERN_KDENABLE, Some(0)); + let _ = command(KERN_KDREMOVE, None); + command(KERN_KDSETBUF, Some(buffer_events as i32)).map_err(describe)?; + command(KERN_KDSETUP, None).map_err(describe)?; + // From here on the session exists and must be torn down on any failure. + let session = Self { + buffer: vec![0; READ_EVENTS * EVENT_BYTES], + }; + let mut filter = vec![0_u8; TYPEFILTER_BYTES]; + for (class, subclass) in SUBSCRIPTIONS { + let bit = usize::from((class << 8) | subclass); + filter[bit / 8] |= 1 << (bit % 8); + } + let mut length = filter.len(); + control( + KERN_KDSET_TYPEFILTER, + None, + filter.as_mut_ptr().cast(), + &mut length, + ) + .map_err(describe)?; + command(KERN_KDENABLE, Some(KDEBUG_ENABLE_TRACE)).map_err(describe)?; + Ok(session) + } + + /// Blocks until the kernel has a batch worth reading, or `timeout_ms`. + pub fn wait(&self, timeout_ms: u64) -> io::Result { + let mut length = timeout_ms as usize; + control(KERN_KDBUFWAIT, None, std::ptr::null_mut(), &mut length)?; + Ok(length != 0) + } + + /// Threads that already existed when tracing started, as (thread, pid). + pub fn thread_map(&self) -> io::Result> { + let mut map = vec![0_u8; 64 * 1024 * 32]; + let mut length = map.len(); + control(KERN_KDTHRMAP, None, map.as_mut_ptr().cast(), &mut length)?; + Ok(map[..length.min(map.len())] + .as_chunks::<32>() + .0 + .iter() + .map(|entry| { + ( + u64::from_le_bytes(entry[0..8].try_into().expect("8 bytes")), + i32::from_le_bytes(entry[8..12].try_into().expect("4 bytes")), + ) + }) + .collect()) + } + + /// Reads whatever the kernel has buffered, oldest first. + pub fn read(&mut self) -> io::Result + '_> { + let mut length = self.buffer.len(); + control( + KERN_KDREADTR, + None, + self.buffer.as_mut_ptr().cast(), + &mut length, + )?; + // The kernel returns a count of events, not a byte count. + let bytes = length.saturating_mul(EVENT_BYTES).min(self.buffer.len()); + Ok(self.buffer[..bytes] + .as_chunks::() + .0 + .iter() + .map(decode)) + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = command(KERN_KDENABLE, Some(0)); + let _ = command(KERN_KDREMOVE, None); + } +} + +fn decode(record: &[u8; EVENT_BYTES]) -> Event { + let word = |at: usize| u64::from_le_bytes(record[at..at + 8].try_into().expect("8 bytes")); + Event { + timestamp: word(0), + args: [word(8), word(16), word(24), word(32)], + thread: word(40), + debugid: u32::from_le_bytes(record[48..52].try_into().expect("4 bytes")), + } +} + +/// Explains the failures people can do something about. +fn describe(error: io::Error) -> io::Error { + match error.raw_os_error() { + Some(libc::EBUSY) => io::Error::new( + error.kind(), + "another program, like Instruments, ktrace or fs_usage, is using the kernel trace \ + facility; stop it and try again", + ), + Some(libc::EPERM) => io::Error::new(error.kind(), "the kernel trace facility needs root"), + _ => error, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_a_record() { + let mut record = [0_u8; EVENT_BYTES]; + record[0..8].copy_from_slice(&1_234_u64.to_le_bytes()); + record[8..16].copy_from_slice(&7_u64.to_le_bytes()); + record[40..48].copy_from_slice(&99_u64.to_le_bytes()); + record[48..52].copy_from_slice(&0x0401_0006_u32.to_le_bytes()); + let event = decode(&record); + assert_eq!(event.timestamp, 1_234); + assert_eq!(event.args[0], 7); + assert_eq!(event.thread, 99); + assert_eq!(event.code(), 0x0401_0004); + assert!(event.is_end()); + } +} diff --git a/src/macos/recon.rs b/src/macos/recon.rs new file mode 100644 index 0000000..bb547d4 --- /dev/null +++ b/src/macos/recon.rs @@ -0,0 +1,359 @@ +// Copyright 2026 The Buildprof Authors. +// SPDX-License-Identifier: Apache-2.0 + +//! Turns kernel trace events into what the build did. +//! +//! The kernel reports every process on the machine. This follows only the ones +//! the build started. A process belongs to the build if the process that +//! created it does. + +use super::event::{Event, Message}; +use super::kdebug; +use super::tracker::Clock; +use std::collections::{HashMap, HashSet}; +use std::io; + +const NEWTHREAD: u32 = 0x0700_0004; +const DATA_EXEC: u32 = 0x0700_0008; +const STRING_EXEC: u32 = 0x0701_0008; +const LOST_EVENTS: u32 = 0x0702_0008; +const PROC_EXIT: u32 = 0x0401_0004; + +/// How long an exit is held before it is believed, in trace time. The kernel +/// also reports an exit when a process starts a new program. Anything further +/// from the pid cancels the exit. +const EXIT_HOLD_NS: u64 = 10_000_000; + +struct HeldExit { + pid: i32, + mach_time: u64, + stat: i32, + believe_after: u64, +} + +pub struct Collector { + root: i32, + clock: Clock, + processes: HashSet, + threads: HashMap, + /// Executions seen but not yet reported. The kernel names the new program + /// in a second event. + starting: HashMap, + held_exits: Vec, + /// Events the kernel dropped because the buffer filled. + pub dropped: u64, +} + +impl Collector { + pub fn new(root: i32, clock: Clock) -> Self { + Self { + root, + clock, + processes: HashSet::from([root]), + threads: HashMap::new(), + starting: HashMap::new(), + held_exits: Vec::new(), + dropped: 0, + } + } + + /// Threads that existed before tracing started. + pub fn seed_threads(&mut self, map: impl IntoIterator) { + self.threads.extend(map); + } + + pub fn observe( + &mut self, + event: &kdebug::Event, + out: &mut impl FnMut(Message) -> io::Result<()>, + ) -> io::Result<()> { + let code = event.code(); + let args = event.args; + let thread = event.thread; + + match code { + NEWTHREAD => { + let (child_thread, pid) = (args[0], args[1] as i32); + let parent = self.threads.get(&thread).copied(); + self.threads.insert(child_thread, pid); + if let Some(parent) = parent + && parent != pid + { + if self.processes.contains(&parent) { + self.cancel_exit(pid); + self.processes.insert(pid); + out(Message { + mach_time: event.timestamp, + pid: parent, + ppid: 0, + event: Event::Fork { child: pid }, + })?; + } else if pid != self.root { + // Another process now has this pid, so stop following + // it. + self.processes.remove(&pid); + } + } + return Ok(()); + } + LOST_EVENTS => { + self.dropped += 1; + return Ok(()); + } + _ => {} + } + + let Some(&pid) = self.threads.get(&thread) else { + return Ok(()); + }; + // Anything further from a pid means its exit was a new program + // starting. The process has not ended. + self.cancel_exit(pid); + self.believe_exits(event.timestamp, out)?; + if !self.processes.contains(&pid) { + return Ok(()); + } + + match code { + DATA_EXEC => { + self.starting.insert(pid, event.timestamp); + } + // The program's name follows the execution that started it. + STRING_EXEC => { + if let Some(mach_time) = self.starting.remove(&pid) { + out(Message { + mach_time, + pid, + ppid: 0, + event: Event::Exec { + executable: decode_string(&args), + }, + })?; + } + } + PROC_EXIT if event.is_end() => { + self.held_exits.push(HeldExit { + pid, + mach_time: event.timestamp, + stat: args[1] as i32, + believe_after: event.timestamp + self.ticks(EXIT_HOLD_NS), + }); + } + _ => {} + } + Ok(()) + } + + /// Emits everything still held back, at the end of a recording. + pub fn flush(&mut self, out: &mut impl FnMut(Message) -> io::Result<()>) -> io::Result<()> { + for exit in std::mem::take(&mut self.held_exits) { + self.emit_exit(exit, out)?; + } + Ok(()) + } + + /// Pids the build started that have not been seen to exit. + pub fn live_pids(&self) -> impl Iterator + '_ { + self.processes.iter().copied() + } + + fn ticks(&self, nanoseconds: u64) -> u64 { + (nanoseconds as f64 * self.clock.ticks_per_ns()) as u64 + } + + fn cancel_exit(&mut self, pid: i32) { + self.held_exits.retain(|exit| exit.pid != pid); + } + + fn believe_exits( + &mut self, + now: u64, + out: &mut impl FnMut(Message) -> io::Result<()>, + ) -> io::Result<()> { + while let Some(index) = self + .held_exits + .iter() + .position(|exit| exit.believe_after <= now) + { + let exit = self.held_exits.remove(index); + self.emit_exit(exit, out)?; + } + Ok(()) + } + + fn emit_exit( + &mut self, + exit: HeldExit, + out: &mut impl FnMut(Message) -> io::Result<()>, + ) -> io::Result<()> { + if exit.pid != self.root { + self.processes.remove(&exit.pid); + } + self.starting.remove(&exit.pid); + out(Message { + mach_time: exit.mach_time, + pid: exit.pid, + ppid: 0, + event: Event::Exit { stat: exit.stat }, + }) + } +} + +/// Names arrive as little-endian words packed with the bytes of the name. +fn decode_string(words: &[u64]) -> String { + let mut bytes = Vec::with_capacity(words.len() * 8); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds the trace events a build produces, for tests. + struct Stream { + events: Vec, + time: u64, + } + + impl Stream { + fn new() -> Self { + Self { + events: Vec::new(), + time: 1_000, + } + } + + fn push(&mut self, thread: u64, debugid: u32, args: [u64; 4]) -> &mut Self { + self.time += 10; + self.events.push(kdebug::Event { + timestamp: self.time, + args, + thread, + debugid, + }); + self + } + + /// An execution: the kernel reports it, then names the program. + fn exec(&mut self, thread: u64, pid: i32, name: &str) -> &mut Self { + let mut bytes = name.as_bytes().to_vec(); + bytes.resize(32, 0); + let mut words = [0; 4]; + for (word, chunk) in words.iter_mut().zip(bytes.chunks(8)) { + *word = u64::from_le_bytes(chunk.try_into().expect("8 bytes")); + } + self.push(thread, DATA_EXEC, [pid as u64, 0, 0, 0]); + self.push(thread, STRING_EXEC, words) + } + + fn collect(&self, collector: &mut Collector) -> Vec { + let mut out = Vec::new(); + for event in &self.events { + collector + .observe(event, &mut |message| { + out.push(message); + Ok(()) + }) + .expect("observe"); + } + collector + .flush(&mut |message| { + out.push(message); + Ok(()) + }) + .expect("flush"); + out + } + } + + fn collector() -> Collector { + let clock = Clock { + origin: 0, + numer: 1, + denom: 1, + }; + let mut collector = Collector::new(100, clock); + collector.seed_threads([(10, 100)]); + collector + } + + #[test] + fn reports_a_process_and_what_it_runs() { + let mut stream = Stream::new(); + stream.exec(10, 100, "make"); + let messages = stream.collect(&mut collector()); + assert_eq!( + messages, + [Message { + mach_time: 1_010, + pid: 100, + ppid: 0, + event: Event::Exec { + executable: "make".into(), + }, + }] + ); + } + + #[test] + fn a_new_process_is_followed_and_its_parent_reported() { + let mut stream = Stream::new(); + stream + .push(10, NEWTHREAD, [11, 200, 0, 0]) + .exec(11, 200, "cc"); + let messages = stream.collect(&mut collector()); + assert!(matches!(messages[0].event, Event::Fork { child: 200 })); + assert_eq!(messages[0].pid, 100); + assert!(matches!( + &messages[1].event, + Event::Exec { executable } if executable == "cc" + )); + assert_eq!(messages[1].pid, 200); + } + + #[test] + fn unrelated_processes_are_ignored() { + let mut stream = Stream::new(); + stream.exec(99, 999, "someone-else"); + let mut collector = collector(); + collector.seed_threads([(99, 999)]); + assert_eq!(stream.collect(&mut collector), []); + } + + #[test] + fn an_exit_is_only_believed_when_nothing_follows_it() { + // The kernel reports an exit when an image is replaced, too. + let mut stream = Stream::new(); + stream + .push(10, PROC_EXIT | 2, [100, 0, 0, 0]) + .exec(10, 100, "still-here"); + let messages = stream.collect(&mut collector()); + assert!( + !messages + .iter() + .any(|message| matches!(message.event, Event::Exit { .. })), + "{messages:?}" + ); + + // With nothing after it, the exit stands, with its wait status. + let mut stream = Stream::new(); + stream.push(10, PROC_EXIT | 2, [100, 256, 0, 0]); + let messages = stream.collect(&mut collector()); + assert_eq!( + messages, + [Message { + mach_time: 1_010, + pid: 100, + ppid: 0, + event: Event::Exit { stat: 256 }, + }] + ); + } +} diff --git a/src/macos/tracker.rs b/src/macos/tracker.rs new file mode 100644 index 0000000..fbf840b --- /dev/null +++ b/src/macos/tracker.rs @@ -0,0 +1,425 @@ +// Copyright 2026 The Buildprof Authors. +// SPDX-License-Identifier: Apache-2.0 + +//! Turns the collector's events into the same process segments the Linux +//! tracer records. + +use super::event::{Event, Message}; +use crate::blind_spots::BlindSpots; +use crate::model::{Process, Segment, exit_code}; +use crate::perfetto::Writer; +use std::collections::{HashMap, HashSet}; +use std::io; +use std::path::Path; + +/// The raw exit status of a child that `posix_spawn` created but could not +/// exec. `posix_spawnp` tries each `PATH` directory in turn, so one spawn can +/// leave several of these. No program ever ran in them. +const FAILED_SPAWN_STAT: i32 = 1; + +/// Where a tracker's records go. This is the trace writer, or a list in tests. +pub trait Sink { + fn process_started(&mut self, pid: i32) -> io::Result<()>; + fn segment(&mut self, process: Process, segment: &Segment) -> io::Result<()>; +} + +/// The trace being recorded. +pub struct TraceSink<'a> { + pub writer: &'a mut Writer, +} + +impl Sink for TraceSink<'_> { + fn process_started(&mut self, pid: i32) -> io::Result<()> { + self.writer.process_started(pid) + } + fn segment(&mut self, process: Process, segment: &Segment) -> io::Result<()> { + self.writer.segment(process, segment) + } +} + +/// Converts `mach_absolute_time` ticks to nanoseconds since recording began. +#[derive(Clone, Copy)] +pub struct Clock { + pub origin: u64, + pub numer: u32, + pub denom: u32, +} + +// libc deprecates these in favour of the mach2 crate. Two calls do not justify +// a new dependency. +#[allow(deprecated)] +impl Clock { + pub fn now() -> Self { + let mut timebase = libc::mach_timebase_info { numer: 0, denom: 0 }; + unsafe { libc::mach_timebase_info(&mut timebase) }; + Self { + origin: mach_now(), + numer: timebase.numer.max(1), + denom: timebase.denom.max(1), + } + } + + pub fn ns(&self, mach_time: u64) -> u64 { + let ticks = u128::from(mach_time.saturating_sub(self.origin)); + (ticks * u128::from(self.numer) / u128::from(self.denom)).min(u128::from(u64::MAX)) as u64 + } + + pub fn elapsed_ns(&self) -> u64 { + self.ns(mach_now()) + } + + /// Trace time units per nanosecond. + pub fn ticks_per_ns(&self) -> f64 { + f64::from(self.denom) / f64::from(self.numer) + } +} + +#[allow(deprecated)] +fn mach_now() -> u64 { + unsafe { libc::mach_absolute_time() } +} + +struct ProcessState { + process: Process, + segment: Segment, +} + +pub struct Tracker<'a, S: Sink> { + sink: &'a mut S, + blind_spots: &'a mut BlindSpots, + clock: Clock, + root_pid: i32, + root_exit_code: Option, + processes: HashMap, + /// Pids whose tracks are in the trace. A process is only announced once + /// something is written for it, so a failed spawn leaves nothing behind. + announced: HashSet, +} + +impl<'a, S: Sink> Tracker<'a, S> { + /// Starts following `root_pid`, already started running `command`. + pub fn new( + sink: &'a mut S, + blind_spots: &'a mut BlindSpots, + clock: Clock, + root_pid: i32, + name: String, + command: String, + cwd: String, + ) -> Self { + let root = ProcessState { + process: Process { + pid: root_pid, + parent_pid: 0, + build_parent_pid: 0, + execed: false, + }, + segment: Segment { + start_ns: 0, + end_ns: 0, + name, + command, + cwd, + exit_code: None, + }, + }; + Self { + sink, + blind_spots, + clock, + root_pid, + root_exit_code: None, + processes: HashMap::from([(root_pid, root)]), + announced: HashSet::new(), + } + } + + pub fn root_exit_code(&self) -> Option { + self.root_exit_code + } + + pub fn handle(&mut self, message: Message) -> io::Result<()> { + let timestamp_ns = self.clock.ns(message.mach_time); + let pid = message.pid; + if !self.processes.contains_key(&pid) { + // Children started with posix_spawn may reach us as an exec + // with no fork before it. + if !matches!(message.event, Event::Exec { .. }) + || !self.processes.contains_key(&message.ppid) + { + return Ok(()); + } + self.start_process(pid, message.ppid, timestamp_ns); + } + match message.event { + Event::Fork { child } => { + if !self.processes.contains_key(&child) { + self.start_process(child, pid, timestamp_ns); + } + } + Event::Exec { executable } => self.exec(pid, timestamp_ns, executable)?, + Event::Exit { stat } => self.exit(pid, timestamp_ns, stat)?, + } + Ok(()) + } + + /// Ends every process still open, as of now. + pub fn finish(mut self) -> io::Result<()> { + let end_ns = self.clock.elapsed_ns(); + for (pid, mut state) in self.processes { + state.segment.end_ns = end_ns.max(state.segment.start_ns); + announce(self.sink, &mut self.announced, pid)?; + self.sink.segment(state.process, &state.segment)?; + } + Ok(()) + } + + fn start_process(&mut self, pid: i32, parent_pid: i32, timestamp_ns: u64) { + let parent = self.processes.get(&parent_pid); + let build_parent_pid = + Process::build_parent_of(parent.map(|parent| &parent.process), parent_pid); + let (name, command, cwd) = parent + .map(|parent| { + ( + format!("fork:{}", parent.segment.name), + parent.segment.command.clone(), + parent.segment.cwd.clone(), + ) + }) + .unwrap_or_else(|| ("fork".into(), String::new(), String::new())); + self.processes.insert( + pid, + ProcessState { + process: Process { + pid, + parent_pid, + build_parent_pid, + execed: false, + }, + segment: Segment { + start_ns: timestamp_ns, + end_ns: 0, + name, + command, + cwd, + exit_code: None, + }, + }, + ); + } + + fn exec(&mut self, pid: i32, timestamp_ns: u64, executable: String) -> io::Result<()> { + self.blind_spots.observe(std::slice::from_ref(&executable)); + let name = Path::new(&executable) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("pid:{pid}")); + let command = executable.clone(); + let Some(state) = self.processes.get_mut(&pid) else { + return Ok(()); + }; + if state.process.execed { + let mut previous = std::mem::replace( + &mut state.segment, + Segment { + start_ns: timestamp_ns, + end_ns: 0, + name, + command, + cwd: String::new(), + exit_code: None, + }, + ); + previous.end_at(timestamp_ns); + announce(self.sink, &mut self.announced, pid)?; + self.sink.segment(state.process, &previous)?; + } else { + state.process.execed = true; + state.segment.name = name; + state.segment.command = command; + } + Ok(()) + } + + fn exit(&mut self, pid: i32, timestamp_ns: u64, stat: i32) -> io::Result<()> { + let Some(mut state) = self.processes.remove(&pid) else { + return Ok(()); + }; + if stat == FAILED_SPAWN_STAT && !state.process.execed && !self.announced.contains(&pid) { + return Ok(()); + } + let exit_code = exit_code(stat); + if pid == self.root_pid { + self.root_exit_code = Some(exit_code.min(u32::from(u8::MAX)) as u8); + } + state.segment.end_at(timestamp_ns); + state.segment.exit_code = Some(exit_code); + announce(self.sink, &mut self.announced, pid)?; + self.sink.segment(state.process, &state.segment) + } +} + +fn announce(sink: &mut S, announced: &mut HashSet, pid: i32) -> io::Result<()> { + if announced.insert(pid) { + sink.process_started(pid)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct Records(Vec); + + impl Sink for Records { + fn process_started(&mut self, pid: i32) -> io::Result<()> { + self.0.push(format!("start {pid}")); + Ok(()) + } + fn segment(&mut self, process: Process, segment: &Segment) -> io::Result<()> { + self.0.push(format!( + "segment {} parent={} build_parent={} execed={} {}..{} {:?} exit={:?}", + process.pid, + process.parent_pid, + process.build_parent_pid, + process.execed, + segment.start_ns, + segment.end_ns, + segment.command, + segment.exit_code + )); + Ok(()) + } + } + + fn clock() -> Clock { + Clock { + origin: 1000, + numer: 1, + denom: 1, + } + } + + /// A message from `pid`, at `mach_time`. + fn at(mach_time: u64, pid: i32, ppid: i32, event: Event) -> Message { + Message { + mach_time, + pid, + ppid, + event, + } + } + + fn exec(program: &str) -> Event { + Event::Exec { + executable: format!("/usr/bin/{program}"), + } + } + + fn tracker<'a>( + records: &'a mut Records, + blind_spots: &'a mut BlindSpots, + name: &str, + ) -> Tracker<'a, Records> { + Tracker::new( + records, + blind_spots, + clock(), + 10, + name.into(), + name.into(), + "/src".into(), + ) + } + + #[test] + fn records_a_build_tree() { + let mut records = Records::default(); + let mut blind_spots = BlindSpots::default(); + let mut tracker = tracker(&mut records, &mut blind_spots, "make"); + for message in [ + at(1001, 10, 1, exec("make")), + at(1010, 10, 1, Event::Fork { child: 11 }), + // A child started with posix_spawn is claimed through its parent. + at(1015, 12, 10, exec("cc")), + at(1020, 11, 10, exec("sh")), + at(1040, 12, 10, Event::Exit { stat: 0 }), + at(1041, 99, 1, exec("unrelated")), + at(1050, 11, 10, Event::Exit { stat: 2 << 8 }), + at(1060, 10, 1, Event::Exit { stat: 0 }), + ] { + tracker.handle(message).unwrap(); + } + assert_eq!(tracker.root_exit_code(), Some(0)); + tracker.finish().unwrap(); + assert_eq!( + records.0, + [ + "start 12", + r#"segment 12 parent=10 build_parent=10 execed=true 15..40 "/usr/bin/cc" exit=Some(0)"#, + "start 11", + r#"segment 11 parent=10 build_parent=10 execed=true 10..50 "/usr/bin/sh" exit=Some(2)"#, + "start 10", + r#"segment 10 parent=0 build_parent=0 execed=true 0..60 "/usr/bin/make" exit=Some(0)"#, + ] + ); + } + + #[test] + fn a_second_exec_starts_a_new_segment() { + let mut records = Records::default(); + let mut blind_spots = BlindSpots::default(); + let mut tracker = tracker(&mut records, &mut blind_spots, "sh"); + for message in [ + at(1001, 10, 1, exec("sh")), + at(1005, 10, 1, exec("cc")), + at(1009, 10, 1, Event::Exit { stat: 9 }), + ] { + tracker.handle(message).unwrap(); + } + assert_eq!(tracker.root_exit_code(), Some(9 + 128)); + assert_eq!( + records.0[1..], + [ + r#"segment 10 parent=0 build_parent=0 execed=true 0..5 "/usr/bin/sh" exit=None"#, + r#"segment 10 parent=0 build_parent=0 execed=true 5..9 "/usr/bin/cc" exit=Some(137)"#, + ] + ); + } + + #[test] + fn a_failed_spawn_leaves_nothing_behind() { + let mut records = Records::default(); + let mut blind_spots = BlindSpots::default(); + let mut tracker = tracker(&mut records, &mut blind_spots, "make"); + for message in [ + at(1001, 10, 1, exec("make")), + // posix_spawnp trying a PATH directory without the program. + at(1002, 10, 1, Event::Fork { child: 13 }), + at(1003, 13, 10, Event::Exit { stat: 1 }), + at(1004, 10, 1, Event::Exit { stat: 0 }), + ] { + tracker.handle(message).unwrap(); + } + tracker.finish().unwrap(); + assert!( + !records.0.iter().any(|record| record.contains(" 13 ")), + "{:?}", + records.0 + ); + } + + #[test] + fn converts_mach_ticks() { + let clock = Clock { + origin: 100, + numer: 125, + denom: 3, + }; + assert_eq!(clock.ns(103), 125); + assert_eq!(clock.ns(50), 0, "events before the origin clamp to it"); + assert!((clock.ticks_per_ns() - 3.0 / 125.0).abs() < f64::EPSILON); + } +} diff --git a/src/main.rs b/src/main.rs index 5dca4dc..d8cb048 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,19 +5,23 @@ mod report; mod args; -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))] mod blind_spots; #[cfg(target_os = "linux")] mod compiler; mod handoff; #[cfg(target_os = "linux")] mod linux; +#[cfg(target_os = "macos")] +mod macos; mod util; -// The trace model and writer are portable; only recording is Linux-specific. +// The trace model and writer work on every platform. Only recording is +// platform-specific. // Building them everywhere keeps the writer's unit tests running on every // platform the viewer ships on. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))] mod model; +// Compiler traces are Linux-only for now. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod perfetto; @@ -42,7 +46,16 @@ fn main() -> ExitCode { file_events, handoff, wait, - } => record(output, command, compiler_traces, file_events, handoff, wait), + saved_environment, + } => record( + output, + command, + compiler_traces, + file_events, + handoff, + wait, + saved_environment, + ), args::Args::Open { source, url, @@ -77,7 +90,7 @@ fn list_examples() -> ExitCode { ExitCode::SUCCESS } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn record( output: std::path::PathBuf, command: Vec, @@ -85,7 +98,22 @@ fn record( file_events: bool, handoff: Option, wait: Wait, + saved_environment: Option, ) -> ExitCode { + // Only the macOS recorder runs itself again through sudo and passes one. + #[cfg(target_os = "linux")] + let _ = saved_environment; + // Collection starts privileged and drops privileges before anything is + // created, so the trace and the build belong to whoever ran the recording. + #[cfg(target_os = "macos")] + let prepared = match macos::prepare(saved_environment.as_deref()) { + Ok(macos::Start::Recording(prepared)) => prepared, + Ok(macos::Start::Finished(code)) => return code, + Err(error) => { + error!("could not start recording: {error}"); + return ExitCode::FAILURE; + } + }; let mut writer = match perfetto::Writer::create(&output) { Ok(writer) => writer, Err(error) => { @@ -97,12 +125,16 @@ fn record( } }; + // macOS recordings carry no file events yet. + let file_events = file_events && cfg!(target_os = "linux"); if let Err(error) = writer.collection_options(file_events, compiler_traces) { error!("could not write recording options: {error}"); return ExitCode::FAILURE; } + #[cfg(target_os = "linux")] let mut compilers = compiler::Capture::new(compiler_traces); let mut blind_spots = blind_spots::BlindSpots::default(); + #[cfg(target_os = "linux")] let result = linux::record( &command, &mut writer, @@ -110,6 +142,8 @@ fn record( &mut blind_spots, file_events, ); + #[cfg(target_os = "macos")] + let result = macos::record(prepared, &command, &mut writer, &mut blind_spots); let write_result = writer .data_loss(&blind_spots.findings()) .and_then(|()| writer.finish()); @@ -148,7 +182,7 @@ fn record( ExitCode::from(exit_code) } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "macos")))] fn record( _output: std::path::PathBuf, _command: Vec, @@ -156,8 +190,9 @@ fn record( _file_events: bool, _handoff: Option, _wait: Wait, + _saved_environment: Option, ) -> ExitCode { - error!("recording needs Linux; this build can only view traces"); + error!("recording needs Linux or macOS; this build can only view traces"); hint!( "record on a Linux machine, copy the trace here, and run {}", report::emph("buildprof open ")