Skip to content

Fix Rust support on macOS and Linux - #286

Merged
emeryberger merged 11 commits into
masterfrom
rust-support-verify
Jul 10, 2026
Merged

emeryberger merged 11 commits into
masterfrom
rust-support-verify

Conversation

@emeryberger

Copy link
Copy Markdown
Member

Two independent bugs made the Rust bindings unusable. Both are fixed here, with the macOS path verified end-to-end.

1. macOS: every Rust binary crashed on startup (SIGBUS)

libcoz/mac_interpose.cpp declared the original signal as a function-pointer variable bound to the _signal symbol:

typedef sig_handler_fn (*signal_fn_t)(int, sig_handler_fn);
extern signal_fn_t orig_signal_fn __asm("_signal");

A variable bound to _signal is the bytes at _signal — i.e. signal()'s machine code — so orig_signal_fn(...) branched to whatever those instruction bytes decoded to as an address. On arm64 the faulting PC was 0x1_52800022, which is exactly the first two instruction words of libsystem's signal() read as a pointer (0x52800022 = movz w2, #1).

Rust's startup unconditionally calls signal(SIGPIPE, SIG_IGN) from std::sys::pal::unix::init::reset_sigpipe, so every Rust program died before main. A bare fn main() {} reproduces it. Any C program calling signal() would hit it too.

Fixed by declaring it as a function bound to _signal, consistent with orig_sigaction, orig_pthread_create, and the rest.

2. Linux: _coz_add_delays() called on every progress point

include/coz.h gates _COZ_CHECK_DELAYS to __APPLE__ only, and says why:

On Linux, delays are already applied in the SIGPROF handler via process_samples(), so calling add_delays() again at every progress-point hit causes double application and TPS collapse under high concurrency.

The Rust crate called coz_add_delays() unconditionally. The __APPLE__ gate landed in coz.h (8c07fc2, 20:15) hours after the Rust call site was added (7cb3c86, 00:25) on the same day, and the crate was never updated. Now gated behind target_os = "macos", mirroring the header.

Verification

  • macOS arm64: coz run --- ./rust/target/release/examples/toy produces 146 experiments and attributes the bottleneck to toy.rs:12 (the b thread's progress point, which does 1.2x the work of a) at ~+44% predicted speedup. Before this change: instant SIGBUS, no profile.
  • Linux code path: cargo check --target x86_64-unknown-linux-gnu compiles clean with no dead-code warnings.
  • cargo test --release passes.
  • C benchmark (benchmarks/toy) still profiles correctly on macOS — 10 experiments — confirming no regression in the signal interposition.

Also refreshes a stale rust/README.md caveat claiming coz is Linux-only.

🤖 Generated with Claude Code

emeryberger and others added 2 commits July 9, 2026 01:00
Two independent bugs made the Rust bindings unusable.

macOS: every Rust binary crashed on startup with SIGBUS before reaching
main. `orig_signal_fn` was declared as a function-pointer *variable* bound
to the `_signal` symbol:

    extern signal_fn_t orig_signal_fn __asm("_signal");

so its value was the first eight bytes of signal()'s machine code rather
than its address, and `orig_signal_fn(...)` branched into whatever those
instruction bytes decoded to. Rust's startup always calls
signal(SIGPIPE, SIG_IGN) from std::sys::pal::unix::init::reset_sigpipe,
so this fired for every Rust program (and any C program calling signal()).
Declare it as a function bound to `_signal`, matching the other originals.

Linux: `increment()` called `_coz_add_delays()` unconditionally. coz.h
gates the equivalent `_COZ_CHECK_DELAYS` to __APPLE__ only, because on
Linux delays are already applied in the SIGPROF handler via
process_samples(); calling add_delays() again at every progress point
double-applies them and collapses throughput under concurrency. The gate
landed in coz.h (8c07fc2) hours after the Rust call site (7cb3c86) and
the crate was never updated. Gate it behind target_os = "macos".

Verified on macOS arm64: rust/examples/toy now yields 146 experiments and
correctly attributes the bottleneck to toy.rs:12. The non-macOS path is
checked via `cargo check --target x86_64-unknown-linux-gnu`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `go/` module exposing coz's progress points to Go, plus the one libcoz
change needed to make Go programs safe to profile.

The binding resolves `_coz_get_counter` and `_coz_add_delays` out of the
injected libcoz with dlsym(RTLD_DEFAULT), the same way the Rust crate does, so
it needs neither coz.h at build time nor a system coz-profiler package. The
counter bump is a plain relaxed atomic on libcoz-owned memory performed from
Go, so there is no cgo call on the hot path on Linux. `_coz_add_delays()` is
gated to macOS, mirroring _COZ_CHECK_DELAYS in coz.h.

API: Progress, ProgressNamed, Begin, End, Scope (defer-friendly latency pair),
and NewThroughput/NewBegin/NewEnd for hoisting the lookup out of a hot loop.
Progress names its point after the caller's file:line via runtime.Caller, so it
behaves like COZ_PROGRESS rather than reporting a line inside the binding.
With CGO_ENABLED=0 the package builds as no-ops instead of failing.

libcoz: install the sampling and error signal handlers with SA_ONSTACK.

Go's runtime creates its Ms with pthread_create (not clone) whenever cgo is in
play, so coz interposes them, gives each a perf event, and delivers SIGPROF to
threads that are running goroutine stacks. Those stacks are small and movable,
and Go's cgo documentation requires foreign signal handlers to set SA_ONSTACK
for exactly this reason. Without it the Go toy segfaults partway through every
run; with it, three consecutive runs complete cleanly. Threads with no
alternate stack ignore the flag, so this is inert for ordinary C/C++ programs
(the C toy benchmark is unaffected: 11 experiments before and after).

Verified end to end on macOS arm64 and on Linux arm64 (Ubuntu 24.04 container,
Go 1.26.5). go/example/toy.go runs two goroutines per round, one doing twice
the work of the other; coz attributes the speedup to the loop on the critical
path (slope 0.57-1.08, R^2 0.87-0.99) and finds the other nearly flat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
emeryberger and others added 2 commits July 9, 2026 08:12
Adds a lang-rust job that builds libcoz, builds and tests the crate, then runs
rust/examples/toy under `coz run` on both platforms and asserts the resulting
profile contains experiments attributed to toy.rs.

This would have caught both bugs fixed in this branch: the macOS SIGBUS (no
profile at all) and, indirectly, any regression that stops progress points from
registering.

Also adds .github/scripts/check_profile.py, shared by the per-language jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a lang-go job that gofmt/vet/race-tests the module, checks it still builds
with CGO_ENABLED=0, then profiles go/example/toy.go under `coz run` on both
platforms and asserts experiments are attributed to toy.go.

This pins down the two things that were easy to get wrong: the SA_ONSTACK
requirement (without it the Go toy segfaults mid-run) and the
-ldflags=-compressdwarf=false requirement (without it coz finds no debug info).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI and others added 5 commits July 9, 2026 15:10
SA_ONSTACK is required for Go: with cgo the Go runtime creates its Ms with
pthread_create, so coz interposes them and delivers SIGPROF to threads running
goroutine stacks, which are small and movable. Without SA_ONSTACK the Go toy
crashes (verified again here on x86_64: rc=2, runtime fatal error).

But SA_ONSTACK means the handler runs on whatever alternate stack the program
installed, and process_samples() needs more room than some runtimes hand out.
Rust's std gives every thread exactly SIGSTKSZ = 8192 bytes. On x86_64 the
signal frame plus the dynamic linker's xsavec trampoline eat much of that and
the handler overruns the rest, so *every* Rust program died under coz:

  #0  _dl_lookup_symbol_x (undef_name="__fprintf_chk")   dl-lookup.c:840
  #1  _dl_fixup                                          dl-runtime.c:95
  #2  _dl_runtime_resolve_xsavec                         dl-trampoline.h:126
  #3  fprintf (__fmt="Segmentation fault at %p\n")
  #4  profiler::on_error                                 profiler.cpp:883
  #5  <signal handler called>
  #6  profiler::process_samples                          profiler.cpp:719

Note the second fault: on_error could not even report the first one, because
fprintf's lazy PLT resolution needed stack that was already gone. That is why
CI showed a bare "Segmentation fault" with an empty profile and no message.

aarch64 has a smaller signal frame and never tripped it, which is why this was
invisible on the Apple-silicon containers used to develop the Go bindings, and
only x86_64 CI caught it.

Fix: interpose sigaltstack and, when a program asks for a stack smaller than the
handler needs, install a 32 KiB one instead. Go already asks for 32 KiB -- at
the floor, not below it -- so its stack is left exactly as it set it up, which
matters because the Go runtime checks that a signal arrived on the gsignal stack
it knows about.

Measured on an x86_64 Ubuntu box, per language, with libcoz built three ways:

                     no SA_ONSTACK   SA_ONSTACK    SA_ONSTACK + floor
  Go toy             crash           -             11 experiments
  Rust toy           33 experiments  SIGSEGV       33 experiments (2/2 runs)
  C toy              12 experiments  12            12 experiments

Also raises the Rust CI job's timeout from 180s to 300s. rust/examples/toy runs
4.4e9 loop iterations, and a 2-core runner does not finish enough experiments
inside 180s -- which is what the *first* red run on this branch was, before
SA_ONSTACK ever landed. Two different failures wore the same job name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
emeryberger and others added 2 commits July 10, 2026 08:00
get_loaded_files() parsed /proc/self/maps and took `base - offset` of each
executable mapping as the object's load bias. That is only the bias when the
executable PT_LOAD segment satisfies p_vaddr == p_offset.

It usually does for a simple C program -- benchmarks/toy has off=0x1000 against
vaddr=0x1000 -- so the bug stayed hidden. rustc's output does not: the Rust toy
has off=0x14f80 against vaddr=0x15f80, and the computed bias came out 0x1000 too
large. Every line range was then shifted by a page.

The consequence is worse than a crash, because nothing looks wrong. Most samples
still land inside *some* line, so coz reports a confident, plausible profile
attributed to the wrong source lines. Measured on x86_64 with the Rust toy:

                        samples matched     hottest line reported
  before   39049 / 43506  (89.8%)          rust/examples/toy.rs:5   (thread::spawn)
  after    43492 / 43513  (99.95%)         rust/src/lib.rs:164      (the atomic add)

Line 5 is where the toy spawns its threads; it burns no time there. Line 164 is
the counter increment the toy actually runs 4.4e9 times.

On some layouts the shift lands outside every range and nothing matches at all,
which is what CI hit: the Rust toy produced 10242 perf samples and matched zero
of them, while the C toy in the same job matched 23300 of 23300.

dl_iterate_phdr reports dlpi_addr, which *is* the bias, for the main executable
and every shared object. Use it. The main executable comes back with an empty
dlpi_name, so resolve it through /proc/self/exe; objects with no executable
PT_LOAD cannot contain a sampled PC and are skipped, as are synthetic ones like
the vDSO that have no file to read DWARF from.

benchmarks/toy is unchanged: 107488/107525 samples matched, toy.cpp:18 at slope
0.504. macOS is untouched -- it has its own get_loaded_files() using dyld.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit b050352)
The toy's loop body was nothing but coz::progress!(), so every sample
landed in the inlined counter-increment code from rust/src/lib.rs and no
experiment ever selected a line in toy.rs, failing the Linux CI check.
Mirror benchmarks/toy/toy.cpp instead: two threads run unequal amounts
of busy work with a progress point per work unit, keeping the hot lines
in this file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@emeryberger
emeryberger merged commit a674b0b into master Jul 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants