Skip to content

Commit ef64d0f

Browse files
branchseerclaude
andauthored
fix: fix flaky SIGSEGV on musl (#278)
## Summary Fix flaky SIGSEGV/SIGBUS crashes in `pty_terminal` tests on musl (Alpine Linux), plus infrastructure improvements for musl CI. ## Changes ### 1. Fix concurrent PTY SIGSEGV on musl (`RUST_TEST_THREADS=1`) On musl libc, `fork()` in multi-threaded processes triggers SIGSEGV in musl internals. When `cargo test` runs multiple test threads, each calling `openpty()` + `fork()`, musl's internal state gets corrupted. The fix sets `RUST_TEST_THREADS=1` in the musl CI job to serialize test execution. A `#[cfg(target_env = "musl")]` process-wide `Mutex` (`PTY_LOCK`) in `Terminal::spawn()` serializes PTY spawn and cleanup operations as a defense-in-depth measure. ### 2. Dynamic musl libc linking (`-C target-feature=-crt-static`) vite-task is shipped as a NAPI module in vite+, and musl Node with native modules links to musl libc dynamically. Set `RUSTFLAGS` with `-C target-feature=-crt-static` for the musl CI job. ### 3. Use `signalfd` for Linux signal handling in tests Replace `signal_hook::low_level::register` (unsafe signal handler) with `nix::sys::signalfd::SignalFd` (safe file descriptor) in the `send_ctrl_c_interrupts_process` test on Linux. macOS/Windows continue using the `ctrlc` crate. ## Verification - Musl tests passed in **8+ consecutive CI runs** (mix of push-triggered and workflow_dispatch) - All platforms (Linux glibc, Linux musl, macOS arm64/x86, Windows) pass --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7b0d0a commit ef64d0f

File tree

8 files changed

+97
-41
lines changed

8 files changed

+97
-41
lines changed

.github/workflows/ci.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,14 @@ jobs:
144144
env:
145145
# Override all rustflags to skip the zig cross-linker from .cargo/config.toml.
146146
# Alpine's cc is already musl-native, so no custom linker is needed.
147-
# Must mirror [build].rustflags from .cargo/config.toml.
148-
RUSTFLAGS: --cfg tokio_unstable -D warnings
147+
# Must mirror [build].rustflags and target rustflags from .cargo/config.toml
148+
# (RUSTFLAGS env var overrides both levels).
149+
# -crt-static: vite-task is shipped as a NAPI module in vite+, and musl Node
150+
# with native modules links to musl libc dynamically, so we must do the same.
151+
RUSTFLAGS: --cfg tokio_unstable -D warnings -C target-feature=-crt-static
152+
# On musl, concurrent PTY operations can trigger SIGSEGV in musl internals.
153+
# Run test threads sequentially to avoid the race.
154+
RUST_TEST_THREADS: 1
149155
steps:
150156
- name: Install Alpine dependencies
151157
shell: sh {0}

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ jsonc-parser = { version = "0.29.0", features = ["serde"] }
8585
libc = "0.2.172"
8686
memmap2 = "0.9.7"
8787
monostate = "1.0.2"
88-
nix = { version = "0.30.1", features = ["dir"] }
88+
nix = { version = "0.30.1", features = ["dir", "signal"] }
8989
ntapi = "0.4.1"
9090
nucleo-matcher = "0.3.1"
9191
once_cell = "1.19"

crates/fspy/tests/static_executable.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
#![cfg(target_os = "linux")]
1+
//! Tests for fspy tracing of statically-linked executables (seccomp path).
2+
//! Skipped on musl: the test binary is an artifact dep targeting musl, and when
3+
//! the CI builds with `-crt-static` the binary becomes dynamically linked,
4+
//! defeating the purpose of these tests.
5+
#![cfg(all(target_os = "linux", not(target_env = "musl")))]
26
use std::{
37
fs::{self, Permissions},
48
os::unix::fs::PermissionsExt as _,

crates/pty_terminal/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ subprocess_test = { workspace = true, features = ["portable-pty"] }
2020
terminal_size = "0.4"
2121

2222
[target.'cfg(unix)'.dev-dependencies]
23+
nix = { workspace = true }
2324
signal-hook = "0.3"
2425

2526
[lints]

crates/pty_terminal/src/terminal.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ impl Terminal {
256256
///
257257
/// Panics if the writer lock is poisoned when the background thread closes it.
258258
pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result<Self> {
259+
// On musl libc (Alpine Linux), concurrent PTY operations trigger
260+
// SIGSEGV/SIGBUS in musl internals (sysconf, fcntl). This affects
261+
// both openpty+fork and FD cleanup (close) from background threads.
262+
// Serialize all PTY lifecycle operations that touch musl internals.
263+
#[cfg(target_env = "musl")]
264+
static PTY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
265+
#[cfg(target_env = "musl")]
266+
let _spawn_guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
267+
259268
let pty_pair = portable_pty::native_pty_system().openpty(portable_pty::PtySize {
260269
rows: size.rows,
261270
cols: size.cols,
@@ -286,6 +295,10 @@ impl Terminal {
286295
let slave = pty_pair.slave;
287296
move || {
288297
let _ = exit_status.set(child.wait().map_err(Arc::new));
298+
// On musl, serialize FD cleanup (close) with PTY spawn to
299+
// prevent racing on musl-internal state.
300+
#[cfg(target_env = "musl")]
301+
let _cleanup_guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
289302
// Close writer first, then drop slave to trigger EOF on the reader.
290303
*writer.lock().unwrap() = None;
291304
drop(slave);

crates/pty_terminal/tests/terminal.rs

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ fn is_terminal() {
1616
println!("{} {} {}", stdin().is_terminal(), stdout().is_terminal(), stderr().is_terminal());
1717
}));
1818

19-
let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } =
19+
let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle, .. } =
2020
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
2121
let mut discard = Vec::new();
2222
pty_reader.read_to_end(&mut discard).unwrap();
@@ -40,7 +40,7 @@ fn write_basic_echo() {
4040
}
4141
}));
4242

43-
let Terminal { mut pty_reader, mut pty_writer, child_handle } =
43+
let Terminal { mut pty_reader, mut pty_writer, child_handle, .. } =
4444
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
4545

4646
pty_writer.write_line(b"hello world").unwrap();
@@ -71,7 +71,7 @@ fn write_multiple_lines() {
7171
}
7272
}));
7373

