Skip to content

Commit c11964c

Browse files
not-matthiasclaude
andcommitted
fix(mac): don't SIGKILL Apple toolchain binaries under samply record
samply injects DYLD_INSERT_LIBRARIES + SAMPLY_BOOTSTRAP_SERVER_NAME into the whole descendant tree. Each descendant that loads samply-mac-preload runs a dyld initializer that sends its mach task-self port to samply so it can be profiled. For an Apple *platform binary* (CS_PLATFORM_BINARY) the task-self port is immovable. Transferring an immovable port to another process raises a fatal EXC_GUARD / GUARD_TYPE_MACH_PORT (ILLEGAL_MOVE) and the kernel SIGKILLs the process — inside the preload's mach_msg, before main(). This is not specific to dsymutil: the entire Xcode toolchain is affected (clang, ld, nm, strip, lipo, dsymutil, ... all confirmed killed by signal 9; clang++, swift(c), ar, ranlib, otool, dwarfdump, objdump, llvm-* share the same CS_PLATFORM_BINARY + non-restricted property). dsymutil was just the binary the original report hit (Go's linker invokes it). Any compile/link step run under `samply record` breaks as soon as a build tool invokes a toolchain binary by absolute path (the norm); going through the restricted /usr/bin shims hides it because they strip DYLD_*. Symptom: "running dsymutil failed: signal: killed". Confirmed via the crash report (EXC_GUARD "ILLEGAL_MOVE on mach port 515", port 515 == mach_task_self()) and by probing csops(2): the only distinguishing bit between a killed platform binary and a surviving locally-built binary is CS_PLATFORM_BINARY. Fix: in the preload, detect platform binaries via csops(getpid(), CS_OPS_STATUS) and skip the task handoff for them. samply cannot profile a platform binary through this mechanism anyway (its task port is protected), so nothing is lost; the process runs normally instead of being killed. Descendant profiling of all normal binaries is unchanged. Includes a regression test (samply/tests/dsymutil_sigkill.rs). Preload rebuilt for x86_64/arm64/arm64e. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e8b8dac commit c11964c

7 files changed

Lines changed: 167 additions & 0 deletions

File tree

32 Bytes
Binary file not shown.
-48 Bytes
Binary file not shown.
32 Bytes
Binary file not shown.
-32 Bytes
Binary file not shown.

samply-mac-preload/src/lib.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,52 @@ static __SETUP_SAMPLY_CONNECTION: unsafe extern "C" fn() = {
3535
__load_samply_lib
3636
};
3737

