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 ")