74-
let Terminal { mut pty_reader, mut pty_writer, child_handle } =
74+
let Terminal { mut pty_reader, mut pty_writer, child_handle, .. } =
7575
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
7676

7777
pty_writer.write_line(b"first").unwrap();
@@ -113,7 +113,7 @@ fn write_after_exit() {
113113
print!("exiting");
114114
}));
115115

116-
let Terminal { mut pty_reader, mut pty_writer, child_handle } =
116+
let Terminal { mut pty_reader, mut pty_writer, child_handle, .. } =
117117
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
118118

119119
// Read all output - this blocks until child exits and EOF is reached
@@ -149,7 +149,7 @@ fn write_interactive_prompt() {
149149
stdout.flush().unwrap();
150150
}));
151151

152-
let Terminal { mut pty_reader, mut pty_writer, child_handle } =
152+
let Terminal { mut pty_reader, mut pty_writer, child_handle, .. } =
153153
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
154154

155155
// Wait for prompt "Name: " (read until the space after colon)
@@ -240,7 +240,7 @@ fn resize_terminal() {
240240
stdout().flush().unwrap();
241241
}));
242242

243-
let Terminal { mut pty_reader, mut pty_writer, child_handle: _ } =
243+
let Terminal { mut pty_reader, mut pty_writer, child_handle: _, .. } =
244244
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
245245