38+
/// Returns true if the current process is an Apple "platform binary"
39+
/// (`CS_PLATFORM_BINARY`). Such processes are given an *immovable* task-self
40+
/// mach port by the kernel: any attempt to transfer that port to another
41+
/// process — which is exactly what samply's task handoff below does — raises a
42+
/// fatal `EXC_GUARD` (`ILLEGAL_MOVE`) and the kernel SIGKILLs the process.
43+
///
44+
/// This is how `samply record -- <build>` would otherwise kill `dsymutil` (and
45+
/// other Apple toolchain binaries) that a build invokes: they inherit samply's
46+
/// `DYLD_INSERT_LIBRARIES`, load this preload, and crash in the handoff.
47+
///
48+
/// samply cannot profile platform binaries through this mechanism regardless
49+
/// (their task port is protected), so detecting this case and skipping the
50+
/// handoff loses nothing and keeps the process alive.
51+
fn is_platform_binary() -> bool {
52+
// `csops(getpid(), CS_OPS_STATUS, &flags, sizeof(flags))` reports the
53+
// process's code-signing status flags. It works on the calling process
54+
// without any privilege. CS_PLATFORM_BINARY == 0x04000000.
55+
const CS_OPS_STATUS: u32 = 0;
56+
const CS_PLATFORM_BINARY: u32 = 0x0400_0000;
57+
extern "C" {
58+
fn csops(
59+
pid: libc::pid_t,
60+
ops: u32,
61+
useraddr: *mut libc::c_void,
62+
usersize: libc::size_t,
63+
) -> libc::c_int;
64+
}
65+
let mut flags: u32 = 0;
66+
let r = unsafe {
67+
csops(
68+
libc::getpid(),
69+
CS_OPS_STATUS,
70+
&mut flags as *mut u32 as *mut libc::c_void,
71+
core::mem::size_of::<u32>() as libc::size_t,
72+
)
73+
};
74+
r == 0 && (flags & CS_PLATFORM_BINARY) != 0
75+
}
76+
3877
fn set_up_samply_connection() -> Option<()> {
78+
// Don't hand our task port to samply if we're a platform binary: the port
79+
// is immovable and sending it would get us SIGKILLed. See
80+
// `is_platform_binary`.
81+
if is_platform_binary() {
82+
return None;
83+
}
3984
let (tx0, rx0) = channel().ok()?;
4085
// Safety:
4186
// - b"SAMPLY_BOOTSTRAP_SERVER_NAME\0" is a nul-terminated c string
-742 Bytes
Binary file not shown.

samply/tests/dsymutil_sigkill.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Regression test for: `samply record` SIGKILLs `dsymutil`.
2+
//!
3+
//! samply injects `DYLD_INSERT_LIBRARIES` (+ `SAMPLY_BOOTSTRAP_SERVER_NAME`) into
4+
//! the *entire* descendant process tree it launches. Any descendant that loads
5+
//! the preload hands its mach task port to samply and lets it "control us
6+
//! completely" (see `samply-mac-preload`). For `dsymutil` this takeover ends in a
7+
//! deterministic `SIGKILL`, which breaks builds run under `samply record` on
8+
//! macOS (the linker invokes `dsymutil` and reports `running dsymutil failed:
9+
//! signal: killed`).
10+
//!
11+
//! This test launches, under the built `samply` binary, a small locally-built
12+
//! "spawner" that execs `dsymutil` on a Mach-O with DWARF, and asserts that
13+
//! `dsymutil` is NOT killed (the desired behaviour).
14+
//!
15+
//! macOS-only (`cfg(target_os = "macos")`), so it compiles out elsewhere. It
16+
//! needs Xcode's `dsymutil` and a working `cc`, both present on `macos-latest`
17+
//! CI runners. Launch-mode profiling does not need `task_for_pid` entitlements
18+
//! (the child volunteers its task port), so no `samply setup` is required.
19+
//!
20+
//! Run with:
21+
//! cargo test -p samply --test dsymutil_sigkill -- --nocapture
22+
#![cfg(target_os = "macos")]
23+
24+
use std::path::Path;
25+
use std::process::Command;
26+
27+
fn cc(args: &[&str]) {
28+
let status = Command::new("cc").args(args).status().expect("failed to run cc");
29+
assert!(status.success(), "cc {args:?} failed");
30+
}
31+
32+
fn xcrun_dsymutil() -> String {
33+
let out = Command::new("xcrun")
34+
.args(["-f", "dsymutil"])
35+
.output()
36+
.expect("failed to run xcrun");
37+
assert!(out.status.success(), "xcrun -f dsymutil failed");
38+
String::from_utf8(out.stdout).unwrap().trim().to_string()
39+
}
40+
41+
#[test]
42+
fn dsymutil_is_not_killed_under_samply() {
43+
let samply = env!("CARGO_BIN_EXE_samply");
44+
let dsymutil = xcrun_dsymutil();
45+
46+
let tmp = std::env::temp_dir().join(format!("samply_dsym_repro_{}", std::process::id()));
47+
std::fs::create_dir_all(&tmp).unwrap();
48+
49+
// A Mach-O with enough DWARF that dsymutil does real work.
50+
let src = tmp.join("big.cpp");
51+
{
52+
use std::fmt::Write as _;
53+
let mut s = String::from("#include <cstdio>\n");
54+
for i in 0..1200 {
55+
writeln!(s, "template<int N> struct S{i} {{ int v[N%7+1]; int f(int x){{return x*{i}+N;}} }};").unwrap();
56+
writeln!(s, "int g{i}(int x){{ S{i}<{}> s; return s.f(x)+{i}; }}", i % 9 + 1).unwrap();
57+
}
58+
s.push_str("int main(){int t=0;");
59+
for i in 0..1200 {
60+
write!(s, "t+=g{i}(t);").unwrap();
61+
}
62+
s.push_str("printf(\"%d\\n\",t);return 0;}\n");
63+
std::fs::write(&src, s).unwrap();
64+
}
65+
let macho = tmp.join("bigcpp");
66+
cc(&["-g", "-O0", "-o", macho.to_str().unwrap(), src.to_str().unwrap()]);
67+
68+
// A locally-built (non-restricted) parent that execs dsymutil and reports
69+
// how the child died via its own exit code: 0 = clean, 1 = killed by signal.
70+
let spawner_src = tmp.join("spawner.c");
71+
std::fs::write(
72+
&spawner_src,
73+
r#"
74+
#include <stdio.h>
75+
#include <stdlib.h>
76+
#include <unistd.h>
77+
#include <sys/wait.h>
78+
int main(int argc, char** argv){
79+
pid_t pid = fork();
80+
if(pid==0){ execl(argv[1],"dsymutil","-f",argv[2],"-o",argv[3],(char*)0); _exit(127); }
81+
int st=0; waitpid(pid,&st,0);
82+
if(WIFSIGNALED(st)){ fprintf(stderr,"dsymutil killed by signal %d\n", WTERMSIG(st)); return 1; }
83+
fprintf(stderr,"dsymutil exited code %d\n", WEXITSTATUS(st)); return 0;
84+
}
85+
"#,
86+
)
87+
.unwrap();
88+
let spawner = tmp.join("spawner");
89+
cc(&["-O0", "-o", spawner.to_str().unwrap(), spawner_src.to_str().unwrap()]);
90+
91+
let out_dwarf = tmp.join("out.dwarf");
92+
let profile = tmp.join("profile.json.gz");
93+
94+
// samply record --save-only -o <profile> -- <spawner> <dsymutil> <macho> <out.dwarf>
95+
let status = Command::new(samply)
96+
.args(["record", "--save-only", "-o"])
97+
.arg(&profile)
98+
.arg("--")
99+
.arg(&spawner)
100+
.arg(&dsymutil)
101+
.arg(&macho)
102+
.arg(&out_dwarf)
103+
.status()
104+
.expect("failed to run samply");
105+
106+
// The spawner exits 0 iff dsymutil completed normally. Under the bug it exits
107+
// 1 because dsymutil was SIGKILLed by samply.
108+
let killed = !status.success();
109+
let produced_output = Path::new(&out_dwarf).exists();
110+
111+
// Clean up only after we've inspected the results (out.dwarf lives in `tmp`).
112+
let _ = std::fs::remove_dir_all(&tmp);
113+
114+
assert!(
115+
!killed,
116+
"dsymutil was killed when run under `samply record` \
117+
(spawner exit = {:?}). samply must not SIGKILL build subprocesses it \
118+
injects into.",
119+
status.code()
120+
);
121+
assert!(produced_output, "dsymutil did not produce its output");
122+
}

0 commit comments

Comments
 (0)