From db5c6ef6b5d27a594f3a173e5177432a2b8d55a4 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 14 Oct 2026 14:58:25 +0800 Subject: [PATCH 1/4] fix(fspy): avoid recursive syscall initialization Co-authored-by: GPT-6 --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/fspy/tests/rust_std.rs | 35 ++++++++++ crates/fspy_preload_unix/Cargo.toml | 3 + .../src/interceptions/linux_syscall.rs | 68 ++++++++++++++++++- 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9e10174..164f8d69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). - **Fixed** Reinstalling dependencies with pnpm, or installing them at another path or with another pnpm version, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825), [#850](https://github.com/voidzero-dev/vite-task/pull/850)). diff --git a/Cargo.lock b/Cargo.lock index e2816bdb6..99c4b31fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1479,6 +1479,7 @@ dependencies = [ "libc", "nix 0.31.2", "static_cell", + "syscalls", ] [[package]] diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index 2f84fbb2e..91895b4cc 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -51,6 +51,41 @@ async fn metadata() -> anyhow::Result<()> { Ok(()) } +#[test(tokio::test)] +async fn concurrent_metadata() -> anyhow::Result<()> { + let tmp_dir = tempfile::tempdir()?; + let tmp_path = tmp_dir.path().join("hello"); + fs::write(&tmp_path, b"hello")?; + let tmp_path_str = tmp_path.to_str().unwrap().to_owned(); + + let accesses = track_fn!(tmp_path_str, |tmp_path_str: String| { + // Release the workers together without calling futex before the file + // accesses. On Linux, concurrent first calls to the syscall interposer + // used to recurse through LazyLock's own futex wait and overflow. + let start = std::sync::atomic::AtomicBool::new(false); + let ready = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..32 { + scope.spawn(|| { + ready.fetch_add(1, std::sync::atomic::Ordering::Release); + while !start.load(std::sync::atomic::Ordering::Acquire) { + std::hint::spin_loop(); + } + assert_eq!(fs::metadata(&tmp_path_str).unwrap().len(), 5); + }); + } + while ready.load(std::sync::atomic::Ordering::Acquire) != 32 { + std::hint::spin_loop(); + } + start.store(true, std::sync::atomic::Ordering::Release); + }); + }) + .await?; + assert_contains(&accesses, tmp_path.as_path(), PathAccess::Read); + + Ok(()) +} + #[test(tokio::test)] async fn readdir() -> anyhow::Result<()> { let tmpdir = tempfile::tempdir()?; diff --git a/crates/fspy_preload_unix/Cargo.toml b/crates/fspy_preload_unix/Cargo.toml index e74c0d632..8c9a1dc66 100644 --- a/crates/fspy_preload_unix/Cargo.toml +++ b/crates/fspy_preload_unix/Cargo.toml @@ -19,5 +19,8 @@ allocator-api2 = { workspace = true } fspy_nostd_alloc = { workspace = true } static_cell = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +syscalls = { workspace = true } + [lints] workspace = true diff --git a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs index 8a22bfb17..0f4bfa4e6 100644 --- a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs +++ b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs @@ -48,6 +48,70 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long { }; } } - // SAFETY: forwarding the syscall to the original libc syscall function with the extracted arguments - unsafe { syscall::original()(syscall_no, a0, a1, a2, a3, a4, a5) } + // Do not resolve libc's syscall through LazyLock: concurrent first calls + // wait through futex, which calls this interposer again before the lock + // is initialized. Forward directly to the kernel instead. + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "c_long and usize have the same width on supported Linux targets; preserve their bit patterns" + )] + // SAFETY: forward the syscall number and arguments supplied by the caller. + let result = unsafe { + syscalls::raw::syscall6( + syscall_no as usize, + a0 as usize, + a1 as usize, + a2 as usize, + a3 as usize, + a4 as usize, + a5 as usize, + ) + }; + match syscalls::Errno::from_ret(result) { + #[expect(clippy::cast_possible_wrap, reason = "preserve the syscall result's bit pattern")] + Ok(value) => value as c_long, + Err(errno) => { + // SAFETY: __errno_location returns this thread's errno storage. + unsafe { *libc::__errno_location() = errno.into_raw() }; + -1 + } + } +} + +#[cfg(test)] +mod tests { + use super::syscall; + + #[test] + fn success_preserves_errno() { + // SAFETY: errno is thread-local; getpid takes no pointer arguments. + unsafe { + *libc::__errno_location() = libc::EBUSY; + assert_eq!( + syscall(libc::SYS_getpid, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64), + libc::getpid().into() + ); + assert_eq!(*libc::__errno_location(), libc::EBUSY); + } + } + + #[test] + fn error_sets_errno() { + // SAFETY: close rejects an invalid descriptor without accessing memory. + unsafe { + *libc::__errno_location() = 0; + assert_eq!(syscall(libc::SYS_close, -1_i64, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64), -1); + assert_eq!(*libc::__errno_location(), libc::EBADF); + } + } + + #[test] + fn unknown_syscall_sets_enosys() { + // SAFETY: the kernel rejects this nonexistent syscall number. + unsafe { + assert_eq!(syscall(-1, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64), -1); + assert_eq!(*libc::__errno_location(), libc::ENOSYS); + } + } } From 3286def5146f7a65f82cd676d218bbc3d5fed016 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 14 Oct 2026 15:03:20 +0800 Subject: [PATCH 2/4] docs: link syscall fix in changelog Co-authored-by: GPT-6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 164f8d69c..edd3132ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). - **Fixed** Reinstalling dependencies with pnpm, or installing them at another path or with another pnpm version, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825), [#850](https://github.com/voidzero-dev/vite-task/pull/850)). From e637db31dcc2b990d059e0e20303698979b36000 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Wed, 14 Oct 2026 23:26:58 +0800 Subject: [PATCH 3/4] test(fspy): drop redundant syscall errno tests Co-Authored-By: Claude Opus 5.5 --- .../src/interceptions/linux_syscall.rs | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs index 0f4bfa4e6..a9d7750c0 100644 --- a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs +++ b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs @@ -83,19 +83,6 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long { mod tests { use super::syscall; - #[test] - fn success_preserves_errno() { - // SAFETY: errno is thread-local; getpid takes no pointer arguments. - unsafe { - *libc::__errno_location() = libc::EBUSY; - assert_eq!( - syscall(libc::SYS_getpid, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64), - libc::getpid().into() - ); - assert_eq!(*libc::__errno_location(), libc::EBUSY); - } - } - #[test] fn error_sets_errno() { // SAFETY: close rejects an invalid descriptor without accessing memory. @@ -105,13 +92,4 @@ mod tests { assert_eq!(*libc::__errno_location(), libc::EBADF); } } - - #[test] - fn unknown_syscall_sets_enosys() { - // SAFETY: the kernel rejects this nonexistent syscall number. - unsafe { - assert_eq!(syscall(-1, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64, 0_i64), -1); - assert_eq!(*libc::__errno_location(), libc::ENOSYS); - } - } } From 449691b887297c581dcc3cc136d0eb2be8a2ebc0 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 15 Oct 2026 00:15:29 +0800 Subject: [PATCH 4/4] test(fspy): reproduce the syscall recursion deterministically Hold the dynamic linker's lock with dlopen on a FIFO so the first syscall caller blocks in dlsym while a second one calls syscall. Co-Authored-By: Claude Opus 5.5 --- crates/fspy/tests/rust_std.rs | 59 +++++++++++++++++------------------ 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index 91895b4cc..f7380182b 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -51,39 +51,38 @@ async fn metadata() -> anyhow::Result<()> { Ok(()) } +#[cfg(all(target_os = "linux", not(target_env = "musl")))] #[test(tokio::test)] -async fn concurrent_metadata() -> anyhow::Result<()> { - let tmp_dir = tempfile::tempdir()?; - let tmp_path = tmp_dir.path().join("hello"); - fs::write(&tmp_path, b"hello")?; - let tmp_path_str = tmp_path.to_str().unwrap().to_owned(); - - let accesses = track_fn!(tmp_path_str, |tmp_path_str: String| { - // Release the workers together without calling futex before the file - // accesses. On Linux, concurrent first calls to the syscall interposer - // used to recurse through LazyLock's own futex wait and overflow. - let start = std::sync::atomic::AtomicBool::new(false); - let ready = std::sync::atomic::AtomicUsize::new(0); - std::thread::scope(|scope| { - for _ in 0..32 { - scope.spawn(|| { - ready.fetch_add(1, std::sync::atomic::Ordering::Release); - while !start.load(std::sync::atomic::Ordering::Acquire) { - std::hint::spin_loop(); - } - assert_eq!(fs::metadata(&tmp_path_str).unwrap().len(), 5); - }); - } - while ready.load(std::sync::atomic::Ordering::Acquire) != 32 { - std::hint::spin_loop(); - } - start.store(true, std::sync::atomic::Ordering::Release); +async fn syscall_during_dlopen() { + let tmp_dir = tempfile::tempdir().unwrap(); + let fifo_path = tmp_dir.path().join("fifo").to_str().unwrap().to_owned(); + nix::unistd::mkfifo(fifo_path.as_str(), nix::sys::stat::Mode::S_IRWXU).unwrap(); + + track_fn!(fifo_path, |fifo_path: String| { + // The interposer used to resolve libc's syscall with dlsym, which waits for dlopen, + // so a second thread calling syscall waited for the first through futex, re-entering + // the interposer until its stack overflowed. + fn call_syscall_during_dlopen(fifo_path: &str) { + // This waits for dlopen to open the FIFO, which it reads until writers close. + let _writer = OpenOptions::new().write(true).open(fifo_path).unwrap(); + // SAFETY: getpid takes no arguments. + unsafe { libc::syscall(libc::SYS_getpid) }; + } + // Opening files and spawning threads can also wait for dlopen, so do both first. + File::open("/").unwrap(); + let path = fifo_path.clone(); + std::thread::spawn(move || { + let c = std::ffi::CString::new(path.as_str()).unwrap(); + // SAFETY: dlopen fails on the FIFO's empty contents. + std::thread::spawn(move || unsafe { + libc::dlopen(c.as_ptr(), libc::RTLD_NOW); + }); + call_syscall_during_dlopen(&path); }); + call_syscall_during_dlopen(&fifo_path); }) - .await?; - assert_contains(&accesses, tmp_path.as_path(), PathAccess::Read); - - Ok(()) + .await + .unwrap(); } #[test(tokio::test)]