Skip to content

Commit 341799a

Browse files
wan9chicodex
andcommitted
fix(fspy): track Bun file accesses on macOS
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 2ba033c commit 341799a

9 files changed

Lines changed: 264 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,16 @@ jobs:
132132
with:
133133
architecture: ${{ matrix.target == 'x86_64-apple-darwin' && 'x64' || '' }}
134134

135-
- name: Install Chromium
136-
run: pnpm --filter vite-task-tools rebuild @playwright/browser-chromium
135+
- name: Install runtime binaries
136+
run: |
137+
if [[ '${{ matrix.target }}' == 'x86_64-apple-darwin' ]]; then
138+
# Bun normally prefers the native arm64 binary under Rosetta, but
139+
# this job needs x64 Bun so the x64 preload dylib can be injected.
140+
PATH="$(dirname "$(command -v node)"):$PNPM_HOME:/usr/bin:/bin" \
141+
pnpm --filter vite-task-tools rebuild --pending
142+
else
143+
pnpm --filter vite-task-tools rebuild --pending
144+
fi
137145
138146
- name: Install Chromium system dependencies
139147
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}
@@ -267,9 +275,9 @@ jobs:
267275
- uses: oxc-project/setup-node@4c588e9266bd930b6ddc34307df0659ed511d187 # v1.3.1
268276
if: matrix.mode == 'ignored'
269277

270-
- name: Install Chromium
278+
- name: Install runtime binaries
271279
if: matrix.mode == 'ignored'
272-
run: pnpm --filter vite-task-tools rebuild @playwright/browser-chromium
280+
run: pnpm --filter vite-task-tools rebuild --pending
273281

