Skip to content

node:fs: hand fs.watch and fs.watchFile paths to the OS as given on POSIX - #41986

Open
robobun wants to merge 4 commits into
mainfrom
robobun/b76711e5/watch-paths-as-given
Open

robobun wants to merge 4 commits into
mainfrom
robobun/b76711e5/watch-paths-as-given

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On POSIX fs.watch("a\\b") and fs.watchFile("a\\b") watched a/b: fs.watch threw ENOENT: no such file or directory, watch 'a\b' when only the literal file existed, and fs.watchFile reported the other file's stats. fs.watch("d/link/..") watched d/, not the parent of the link target. Node hands the string to inotify / kqueue / stat unchanged.
  • Cause: FSWatcher::init (src/runtime/node/node_fs_watcher.rs) and StatWatcher::init (node_fs_stat_watcher.rs) built the absolute path with join_abs_string_buf. Its Platform::Posix normalizer splits on \ and folds .. lexically. Same root cause as Preserve literal backslashes in fs.realpath on POSIX #33410.

Fix

  • One helper, absolute_watch_path_z, for both watchers. On POSIX an absolute path is copied verbatim and a relative one is appended to the cwd, unnormalized. Windows keeps the normalizing join: Win32 resolves .. lexically itself.
  • Correct because the POSIX backend only open()s this string and then keys, stores, and reports the get_fd_path() realpath, and fs.watchFile only stat()s it.
  • Verified: new tests in test/js/node/watch/fs.watch.test.ts (backslash, symlink/..) and fs.watchFile.test.ts (backslash) fail on 1.4.3-canary and pass here. All test/js/node/watch/*.test.ts files and 45 vendored test-fs-watch* node tests pass.
  • Self-reviewed as part of a larger diff; the review asked for this split (both watchers, one helper, both tests).

Background

  • On POSIX \ is an ordinary filename byte, and the kernel resolves .. in the directory a symlink points to. A lexical normalizer does neither.
  • fs.watchFile already path.resolve()s its argument in JS (node too). fs.watch passes the caller's string through.
  • Preserve literal backslashes in fs.realpath on POSIX #33410 found that a strict Platform::Posix normalizer breaks npm lockfile migration (packages\pkg1), so this change avoids the normalizer rather than changing it.
Notes
  • Found by a differential run of bun 1.4.2 / canary against node v26.3.0: fs.watchFile('a\\b') stat'ed <cwd>/a/b (append to the literal a\b: no event; append to a/b: event), node the literal name. fs.watch had been recorded earlier with the same backslash behavior.
  • path_watcher::watch (POSIX) flow: open(path, O_PATH|O_DIRECTORY) (retried without O_DIRECTORY for files), get_fd_path(fd) for the canonical path, dedup map keyed by that realpath plus the recursive flag, inotify wd owners tracked per wd in wd_map: HashMap<i32, Vec<WdOwner>>, so two spellings of one directory were already handled. Only if get_fd_path fails does the caller's string become the key, as before.
  • top_level_dir tracks process.chdir() (node_process.rs), so a relative fs.watch path still resolves against the cwd at call time, as before.
  • StatWatcher::init used the unchecked join; an over-long path now returns ENAMETOOLONG through the existing "Failed to watch file" error instead of indexing past the buffer.
  • Companion PR: node:fs: report the caller's path, as given, as Dirent.parentPath of root entries #41975 (recursive readdir parentPath), split from the same report.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/watch/fs.watchFile.test.ts, test/js/node/watch/fs.watch.test.ts

…OSIX

FSWatcher::init and StatWatcher::init built the absolute path to watch
with join_abs_string_buf, whose POSIX normalizer splits on `\` and folds
`..` lexically. So fs.watch("a\\b") and fs.watchFile("a\\b") watched a/b,
and fs.watch("d/link/..") watched d/ instead of the parent of the link
target. Node (libuv) passes the string to inotify/kqueue/stat unchanged.

Both now go through absolute_watch_path_z: on POSIX an absolute path is
used verbatim and a relative one is appended to the cwd without
normalization; Windows keeps the normalizing join, since Win32 resolves
paths lexically itself.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a0fcd813-35e2-4722-84c0-05bfb9caa7ea

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and 64de9ac.

📒 Files selected for processing (4)
  • src/runtime/node/node_fs_stat_watcher.rs
  • src/runtime/node/node_fs_watcher.rs
  • test/js/node/watch/fs.watch.test.ts
  • test/js/node/watch/fs.watchFile.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Watch path construction now preserves POSIX path bytes and uses Windows-specific joining. Both watcher implementations use the shared helper. Regression tests cover literal backslashes and symlink parent resolution.

Changes

Watch path handling

Layer / File(s) Summary
Platform-specific watch path construction
src/runtime/node/node_fs_watcher.rs
Adds absolute_watch_path_z with Windows joining, POSIX byte preservation, NUL termination, and capacity checks.
Watcher integration and regression coverage
src/runtime/node/node_fs_stat_watcher.rs, src/runtime/node/node_fs_watcher.rs, test/js/node/watch/*
Updates StatWatcher and FSWatcher to use the helper. Adds POSIX tests for literal backslashes and symlink-based .. resolution.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal — Schedule the POSIX watcher path fix because it corrects user-visible fs.watch and fs.watchFile behavior for literal backslashes and symlink-based paths.

Merge Risk: ⚪ Minimal · up to 64de9

POSIX fs.watch and fs.watchFile now preserve literal path bytes, fixing backslash filenames and symlink/.. resolution while retaining Windows joining behavior. The targeted regression coverage supports merge readiness.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the problem, root cause, fix, platform behavior, tests, and verification results. It does not use the exact template headings, but it provides the required information…
Title check ✅ Passed The title clearly identifies the main change: POSIX fs.watch and fs.watchFile paths are passed to the operating system without lexical normalization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary (Linux x64) against node v26.3.0, from a temp dir:

fs.mkdirSync("a"); fs.writeFileSync("a\\b", "LL"); fs.writeFileSync("a/b", "S");
fs.watchFile("a\\b", { interval: 20 }, (c, p) => console.log(p.size, "->", c.size));
setTimeout(() => { fs.appendFileSync("a\\b", "+"); fs.appendFileSync("a/b", "+"); }, 150);
// node: 2 -> 3 (the literal a\b)   bun: 1 -> 2 (a/b)

fs.rmSync("a/b"); fs.watch("a\\b");
// node: watches a\b   bun: ENOENT: no such file or directory, watch 'a\b'
  • Tests: test/js/node/watch/fs.watch.test.ts ("a backslash in the path is not a separator", "symlink/.. in the path resolves through the symlink") and test/js/node/watch/fs.watchFile.test.ts ("a backslash in the filename is not a separator"). All three fail with USE_SYSTEM_BUN=1 and pass with bun bd test.
  • Also green locally (debug + ASAN): all six test/js/node/watch/*.test.ts files, 45 vendored test-fs-watch* / test-fs-watchfile* / test-fs-promises-watch* node tests, cargo check --target x86_64-pc-windows-msvc.
  • CI: build 112934 passed, 181/181 jobs (three unrelated retry-pass flakes).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/node/node_fs_stat_watcher.rs
Comment thread test/js/node/watch/fs.watch.test.ts
Comment thread src/runtime/node/node_fs_watcher.rs Outdated
Comment thread src/runtime/node/node_fs_watcher.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — thanks for wiring the "error" event to reject in the new fs.watch tests.

Checked absolute_watch_path_z bounds arithmetic: cap = buf.len() - 1 reserves the NUL slot and both length checks prevent the final buf[len] = 0 from indexing past the buffer. Confirmed bun_paths::is_absolute on POSIX only tests for leading /, so a \-prefixed path correctly falls into the relative branch and gets cwd-prepended. Windows path keeps the checked normalizing join, so behavior there is unchanged. The remaining nit about attaching the path to the ENAMETOOLONG error in StatWatcher::init was optional and doesn't block.

Extended reasoning...

Overview

This PR fixes fs.watch() and fs.watchFile() on POSIX to hand the caller's path bytes to the kernel unnormalized, so filenames containing literal \ and paths containing symlink/.. behave the way Node does. A single helper absolute_watch_path_z in node_fs_watcher.rs replaces two separate join_abs_string_buf[_checked]::<platform::Auto> calls; on POSIX it just prepends cwd + / for relative paths and copies bytes verbatim, on Windows it delegates to the existing checked Windows join. StatWatcher::init now returns ENAMETOOLONG on overflow instead of using the unchecked join. Three POSIX-only tests are added to the existing watch test files.

Security risks

None identified. The helper never widens what the OS is asked to open — it removes lexical rewriting so the kernel sees exactly what the caller passed (which is what Node does and what open()/stat() would see if called directly). Bounds checks on the scratch buffer are correct: cap reserves one byte for the NUL terminator, both cwd.len() + 1 > cap and len + path.len() > cap guard the copies, and buf[len] = 0 is in-bounds because len <= cap = buf.len() - 1. No untrusted network/archive input is involved.

Level of scrutiny

Moderate. Path handling is subtle and cross-platform, but the change is small (~80 lines net), extracts the two callers into one helper per REVIEW.md's "fix the whole class" guidance, and the POSIX branch is deliberately minimal (no normalization at all — just concatenation). I verified bun_paths::is_absolute → is_absolute_native on POSIX checks only for leading /, so backslash-leading inputs are correctly treated as relative. The Windows branch is behavior-preserving modulo the explicit platform::Windows in place of platform::Auto, which resolves to Windows on Windows anyway.

Other factors

This is my third look at this PR. The earlier review raised two optional nits: (1) StatWatcher's ENAMETOOLONG omits the path, and (2) the new tests didn't wire "error" to reject. Commit 15b55a40 addressed (2) — both new fs.watch tests now watcher.once("error", reject) before starting the write interval. The author declined (1), which was explicitly optional and is still a strict improvement over base (unchecked join → potential OOB). Subsequent commits only shortened the helper's doc comment. The tests follow harness conventions (tempDirWithFiles, test.skipIf(isWindows) with a reason comment, Promise.withResolvers resolved from event handlers with a bounded repeat driver, finally cleanup). No outstanding third-party CHANGES_REQUESTED reviews.

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:32 AM PT - Sep 8th, 2026

✅ @robobun, your commit 64de9ac7099f21e8f2f59e3e8260348e232f57af passed in Build #112934! 🎉


🧪   To try this PR locally:

bunx bun-pr 41986

That installs a local version of the PR into your bun-41986 executable, so you can run:

bun-41986 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants