Skip to content

Commit 4ca70cf

Browse files
wan9chiclaude
andcommitted
fix(fspy): intercept close$NOCANCEL on macOS so libuv (Node) closes fire
On macOS, libuv (and thus Node) closes descriptors via `close$NOCANCEL`, a distinct libc symbol from `close`. The preload only interposed `close`, so a write-close from Node was never observed: its `Closing` callback never fired. For worldline this meant `fs.writeFileSync` wrote the file but the run captured zero writes ("No file writes were captured"); Rust's std uses plain `close`, which is why it worked and hid the gap. Interpose `close$NOCANCEL` onto the same hook (forwarding through the regular `close`, which closes the descriptor identically). Add two `#[ignore]` Node regression tests, run in CI's `--ignored` step on every platform: one at the fspy callback layer asserting Opened+Closing both fire for `fs.writeFileSync`, and one at the worldline layer mirroring the `worldline node` scenario and asserting the captured write's before/after content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a91f4c3 commit 4ca70cf

3 files changed

Lines changed: 101 additions & 0 deletions

File tree

crates/fspy/tests/file_callback.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,53 @@ async fn close_callback_fires_before_close() -> anyhow::Result<()> {
245245
Ok(())
246246
}
247247

248+
/// Regression: Node's libuv closes descriptors via `close$NOCANCEL` on macOS, a
249+
/// distinct libc symbol from `close`. The preload must interpose it too, or the
250+
/// write-close is never observed. Asserts both `Opened` and `Closing` fire for a
251+
/// real `fs.writeFileSync`. (On Linux/Windows Node closes via plain `close`, so
252+
/// the test still validates the open/close pair there.)
253+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
254+
#[ignore = "requires node"]
255+
async fn close_callback_fires_for_node_write() -> anyhow::Result<()> {
256+
use std::env::vars_os;
257+
258+
let dir = tempfile::tempdir()?;
259+
let dir_path = std::fs::canonicalize(dir.path())?;
260+
let out = dir_path.join("node-out.txt");
261+
262+
let kinds: Arc<Mutex<Vec<FileEventKind>>> = Arc::new(Mutex::new(Vec::new()));
263+
let callback = {
264+
let (dir_path, kinds) = (dir_path.clone(), Arc::clone(&kinds));
265+
move |event: FileEvent<'_>| {
266+
let Some(path) = event.path.get() else {
267+
return;
268+
};
269+
if path.starts_with(&dir_path) {
270+
kinds.lock().unwrap().push(event.kind);
271+
}
272+
}
273+
};
274+
275+
let mut command = fspy::Command::new("node");
276+
command
277+
.arg("-e")
278+
.arg("require('fs').writeFileSync(process.argv[1], 'x')")
279+
.arg(out.as_os_str())
280+
.envs(vars_os()); // https://github.com/jdx/mise/discussions/5968
281+
command.on_file_event(AccessMode::WRITE, callback);
282+
let child = command.spawn(CancellationToken::new()).await?;
283+
let termination = child.wait_handle.await?;
284+
assert!(termination.status.success());
285+
286+
let kinds = kinds.lock().unwrap().clone();
287+
assert!(kinds.contains(&FileEventKind::Opened), "expected an Opened event, got {kinds:?}");
288+
assert!(
289+
kinds.contains(&FileEventKind::Closing),
290+
"expected a Closing event for node's write (close$NOCANCEL must be intercepted), got {kinds:?}"
291+
);
292+
Ok(())
293+
}
294+
248295
/// The access-mode mask filters which events reach the callback.
249296
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
250297
async fn mask_filters_events() -> anyhow::Result<()> {

crates/fspy_preload_unix/src/interceptions/close.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ unsafe extern "C" fn close(fd: c_int) -> c_int {
1010
unsafe { close::original()(fd) }
1111
}
1212

13+
// On macOS, libuv (Node) closes descriptors via the cancellation-point-free
14+
// `close$NOCANCEL`, a distinct symbol from `close`. Interpose it onto the same
15+
// hook so those closes are observed too; forwarding goes through the regular
16+
// `close` (which closes the descriptor identically).
17+
#[cfg(target_os = "macos")]
18+
const _: () = {
19+
unsafe extern "C" {
20+
#[link_name = "close$NOCANCEL"]
21+
fn close_nocancel(fd: c_int) -> c_int;
22+
}
23+
#[used]
24+
#[unsafe(link_section = "__DATA,__interpose")]
25+
static mut _INTERPOSE_CLOSE_NOCANCEL: crate::macros::InterposeEntry =
26+
crate::macros::InterposeEntry { _new: close as *const _, _old: close_nocancel as *const _ };
27+
};
28+
1329
intercept!(fclose(64): unsafe extern "C" fn(stream: *mut FILE) -> c_int);
1430
unsafe extern "C" fn fclose(stream: *mut FILE) -> c_int {
1531
if !stream.is_null() {

crates/worldline/tests/capture.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,41 @@ async fn captures_writes_with_before_and_after() {
9191
"non-UTF-8 img.bin should be served as a base64 blob"
9292
);
9393
}
94+
95+
/// Regression for the user-facing `worldline node` scenario: Node's libuv closes
96+
/// descriptors via `close$NOCANCEL` on macOS — a distinct libc symbol from
97+
/// `close`. If fspy doesn't interpose it, the write-close is never observed, so
98+
/// `fs.writeFileSync` writes the file but worldline captures zero writes ("No
99+
/// file writes were captured"). Requires a real `node` on PATH; gated `#[ignore]`
100+
/// per the repo's Node-test convention and run in CI's `--ignored` step.
101+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
102+
#[ignore = "requires node"]
103+
async fn captures_node_write_file_sync() {
104+
let dir = tempfile::tempdir().unwrap();
105+
let cwd = AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();
106+
let ignore = IgnoreSet::new(cwd.clone(), true, &[]).unwrap();
107+
108+
let captured = run(RunOptions {
109+
program: "node".into(),
110+
args: vec!["-e".into(), "require('fs').writeFileSync('node-out.txt', 'HELLO-NODE')".into()],
111+
cwd,
112+
ignore,
113+
})
114+
.await
115+
.expect("run worldline under node");
116+
117+
assert_eq!(captured.meta.exit_code, Some(0), "node should exit cleanly");
118+
119+
let api = reconstruct(&captured);
120+
let w = api
121+
.writes
122+
.iter()
123+
.find(|w| w.path.ends_with("node-out.txt"))
124+
.expect("a captured write to node-out.txt (close$NOCANCEL must be intercepted)");
125+
assert_eq!(text(&api, w.before.as_str()).as_deref(), Some(""), "before = empty (new file)");
126+
assert_eq!(
127+
text(&api, w.after.as_str()).as_deref(),
128+
Some("HELLO-NODE"),
129+
"after = the content node wrote"
130+
);
131+
}

0 commit comments

Comments
 (0)