274282
- name: Run ignored tests
275283
if: matrix.mode == 'ignored'

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Fixed** An issue where Bun tasks on macOS did not rerun when files they read, wrote, or listed changed ([#532](https://github.com/voidzero-dev/vite-task/issues/532), [#542](https://github.com/voidzero-dev/vite-task/pull/542)).
34
- **Improved** Windows file-access tracking now uses sparse temporary backing files where supported, avoiding upfront allocation of the full backing file on disk ([#524](https://github.com/voidzero-dev/vite-task/pull/524)).
45
- **Fixed** Automatic file-access tracking on Linux now works in containers and Kubernetes runners with limited `/dev/shm` space ([#353](https://github.com/voidzero-dev/vite-task/issues/353), [#523](https://github.com/voidzero-dev/vite-task/pull/523)).
56
- **Fixed** Windows automatic input tracking now records executable image reads when process creation performs the lookup inside `NtCreateUserProcess` ([#518](https://github.com/voidzero-dev/vite-task/pull/518)).

crates/fspy/tests/node_fs.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,13 @@ fn track_script(
5353
})
5454
}
5555

56-
// Deno does not distribute a musl runtime, so only its generated cases are
57-
// excluded there. Node remains covered by the same tests on musl.
56+
// Bun's Linux runtime uses direct syscalls, which require universal seccomp
57+
// tracing instead of preload interception. Deno does not distribute a musl
58+
// runtime. Node remains covered on every target.
5859
#[test_case("node")]
5960
#[ignore = "requires node"]
61+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
62+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
6063
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
6164
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
6265
fn read_sync(runtime: &str) -> anyhow::Result<()> {
@@ -67,6 +70,8 @@ fn read_sync(runtime: &str) -> anyhow::Result<()> {
6770

6871
#[test_case("node")]
6972
#[ignore = "requires node"]
73+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
74+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
7075
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
7176
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
7277
fn exist_sync(runtime: &str) -> anyhow::Result<()> {
@@ -77,6 +82,8 @@ fn exist_sync(runtime: &str) -> anyhow::Result<()> {
7782

7883
#[test_case("node")]
7984
#[ignore = "requires node"]
85+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
86+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
8087
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
8188
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
8289
fn stat_sync(runtime: &str) -> anyhow::Result<()> {
@@ -87,6 +94,8 @@ fn stat_sync(runtime: &str) -> anyhow::Result<()> {
8794

8895
#[test_case("node")]
8996
#[ignore = "requires node"]
97+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
98+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
9099
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
91100
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
92101
fn create_read_stream(runtime: &str) -> anyhow::Result<()> {
@@ -101,6 +110,8 @@ fn create_read_stream(runtime: &str) -> anyhow::Result<()> {
101110

102111
#[test_case("node")]
103112
#[ignore = "requires node"]
113+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
114+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
104115
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
105116
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
106117
fn create_write_stream(runtime: &str) -> anyhow::Result<()> {
@@ -117,6 +128,8 @@ fn create_write_stream(runtime: &str) -> anyhow::Result<()> {
117128

118129
#[test_case("node")]
119130
#[ignore = "requires node"]
131+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
132+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
120133
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
121134
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
122135
fn write_sync(runtime: &str) -> anyhow::Result<()> {
@@ -133,6 +146,8 @@ fn write_sync(runtime: &str) -> anyhow::Result<()> {
133146

134147
#[test_case("node")]
135148
#[ignore = "requires node"]
149+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
150+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
136151
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
137152
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
138153
fn read_dir_sync(runtime: &str) -> anyhow::Result<()> {
@@ -143,6 +158,8 @@ fn read_dir_sync(runtime: &str) -> anyhow::Result<()> {
143158

144159
#[test_case("node")]
145160
#[ignore = "requires node"]
161+
#[cfg_attr(not(target_os = "linux"), test_case("bun"))]
162+
#[cfg_attr(not(target_os = "linux"), ignore = "requires node")]
146163
#[cfg_attr(not(target_env = "musl"), test_case("deno"))]
147164
#[cfg_attr(not(target_env = "musl"), ignore = "requires node")]
148165
fn subprocess(runtime: &str) -> anyhow::Result<()> {

crates/fspy_preload_unix/src/interceptions/dirent.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ unsafe extern "C" fn scandir(
2626

2727
#[cfg(target_os = "macos")]
2828
mod macos_only {
29-
use super::{AccessMode, c_char, c_int, c_void, handle_open, intercept};
29+
use super::{AccessMode, Fd, c_char, c_int, c_void, handle_open, intercept};
30+
3031
intercept!(scandir_b: unsafe extern "C" fn (
3132
dirname: *const c_char,
3233
namelist: *mut c_void,
@@ -44,6 +45,19 @@ mod macos_only {
4445
// SAFETY: calling the original libc scandir_b() with the same arguments forwarded from the interposed function
4546
unsafe { scandir_b::original()(dirname, namelist, select, compar) }
4647
}
48+
49+
intercept!(__getdirentries64: unsafe extern "C" fn(c_int, *mut u8, usize, *mut i64) -> isize);
50+
unsafe extern "C" fn __getdirentries64(
51+
fd: c_int,
52+
buf: *mut u8,
53+
buf_len: usize,
54+
basep: *mut i64,
55+
) -> isize {
56+
// SAFETY: fd is a valid file descriptor provided by the caller of __getdirentries64
57+
unsafe { handle_open(Fd(fd), AccessMode::READ_DIR) };
58+
// SAFETY: calling the original libc __getdirentries64() with the same arguments forwarded from the interposed function
59+
unsafe { __getdirentries64::original()(fd, buf, buf_len, basep) }
60+
}
4761
}
4862

4963
intercept!(getdirentries(64): unsafe extern "C" fn (fd: c_int, buf: *mut c_char, nbytes: c_int, basep: *mut c_long) -> c_int);

crates/fspy_preload_unix/src/interceptions/open.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,45 @@ unsafe extern "C" fn openat(
6262
}
6363
}
6464

65+
#[cfg(target_os = "macos")]
66+
intercept!(open_nocancel: unsafe extern "C" fn(*const c_char, c_int, ...) -> c_int);
67+
#[cfg(target_os = "macos")]
68+
unsafe extern "C" fn open_nocancel(path: *const c_char, flags: c_int, mut args: ...) -> c_int {
69+
// SAFETY: path is a valid C string pointer provided by the caller of open$NOCANCEL
70+
unsafe { handle_open(path, OpenFlags(flags)) };
71+
if has_mode_arg(flags) {
72+
// SAFETY: O_CREAT requires a mode argument, matching the open$NOCANCEL contract
73+
let mode: Mode = unsafe { args.next_arg() };
74+
// SAFETY: calling the original libc open$NOCANCEL() with the same arguments forwarded from the interposed function
75+
unsafe { open_nocancel::original()(path, flags, mode) }
76+
} else {
77+
// SAFETY: calling the original libc open$NOCANCEL() with the same arguments forwarded from the interposed function
78+
unsafe { open_nocancel::original()(path, flags) }
79+
}
80+
}
81+
82+
#[cfg(target_os = "macos")]
83+
intercept!(openat_nocancel: unsafe extern "C" fn(c_int, *const c_char, c_int, ...) -> c_int);
84+
#[cfg(target_os = "macos")]
85+
unsafe extern "C" fn openat_nocancel(
86+
dirfd: c_int,
87+
path: *const c_char,
88+
flags: c_int,
89+
mut args: ...
90+
) -> c_int {
91+
// SAFETY: dirfd and path are valid arguments provided by the caller of openat$NOCANCEL
92+
unsafe { handle_open(PathAt(dirfd, path), OpenFlags(flags)) };
93+
if has_mode_arg(flags) {
94+
// SAFETY: O_CREAT requires a mode argument, matching the openat$NOCANCEL contract
95+
let mode: Mode = unsafe { args.next_arg() };
96+
// SAFETY: calling the original libc openat$NOCANCEL() with the same arguments forwarded from the interposed function
97+
unsafe { openat_nocancel::original()(dirfd, path, flags, mode) }
98+
} else {
99+
// SAFETY: calling the original libc openat$NOCANCEL() with the same arguments forwarded from the interposed function
100+
unsafe { openat_nocancel::original()(dirfd, path, flags) }
101+
}
102+
}
103+
65104
intercept!(fopen(64): unsafe extern "C" fn(path: *const c_char, mode: *const c_char) -> *mut FILE);
66105
unsafe extern "C" fn fopen(path: *const c_char, mode: *const c_char) -> *mut libc::FILE {
67106
// SAFETY: path and mode are valid C string pointers provided by the caller of the interposed function

crates/fspy_preload_unix/src/libc.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,20 @@ unsafe extern "C" {
2727
nbytes: c_int,
2828
basep: *mut c_long,
2929
) -> c_int;
30+
31+
#[cfg(target_os = "macos")]
32+
#[link_name = "open$NOCANCEL"]
33+
pub unsafe fn open_nocancel(path: *const c_char, flags: c_int, ...) -> c_int;
34+
35+
#[cfg(target_os = "macos")]
36+
#[link_name = "openat$NOCANCEL"]
37+
pub unsafe fn openat_nocancel(dirfd: c_int, path: *const c_char, flags: c_int, ...) -> c_int;
38+
39+
#[cfg(target_os = "macos")]
40+
pub unsafe fn __getdirentries64(
41+
fd: c_int,
42+
buf: *mut u8,
43+
buf_len: usize,
44+
basep: *mut i64,
45+
) -> isize;
3046
}

packages/tools/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"@playwright/browser-chromium": "catalog:",
77
"@vitest/browser-playwright": "catalog:",
88
"@voidzero-dev/vite-task-client": "workspace:*",
9+
"bun": "catalog:",
910
"cross-env": "catalog:",
1011
"deno": "catalog:",
1112
"oxfmt": "catalog:",

0 commit comments

Comments
 (0)