246246
// Wait for initial size line (synchronize before resizing)
@@ -275,43 +275,74 @@ fn send_ctrl_c_interrupts_process() {
275275
let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| {
276276
use std::io::{Write, stdout};
277277

278-
// On Windows, clear the "ignore CTRL_C" flag set by Rust runtime
279-
// so that CTRL_C_EVENT reaches the ctrlc handler.
280-
#[cfg(windows)]
278+
// On Linux, use signalfd to wait for SIGINT without signal handlers or
279+
// background threads. This avoids musl issues where threads spawned during
280+
// .init_array (via ctor) are blocked by musl's internal lock.
281+
#[cfg(target_os = "linux")]
281282
{
282-
// SAFETY: Declaring correct signature for SetConsoleCtrlHandler from kernel32.
283-
unsafe extern "system" {
284-
fn SetConsoleCtrlHandler(
285-
handler: Option<unsafe extern "system" fn(u32) -> i32>,
286-
add: i32,
287-
) -> i32;
288-
}
283+
use nix::sys::{
284+
signal::{SigSet, Signal},
285+
signalfd::SignalFd,
286+
};
289287

290-
// SAFETY: Clearing the "ignore CTRL_C" flag so handlers are invoked.
291-
unsafe {
292-
SetConsoleCtrlHandler(None, 0); // FALSE = remove ignore
293-
}
294-
}
288+
// Block SIGINT so it goes to signalfd instead of the default handler.
289+
let mut mask = SigSet::empty();
290+
mask.add(Signal::SIGINT);
291+
mask.thread_block().unwrap();
295292

296-
ctrlc::set_handler(move || {
297-
// Write directly and exit from the handler to avoid races.
298-
use std::io::Write;
299-
let _ = write!(std::io::stdout(), "INTERRUPTED");
300-
let _ = std::io::stdout().flush();
293+
let sfd = SignalFd::new(&mask).unwrap();
294+
295+
println!("ready");
296+
stdout().flush().unwrap();
297+
298+
// Block until SIGINT arrives via signalfd.
299+
sfd.read_signal().unwrap().unwrap();
300+
print!("INTERRUPTED");
301+
stdout().flush().unwrap();
301302
std::process::exit(0);
302-
})
303-
.unwrap();
303+
}
304304

305-
println!("ready");
306-
stdout().flush().unwrap();
305+
// On macOS/Windows, use ctrlc which works fine (no .init_array/musl issue).
306+
#[cfg(not(target_os = "linux"))]
307+
{
308+
// On Windows, clear the "ignore CTRL_C" flag set by Rust runtime
309+
// so that CTRL_C_EVENT reaches the ctrlc handler.
310+
#[cfg(windows)]
311+
{
312+
// SAFETY: Declaring correct signature for SetConsoleCtrlHandler from kernel32.
313+
unsafe extern "system" {
314+
fn SetConsoleCtrlHandler(
315+
handler: Option<unsafe extern "system" fn(u32) -> i32>,
316+
add: i32,
317+
) -> i32;
318+
}
319+
320+
// SAFETY: Clearing the "ignore CTRL_C" flag so handlers are invoked.
321+
unsafe {
322+
SetConsoleCtrlHandler(None, 0); // FALSE = remove ignore
323+
}
324+
}
325+
326+
ctrlc::set_handler(move || {
327+
// Write directly and exit from the handler to avoid races.
328+
use std::io::Write;
329+
let _ = write!(std::io::stdout(), "INTERRUPTED");
330+
let _ = std::io::stdout().flush();
331+
std::process::exit(0);
332+
})
333+
.unwrap();
307334

308-
// Block until Ctrl+C handler exits the process.
309-
loop {
310-
std::thread::park();
335+
println!("ready");
336+
stdout().flush().unwrap();
337+
338+
// Block until Ctrl+C handler exits the process.
339+
loop {
340+
std::thread::park();
341+
}
311342
}
312343
}));
313344

314-
let Terminal { mut pty_reader, mut pty_writer, child_handle: _ } =
345+
let Terminal { mut pty_reader, mut pty_writer, child_handle: _, .. } =
315346
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
316347

317348
// Wait for process to be ready
@@ -342,7 +373,7 @@ fn read_to_end_returns_exit_status_success() {
342373
println!("success");
343374
}));
344375

345-
let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } =
376+
let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle, .. } =
346377
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
347378
let mut discard = Vec::new();
348379
pty_reader.read_to_end(&mut discard).unwrap();
@@ -358,7 +389,7 @@ fn read_to_end_returns_exit_status_nonzero() {
358389
std::process::exit(42);
359390
}));
360391

361-
let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle } =
392+
let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle, .. } =
362393
Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap();
363394
let mut discard = Vec::new();
364395
pty_reader.read_to_end(&mut discard).unwrap();

crates/pty_terminal_test/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ impl TestTerminal {
3434
///
3535
/// Returns an error if the PTY cannot be opened or the command fails to spawn.
3636
pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result<Self> {
37-
let Terminal { pty_reader, pty_writer, child_handle } = Terminal::spawn(size, cmd)?;
37+
let Terminal { pty_reader, pty_writer, child_handle, .. } = Terminal::spawn(size, cmd)?;
3838
Ok(Self {
3939
writer: pty_writer,
4040
reader: Reader { pty: BufReader::new(pty_reader), child_handle: child_handle.clone() },

0 commit comments

Comments
 (0)