diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index 88b2ca5fce..ad8912a405 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -100,6 +100,8 @@ title: "Limitations"
../limitations/builtin-apis/file-streams.mdx
+../limitations/builtin-apis/file-watching.mdx
+
../limitations/builtin-apis/crypto.mdx
../limitations/builtin-apis/assertions.mdx
diff --git a/docs/content/limitations/builtin-apis/file-watching.mdx b/docs/content/limitations/builtin-apis/file-watching.mdx
new file mode 100644
index 0000000000..2d2b386f05
--- /dev/null
+++ b/docs/content/limitations/builtin-apis/file-watching.mdx
@@ -0,0 +1,5 @@
+- **File watching:** `fs.watch` returns an `FSWatcher` whose `on`, `once`, `off`, and `close` take `change`, `rename`, `error`, and `close` listeners.
+ A listener may take the event name and the entry name, but the name arrives as a string (Node's default encoding), so a listener annotated with the `Buffer` arm of `@types/node`'s `change` overload has no lowering.
+ Watching a directory on macOS with a listener that reads the entry name is refused with `SC2020` because FSEvents is not implemented yet; file watches and listeners that ignore the name work on every target.
+- **Stat polling:** `fs.watchFile` polls the resolved path every `interval` milliseconds (default 5007) and calls its listener with `(current, previous)` `Stats`; `fs.unwatchFile` removes the last listener registered with that callback, or every listener when called without one.
+ The call must be a statement of its own, because the `StatWatcher` it returns in Node.js is not lowered, and `bigint: true` is refused.
diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json
index 387f5b21cb..391d7a5f81 100644
--- a/docs/src/generated/node-v24-compatibility.json
+++ b/docs/src/generated/node-v24-compatibility.json
@@ -30842,9 +30842,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "Implemented by a dedicated static compiler/runtime path.",
+ "verification": "test-backed"
}
},
{
@@ -30911,9 +30911,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "Implemented by a dedicated static compiler/runtime path.",
+ "verification": "test-backed"
}
},
{
@@ -32659,9 +32659,9 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "detail": "No static compiler lowering is registered for this API yet.",
- "verification": "registry-gap"
+ "status": "partial",
+ "detail": "Implemented by a dedicated static compiler/runtime path.",
+ "verification": "test-backed"
}
},
{
diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json
index 00d6f8db35..a93e7b34c1 100644
--- a/internal/compatibility/generated/node-v24-internal.json
+++ b/internal/compatibility/generated/node-v24-internal.json
@@ -44292,6 +44292,7 @@
"evidence": "derived:descendants",
"tests": [
"tests/corpus/1403-buffer-fs.ts",
+ "tests/corpus/1564-fs-watch.ts",
"tests/corpus/2595-fs-arg-ladders.cjs",
"tests/corpus/2686-fs-file-handle.ts",
"tests/corpus/2844-node-disposable-resources.ts",
@@ -44328,6 +44329,8 @@
"tests/corpus/6207-fs-constants-object.ts",
"tests/corpus/6208-fs-constants-require.cjs",
"tests/corpus/fs-native-realpath.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts",
"tests/corpus/node24-fs-links-callbacks.cjs",
"tests/corpus/node24-fs-links-errors.cjs",
"tests/corpus/node24-fs-links-options.cjs",
@@ -47429,8 +47432,13 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:fs"
+ "status": "partial",
+ "evidence": "compiler-dedicated:fs.unwatchFile",
+ "tests": [
+ "tests/corpus/1564-fs-watch.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -47601,8 +47609,13 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:fs"
+ "status": "partial",
+ "evidence": "compiler-dedicated:fs.watchFile",
+ "tests": [
+ "tests/corpus/1564-fs-watch.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -49669,8 +49682,13 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:fs"
+ "status": "partial",
+ "evidence": "compiler-dedicated:fs.FSWatcher",
+ "tests": [
+ "tests/corpus/1564-fs-watch.ts",
+ "tests/corpus/fs-watch-file-names.ts",
+ "tests/corpus/fs-watch-file-polling.ts"
+ ]
},
"anchorSource": "exact"
},
diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json
index 1242ad9a7e..8afe8af223 100644
--- a/internal/compatibility/static-support.json
+++ b/internal/compatibility/static-support.json
@@ -485,6 +485,7 @@
{ "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] },
{ "symbols": ["readline.createInterface", "rl.close"], "status": "partial", "evidence": ["tests/corpus/1475-readline-closed-stdin.ts", "tests/corpus/5878-readline-dynamic-streams.mjs"] },
{ "symbols": ["test", "it", "test.skip", "test.todo", "test.only", "describe", "suite", "before", "after", "beforeEach", "afterEach", "context.test", "context.skip", "context.todo", "context.assert"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] },
- { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] }
+ { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] },
+ { "symbols": ["fs.FSWatcher", "fs.watchFile", "fs.unwatchFile"], "status": "partial", "evidence": ["tests/corpus/1564-fs-watch.ts", "tests/corpus/fs-watch-file-names.ts", "tests/corpus/fs-watch-file-polling.ts"] }
]
}
diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts
index 46c38a3158..70172f384f 100644
--- a/packages/compiler/ambient/node-fallback/fs.d.ts
+++ b/packages/compiler/ambient/node-fallback/fs.d.ts
@@ -595,6 +595,28 @@ declare module "node:fs" {
options: WatchOptions,
listener?: WatchListener,
): FSWatcher;
+ /* fs.watchFile — stat polling (scr_watch_file.c, libuv's uv_fs_poll
+ * shape): the listener runs with (current, previous) Stats when a poll
+ * sees a difference, the first poll only records the baseline, and a
+ * path that cannot be stat'd polls as all-zero Stats. One poller per
+ * resolved path is shared by every listener on it. interval defaults to
+ * 5007 ms, persistent defaults to true; bigint: true (BigIntStats) has
+ * no lowering. The call is a statement: Node's StatWatcher has no
+ * lowering. */
+ export interface StatWatcher {}
+ export function watchFile(
+ filename: string,
+ options: { interval?: number; persistent?: boolean; bigint?: false },
+ listener: (current: Stats, previous: Stats) => void,
+ ): StatWatcher;
+ export function watchFile(
+ filename: string,
+ listener: (current: Stats, previous: Stats) => void,
+ ): StatWatcher;
+ export function unwatchFile(
+ filename: string,
+ listener?: (current: Stats, previous: Stats) => void,
+ ): void;
/* fs.promises IS the fs/promises module (Node's rule) — the namespace-
* import form `fs.promises.readFile(...)` lowers through the same
* fs/promises table as `import { readFile } from "node:fs/promises"`. */
diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts
index f4290a15ae..bb45425734 100644
--- a/packages/compiler/src/backend/executable-features.ts
+++ b/packages/compiler/src/backend/executable-features.ts
@@ -66,6 +66,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
dgramDyn: features.dgramDyn,
textCodecDyn: features.textCodecDyn,
watch: features.fsWatch,
+ watchFile: features.fsWatchFile,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
tls: features.tls,
diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts
index 14ffdff28b..7b74dc8081 100644
--- a/packages/compiler/src/backend/library-pack.ts
+++ b/packages/compiler/src/backend/library-pack.ts
@@ -56,6 +56,7 @@ export async function compilePackedLibrary(
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts
index 6f261d4bb9..bcdb985f4b 100644
--- a/packages/compiler/src/backend/llvm/emit-module.ts
+++ b/packages/compiler/src/backend/llvm/emit-module.ts
@@ -478,6 +478,10 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
const usesEvents = runtimeFeatures.processEvents;
const usesChildProcess = runtimeFeatures.childProcess;
const usesFsWatch = runtimeFeatures.fsWatch;
+ // fs.watchFile programs register the poller unit's exit cleanup — the
+ // same one-line gating (native/executable.ts links scr_watch_file.c on
+ // the same predicate).
+ const usesFsWatchFile = runtimeFeatures.fsWatchFile;
// Programs that call into Node record the libnode pack and its Node
// version before %main (scr_node_island.c).
const usesNodeIsland = runtimeFeatures.nodeIsland;
@@ -541,6 +545,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
if (workerStdioBox || workerStdinBox)
emitter.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`);
if (usesFsWatch) emitter.declare(`declare void @scr_watch_install()`);
+ if (usesFsWatchFile) emitter.declare(`declare void @scr_watch_file_install()`);
if (usesNodeIsland) emitter.declare(`declare void @scr_node_island_install(ptr, ptr)`);
// Interned now: the C-string constants flush before main assembles.
const nodeIslandConfig = usesNodeIsland
@@ -1226,6 +1231,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// fs.watch programs fill the loop's watch hooks the same way —
// scr_watch.c links only when this line is emitted.
...(usesFsWatch ? [` call void @scr_watch_install()`] : []),
+ // fs.watchFile programs register the poller unit's atexit cleanup the
+ // same way — scr_watch_file.c links only when this line is emitted.
+ ...(usesFsWatchFile ? [` call void @scr_watch_file_install()`] : []),
...(nodeIslandConfig
? [
` call void @scr_node_island_install(ptr ${nodeIslandConfig[0]}, ptr ${nodeIslandConfig[1]})`,
diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts
index 310f2cb57e..dc9c5c876f 100644
--- a/packages/compiler/src/backend/llvm/lib-filesystem.ts
+++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts
@@ -549,6 +549,40 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
B.line(`call void @scr_watcher_close(ptr ${w.name})`);
return { name: "", type: e.type };
}
+ if (e.fn === "fs.watchFile") {
+ // (path, interval, persistent, listener) — the listener MOVES into
+ // the poller's registry and the adapter is runtime-provided per
+ // listener shape (zero, one or two Stats parameters). The poller
+ // re-arms a timer every interval, so this is a timer program.
+ host.usesTimers = true;
+ const cbT = e.args[3]!.type;
+ if (cbT.kind !== "func")
+ throw new InternalCompilerError("llvm emitter bug: fs.watchFile listener not a func");
+ const path = host.emitExpr(e.args[0]!);
+ const interval = host.emitExpr(e.args[1]!);
+ const persistent = host.emitExpr(e.args[2]!);
+ const cb = host.emitExpr(e.args[3]!);
+ host.toHeap(cb);
+ const adapter = `scr_watch_file_thunk${cbT.params.length}`;
+ host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
+ host.declare(`declare void @scr_fs_watch_file(ptr, double, i1 zeroext, ptr, ptr)`);
+ B.line(
+ `call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`,
+ );
+ return { name: "", type: e.type };
+ }
+ if (e.fn === "fs.unwatchFile") {
+ // Removal is by closure identity: the listener stays borrowed
+ // (never heap-moved), exactly like watcher.off's; an absent one is
+ // the NULL that removes every listener on the path.
+ const path = host.emitExpr(e.args[0]!);
+ const cb = e.args[1] === undefined ? null : host.emitExpr(e.args[1]!);
+ host.declare(`declare void @scr_fs_unwatch_file(ptr, ptr)`);
+ B.line(
+ `call void @scr_fs_unwatch_file(ptr ${path.name}, ptr ${cb === null ? "null" : cb.name})`,
+ );
+ return { name: "", type: e.type };
+ }
if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") {
// Dirent rows from one glob snapshot (scr_glob.c): mode 1 is the
// globSync form, 3 the fs/promises form with resolved parent paths.
diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts
index 6a40a1be76..10d6657907 100644
--- a/packages/compiler/src/backend/native-link-info-core.ts
+++ b/packages/compiler/src/backend/native-link-info-core.ts
@@ -116,6 +116,8 @@ export interface NativeLinkFeatures {
* scr_text_codec_dyn.c. */
textCodecDyn: boolean;
watch: boolean;
+ /** fs.watchFile/fs.unwatchFile: scr_watch_file.c. */
+ watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts
index f093d64834..aca6000b4a 100644
--- a/packages/compiler/src/backend/native/contracts.ts
+++ b/packages/compiler/src/backend/native/contracts.ts
@@ -255,6 +255,11 @@ export interface CcOptions {
* scr_watch.c into the binary — the net gating precedent, so watch-free
* binaries keep their exact link line. */
watch?: boolean;
+ /** The program uses fs.watchFile or fs.unwatchFile (moduleUsesFsWatchFile
+ * on the IR): compiles scr_watch_file.c into the binary — its own gated
+ * unit under the same precedent, so polling-free binaries keep their
+ * exact link line. */
+ watchFile?: boolean;
/** The executable manifest has a format-5 foreign callback descriptor:
* compiles the MPSC queue/self-pipe unit. Other FFI and non-FFI binaries
* keep their existing runtime size class. */
diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts
index 3515bc387b..e8ae31067d 100644
--- a/packages/compiler/src/backend/native/executable.ts
+++ b/packages/compiler/src/backend/native/executable.ts
@@ -336,6 +336,9 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"),
unit((u) => u.opts.textCodecDyn, "scr_text_codec_dyn.c"),
unit((u) => u.opts.watch, "scr_watch.c"),
+ // fs.watchFile's stat poller: its own gated unit (no loop hook — the
+ // re-armed timers are the liveness), linked only by programs that poll.
+ unit((u) => u.opts.watchFile, "scr_watch_file.c"),
unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"),
unit((u) => u.opts.nodeTest, "scr_test.c"),
unit((u) => u.opts.webcrypto, "scr_webcrypto.c"),
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index fcbe3f08e1..510e046b68 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -120,6 +120,7 @@ const features = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index a12c137b25..0fc279d205 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -61,6 +61,7 @@ const BASE: NativeLinkFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts
index c42ceb79e6..25254934fd 100644
--- a/packages/compiler/src/backend/warm-cache.ts
+++ b/packages/compiler/src/backend/warm-cache.ts
@@ -63,6 +63,7 @@ export async function warmNativeCaches(
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts
index 1f5625fb84..9c81ccc192 100644
--- a/packages/compiler/src/executable/executable-cache.test.ts
+++ b/packages/compiler/src/executable/executable-cache.test.ts
@@ -60,6 +60,7 @@ const native: EarlyExecutableNativeFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index 25a77c10df..c7daf3f684 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -109,6 +109,7 @@ export interface EarlyExecutableNativeFeatures {
dgramDyn: boolean;
textCodecDyn: boolean;
watch: boolean;
+ watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
@@ -295,6 +296,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgramDyn",
"textCodecDyn",
"watch",
+ "watchFile",
"foreignFfi",
"nodeTest",
"tls",
diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts
index d2e4b53d53..33c4502bc7 100644
--- a/packages/compiler/src/frontend/lowering/builtin-calls.ts
+++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts
@@ -1,4 +1,5 @@
import { lowerReadlineCursorCall } from "./builtins/readline.js";
+import { lowerFsWatchFileCall } from "./builtins/filesystem.js";
import { lowerMonitorEventLoopDelayCall } from "./builtins/performance.js";
import {
lowerEnvironmentData,
@@ -134,6 +135,13 @@ export function lowerBuiltinCall(
if (streamServed) return streamServed;
const fsTs = lowerer.lowerFsToUnixTimestampCall(expr, bi, loc);
if (fsTs) return fsTs;
+ // fs.watchFile/fs.unwatchFile have no table entry (their checked-dynamic
+ // form is the fs.watchFileChk ladder below), so the typed polling forms
+ // dispatch here; anything else falls through to the ladder and the fence.
+ if (bi.module === "fs" && (bi.member === "watchFile" || bi.member === "unwatchFile")) {
+ const polling = lowerFsWatchFileCall(lowerer, expr, loc, bi.member === "unwatchFile");
+ if (polling) return polling;
+ }
const fsLadder = lowerer.lowerFsLadderCall(expr, bi, loc);
if (fsLadder) return fsLadder;
const cryptoServed = lowerer.lowerCryptoModuleCall(expr, bi, loc);
diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts
index d248caa5eb..318d58ee0d 100644
--- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts
@@ -8,6 +8,7 @@ import {
builtinModuleFnOf,
FS_READDIR_DOCUMENTED_OPTIONS,
FS_WATCH_DOCUMENTED_OPTIONS,
+ FS_WATCH_FILE_DOCUMENTED_OPTIONS,
fenceOrDropOptionKey,
} from "../surfaces.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
@@ -1261,6 +1262,128 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
};
}
+/** `fs.watchFile(path[, options], listener)` and
+ * `fs.unwatchFile(path[, listener])` → the polling libCalls
+ * (scr_watch_file.c: libuv's uv_fs_poll shape — one poller per RESOLVED
+ * path, shared by every listener on it, re-armed every `interval` ms,
+ * 5007 by default, persistent by default). The listener takes
+ * (current: Stats, previous: Stats) — zero, one or both — and runs when a
+ * poll sees a difference; the first poll only records the baseline, and a
+ * file that cannot be stat'd polls as all-zero Stats. The options record
+ * follows the options-record stance: interval, persistent: true/false and
+ * bigint: false are accepted; bigint: true fences (BigIntStats); other
+ * documented keys fence by name; undocumented keys drop like Node.
+ * Statement position only — Node returns a StatWatcher, which has no
+ * lowering. Returns null for any untyped shape so the checked-dynamic
+ * ladder (fs.watchFileChk) keeps its current IR. */
+export function lowerFsWatchFileCall(
+ lowerer: Lowerer,
+ expr: ts.CallExpression,
+ loc: SrcLoc,
+ unwatch: boolean,
+): IrExpr | null {
+ const nodes = expr.arguments.nodes;
+ if (nodes.length < 1 || nodes.length > 3 || nodes.some(ts.isSpreadElement)) return null;
+ if (lowerer.mapTypeOf(lowerer.typeOf(nodes[0]!))?.kind !== "string") return null;
+ const path = lowerer.lowerExprExpecting(nodes[0]!, STRING);
+ if (unwatch) {
+ if (nodes.length > 2) return null;
+ const args: IrExpr[] = [path];
+ if (nodes.length === 2) {
+ if (lowerer.mapTypeOf(lowerer.typeOf(nodes[1]!))?.kind !== "func") return null;
+ args.push(lowerer.lowerExpr(nodes[1]!));
+ }
+ return { kind: "libCall", fn: "fs.unwatchFile", args, type: VOID, loc };
+ }
+ const hasOptions = nodes.length >= 2 && ts.isObjectLiteralExpression(nodes[1]!);
+ if (nodes.length === 3 && !hasOptions) return null;
+ if (nodes.length === 2 && hasOptions) return null; /* no listener */
+ const listenerArg = nodes[nodes.length - 1]!;
+ if (lowerer.mapTypeOf(lowerer.typeOf(listenerArg))?.kind !== "func") return null;
+ const parent = expr.parent;
+ if (!ts.isExpressionStatement(parent) && !(ts.isArrowFunction(parent) && parent.body === expr)) {
+ lowerer.unsupported(
+ "SC1090",
+ expr,
+ "using the returned StatWatcher (register the fs.watchFile call as its own statement)",
+ );
+ }
+ let interval: IrExpr | null = null;
+ let persistent: IrExpr | null = null;
+ if (hasOptions) {
+ for (const prop of (nodes[1] as ts.ObjectLiteralExpression).properties.nodes) {
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) {
+ lowerer.noLowering(
+ "fs.watchFile options with computed keys or spreads",
+ prop,
+ "each option must be a plain `name: value` entry with a literal key",
+ );
+ }
+ if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
+ lowerer.noLowering(
+ "fs.watchFile options with computed keys",
+ prop,
+ "each option must be a plain `name: value` entry with a literal key",
+ );
+ }
+ const key = prop.name.text;
+ const value = ts.isPropertyAssignment(prop)
+ ? prop.initializer
+ : ts.isShorthandPropertyAssignment(prop)
+ ? prop.name
+ : null;
+ if (key === "interval" && value !== null) {
+ interval = lowerer.lowerExprExpecting(value, F64);
+ continue;
+ }
+ if (key === "persistent" && value !== null) {
+ persistent = lowerer.lowerExprExpecting(value, BOOL);
+ continue;
+ }
+ if (key === "bigint") {
+ // The fallback's option record declares `bigint: false`, so the
+ // false literal (or `false` as a value) states the lowered Stats
+ // shape; BigIntStats has no lowering.
+ if (value !== null && value.kind === ts.SyntaxKind.FalseKeyword) continue;
+ lowerer.noLowering(
+ "fs.watchFile with bigint: true",
+ prop,
+ "the listener receives Stats (double integers); BigIntStats has no lowering",
+ );
+ }
+ fenceOrDropOptionKey(
+ lowerer,
+ prop,
+ key,
+ "fs.watchFile",
+ FS_WATCH_FILE_DOCUMENTED_OPTIONS,
+ "interval, persistent: true/false, and bigint: false are the accepted options",
+ );
+ // An undocumented key, dropped like Node drops it.
+ }
+ }
+ const cb = lowerer.lowerExpr(listenerArg);
+ if (
+ cb.type.kind !== "func" ||
+ cb.type.ret.kind !== "void" ||
+ cb.type.params.length > 2 ||
+ !cb.type.params.every((p) => p.kind === "stats")
+ ) {
+ lowerer.noLowering(
+ "fs.watchFile with this listener shape",
+ listenerArg,
+ "the listener takes (current: Stats, previous: Stats)",
+ );
+ }
+ return {
+ kind: "libCall",
+ fn: "fs.watchFile",
+ args: [path, interval ?? numLit(5007, loc), persistent ?? boolLit(true, loc), cb],
+ type: VOID,
+ loc,
+ };
+}
+
/** Method calls on FSWatcher receivers: close() (idempotent, statement
* position — Node returns void there too) and the EventEmitter surface for
* 'change', 'error' and 'close' (on/once/addListener/prependListener/
diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts
index 03b65bd547..6a06fbaad1 100644
--- a/packages/compiler/src/frontend/lowering/surfaces.ts
+++ b/packages/compiler/src/frontend/lowering/surfaces.ts
@@ -443,6 +443,13 @@ export const FS_WATCH_DOCUMENTED_OPTIONS: ReadonlySet = new Set([
"signal",
]);
+/** fs.watchFile's documented option keys. */
+export const FS_WATCH_FILE_DOCUMENTED_OPTIONS: ReadonlySet = new Set([
+ "interval",
+ "persistent",
+ "bigint",
+]);
+
/** fs.readdirSync's documented option keys. */
export const FS_READDIR_DOCUMENTED_OPTIONS: ReadonlySet = new Set([
"encoding",
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 0719112cc1..fdf2335a8f 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -745,6 +745,7 @@ async function compileExecutableNative(
dgramDyn: features.dgramDyn,
textCodecDyn: features.textCodecDyn,
watch: features.watch,
+ watchFile: features.watchFile,
foreignFfi: features.foreignFfi,
...(features.workers ? { workers: true } : {}),
...(features.youngNursery ? { youngNursery: true } : {}),
diff --git a/packages/compiler/src/ir/api.ts b/packages/compiler/src/ir/api.ts
index 600cdb051c..8ea02cfe68 100644
--- a/packages/compiler/src/ir/api.ts
+++ b/packages/compiler/src/ir/api.ts
@@ -28,6 +28,7 @@ export {
moduleUsesQs,
moduleUsesParseArgs,
moduleUsesFsWatch,
+ moduleUsesFsWatchFile,
moduleUsesNodeTest,
moduleUsesDgram,
moduleUsesHttpServer,
diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts
index 48580f4fe3..5733525cd9 100644
--- a/packages/compiler/src/ir/builtin-effects.ts
+++ b/packages/compiler/src/ir/builtin-effects.ts
@@ -1090,6 +1090,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([
"fs.watchCb",
"watcher.on",
"watcher.off",
+ "fs.watchFile",
+ "fs.unwatchFile",
"crypto.x509Fingerprint",
"crypto.x509FingerprintStr",
"crypto.x509ValidFrom",
diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts
index 47514c5610..622abfd94b 100644
--- a/packages/compiler/src/ir/builtin-signatures.ts
+++ b/packages/compiler/src/ir/builtin-signatures.ts
@@ -1311,6 +1311,16 @@ function buildLibFnSignatures() {
// the null slot pins the arity and the receiver.
"watcher.on": { argTypes: [FSWATCHER_T, F64, F64, null], result: VOID },
"watcher.off": { argTypes: [FSWATCHER_T, F64, null], result: VOID },
+ /** fs.watchFile(path, interval, persistent, listener) → void
+ * (scr_watch_file.c — its own gated unit; moduleUsesFsWatchFile is the
+ * switch, and scr_watch_file_install() is emitted with it). One
+ * libuv-shaped stat poller per RESOLVED path, shared by every listener
+ * on it; the listener's (current, previous) Stats func type is
+ * program-dependent, so the slot pins the arity. Statement position
+ * only (Node returns a StatWatcher). fs.unwatchFile(path[, listener])
+ * takes a borrowed listener; the null slot is the absent one. */
+ "fs.watchFile": { argTypes: [STRING, F64, BOOL, null], result: VOID },
+ "fs.unwatchFile": { argTypes: [STRING, null], result: VOID },
/** The composed `new crypto.X509Certificate(data).fingerprint` read
* (scr_lib.c — no certificate handle exists): the SHA-1 of the DER,
* uppercase colon-separated, over PEM or raw-DER Buffer input; other
diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts
index da4f116237..d583ea62ad 100644
--- a/packages/compiler/src/ir/runtime-features.ts
+++ b/packages/compiler/src/ir/runtime-features.ts
@@ -1069,6 +1069,9 @@ const RUNTIME_FEATURE_RULES = {
fetch: rule({ prefixes: ["fetch."] }),
fileHandle: rule({ types: ["fileHandle"] }),
fsWatch: rule({ prefixes: ["fs.watch", "watcher."], types: ["fsWatcher"] }),
+ /** fs.watchFile / fs.unwatchFile (scr_watch_file.c): the stat-polling
+ * half of the watching surface, gated on its own unit. */
+ fsWatchFile: rule({ fns: ["fs.watchFile", "fs.unwatchFile"] }),
/** A socket 'lookup' listener: the C main installs the net unit's
* lookup records (scr_net_lookup_install). */
netLookup: rule({ fns: ["net.sockOnLookup"] }),
@@ -1286,6 +1289,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
fetch: false,
fileHandle: false,
fsWatch: false,
+ fsWatchFile: false,
http2: false,
http: false,
inspect: false,
@@ -1631,6 +1635,15 @@ export function moduleUsesFsWatch(mod: IrModule): boolean {
return scanRuntimeFeatures(mod, "fsWatch").fsWatch;
}
+/** True when the module contains an fs.watchFile/fs.unwatchFile libCall —
+ * the link switch that pulls scr_watch_file.c into the binary and has the
+ * emitted main call scr_watch_file_install (the moduleUsesFsWatch
+ * precedent, one unit of its own). Programs that never poll pay zero bytes
+ * and keep their exact link line. */
+export function moduleUsesFsWatchFile(mod: IrModule): boolean {
+ return scanRuntimeFeatures(mod, "fsWatchFile").fsWatchFile;
+}
+
/** True when the module contains any test.* libCall or a testCtx handle
* type — the link switch that pulls scr_test.c into the binary and has
* the emitted main return scr_test_exit_code() after the loop drains
diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts
index 995603bcd2..f5312713c1 100644
--- a/packages/compiler/src/ir/validate.ts
+++ b/packages/compiler/src/ir/validate.ts
@@ -4187,7 +4187,14 @@ function validateFunction(
e.fn === "readable.unpipe" ||
e.fn === "rl.create" ||
e.fn === "rl.createSignal";
- if (variadic ? e.args.length < sig.argTypes.length : e.args.length !== sig.argTypes.length) {
+ // fs.unwatchFile's listener is optional, so its last slot may be
+ // omitted (1 or 2 args) rather than extended.
+ const optionalTail = e.fn === "fs.unwatchFile" ? 1 : 0;
+ const arityOk = variadic
+ ? e.args.length >= sig.argTypes.length
+ : e.args.length === sig.argTypes.length ||
+ (optionalTail > 0 && e.args.length === sig.argTypes.length - optionalTail);
+ if (!arityOk) {
err(`libCall ${e.fn}: ${e.args.length} args, expected ${sig.argTypes.length}`, e.loc);
}
e.args.forEach((a, i) => {
diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts
index bc33e683aa..5c8a910224 100644
--- a/packages/compiler/src/native/cli.ts
+++ b/packages/compiler/src/native/cli.ts
@@ -194,6 +194,7 @@ async function main(): Promise {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts
index bf496aa998..009818cd71 100644
--- a/packages/compiler/src/native/prepare.ts
+++ b/packages/compiler/src/native/prepare.ts
@@ -268,6 +268,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures {
"dgramDyn",
"textCodecDyn",
"watch",
+ "watchFile",
"foreignFfi",
"nodeTest",
"tls",
diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts
index b1d03b52d3..509602d647 100644
--- a/packages/compiler/src/native/sanitizer.test.ts
+++ b/packages/compiler/src/native/sanitizer.test.ts
@@ -66,6 +66,7 @@ const features = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
+ watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json
new file mode 100644
index 0000000000..88ec759413
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/fs-watch-file-names.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json
new file mode 100644
index 0000000000..7f11fb9bc3
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/fs-watch-file-polling.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs
index 72e888dce3..d4dff88c3f 100644
--- a/packages/runtime-pack-common/runtime-pack-matrix.mjs
+++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs
@@ -177,6 +177,7 @@ export const OPTIONAL_RUNTIME_SOURCES = [
// TextEncoder and TextDecoder records boxed as untyped values.
["scr_text_codec_dyn.c", "textCodecDyn"],
["scr_watch.c", "watch"],
+ ["scr_watch_file.c", "watchFile"],
["scr_ffi_queue.c", "foreignFfi"],
["scr_test.c", "nodeTest"],
["scr_tls_ca.c", "tlsCaEffective"],
diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c
index 67f18c0d8c..8a5f46a108 100644
--- a/packages/runtime/src/scr_lib.c
+++ b/packages/runtime/src/scr_lib.c
@@ -5557,7 +5557,7 @@ static bool scr_stats_link_target(HANDLE h, DWORD tag, double *size_out, ScrStr
return true;
}
-static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op,
+static ScrStats *scr_stats_win_handle_stats(const ScrStr *path, const char *op,
bool no_follow) {
WCHAR *wide = scr_fs_win_wide(path);
if (!wide) return scr_stats_crt_fallback(path, op);
@@ -5603,7 +5603,7 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op,
/* Reparse points are a general interception mechanism. Node/libuv
* follows non-link tags even for lstat, so reopen the resolved target. */
CloseHandle(h);
- return scr_stats_of_path(path, op, false);
+ return scr_stats_win_handle_stats(path, op, false);
}
if (!scr_stats_link_target(h, tagged.ReparseTag, &link_size, NULL)) {
/* A mount-point tag may name a mounted volume rather than a junction.
@@ -5611,7 +5611,7 @@ static ScrStats *scr_stats_of_path(const ScrStr *path, const char *op,
* ordinary reparse points and retries with the final component
* followed. Do not classify from the tag alone. */
CloseHandle(h);
- return scr_stats_of_path(path, op, false);
+ return scr_stats_win_handle_stats(path, op, false);
}
is_link = true;
}
@@ -6054,7 +6054,7 @@ ScrBytes *scr_fs_readlink_bytes(ScrStr *path) {
ScrStats *scr_fs_stat(ScrStr *path) {
if (!scr_fs_path_ok(path, "path")) return NULL;
#ifdef _WIN32
- return scr_stats_of_path(path, "stat", false);
+ return scr_stats_win_handle_stats(path, "stat", false);
#else
int error = 0;
ScrStats *s = scr_stats_posix(path->data, -1, false, &error); /* follows symlinks */
@@ -6066,7 +6066,7 @@ ScrStats *scr_fs_stat(ScrStr *path) {
ScrStats *scr_fs_lstat(ScrStr *path) {
if (!scr_fs_path_ok(path, "path")) return NULL;
#ifdef _WIN32
- return scr_stats_of_path(path, "lstat", true);
+ return scr_stats_win_handle_stats(path, "lstat", true);
#else
int error = 0;
ScrStats *s = scr_stats_posix(path->data, -1, true, &error); /* no follow */
@@ -6108,6 +6108,33 @@ ScrStats *scr_fs_fstat(double fd) {
return s;
}
+/* fs.watchFile's poll (scr_watch_file.c calls this): stat(2) following
+ * symlinks, libuv's uv_fs_stat — NULL with *error = errno on failure,
+ * never throwing, because a failed poll IS a reportable event. +1. */
+ScrStats *scr_stats_of_path(ScrStr *path, int *error) {
+ if (!scr_fs_path_ok(path, "path")) {
+ *error = EINVAL;
+ return NULL;
+ }
+#ifdef _WIN32
+ /* The CRT snapshot states what the probe can see without opening a
+ * handle: the poll needs the errno, not Win32's extended attributes. */
+ struct stat st;
+ if (stat(path->data, &st) != 0) {
+ *error = errno;
+ return NULL;
+ }
+ return scr_stats_of_crt(&st);
+#else
+ return scr_stats_posix(path->data, -1, false, error);
+#endif
+}
+
+/* The all-zero Stats a failed poll reports (Node's kZeroStats): every
+ * field 0, so isFile()/isDirectory() and the size/date reads all answer
+ * zero/false. +1. */
+ScrStats *scr_stats_zero(void) { return scr_stats_new(); }
+
/* ── Stats objects ───────────────────────────────────────────────────
* Node's Stats and BigIntStats over a snapshot. The integers and the *Ms
* fields (and BigIntStats' *Ns fields) are own properties in the binding's
diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h
index fa88873afa..1a099c84d2 100644
--- a/packages/runtime/src/scr_lib.h
+++ b/packages/runtime/src/scr_lib.h
@@ -479,6 +479,11 @@ double scr_stats_ctime(ScrStats *s);
double scr_stats_birthtime(ScrStats *s);
/* fstat without throwing: +1, or NULL with *error set to the errno. */
ScrStats *scr_stats_of_fd(int fd, int *error);
+/* stat(2) following symlinks without throwing (the fs.watchFile poll):
+ * +1, or NULL with *error set to the errno. */
+ScrStats *scr_stats_of_path(ScrStr *path, int *error);
+/* The all-zero snapshot a failed poll reports (Node's kZeroStats). +1. */
+ScrStats *scr_stats_zero(void);
/* Node's Stats (or BigIntStats) object for the snapshot: own fields in the
* binding's order, lazy Date accessors and the type tests on the
* prototypes. Borrows s; +1. */
diff --git a/packages/runtime/src/scr_watch.h b/packages/runtime/src/scr_watch.h
index c2118d1cda..83f02c9172 100644
--- a/packages/runtime/src/scr_watch.h
+++ b/packages/runtime/src/scr_watch.h
@@ -50,6 +50,25 @@ void scr_watch_thunk0(ScrClosure *cb, const char *event, const char *filename);
void scr_watch_thunk_event(ScrClosure *cb, const char *event, const char *filename);
void scr_watch_err_thunk0(ScrClosure *cb, ScrError *err);
void scr_watch_install(void);
+
+/* ── fs.watchFile (scr_watch_file.c — same gating precedent: linked only
+ * when the program uses it). Stat polling in Node's shape: one poller per
+ * RESOLVED path, shared by every listener on it. */
+typedef void (*ScrWatchFileFn)(ScrClosure *cb, ScrStats *current, ScrStats *previous);
+/* fs.watchFile(path, { interval, persistent }, listener) — cb MOVES in;
+ * the adapter calls the listener with (current, previous) Stats (+1 each).
+ * The path is resolved (path.resolve) and keys the poller; a second call
+ * on the same resolved path adds the listener and ignores its options. */
+void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb,
+ ScrWatchFileFn fn);
+/* fs.unwatchFile(path[, listener]) — cb borrowed, NULL removes every
+ * listener; with none left the poller stops. */
+void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb);
+void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous);
+void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous);
+void scr_watch_file_thunk2(ScrClosure *cb, ScrStats *current, ScrStats *previous);
+void scr_watch_file_install(void);
+
/* The loop-side registration (scr_async.c, always linked) — the net
* hook's exact shape, one more nullable slot set. */
void scr_loop_set_watch(bool (*pending)(void), void (*dispatch)(void), int (*pollfd)(void));
diff --git a/packages/runtime/src/scr_watch_file.c b/packages/runtime/src/scr_watch_file.c
new file mode 100644
index 0000000000..6b40e5ed08
--- /dev/null
+++ b/packages/runtime/src/scr_watch_file.c
@@ -0,0 +1,331 @@
+/* fs.watchFile / fs.unwatchFile — the stat-polling half of the OPTIONAL
+ * file-watching surface: one libuv-shaped poller (uv_fs_poll) per RESOLVED
+ * path, shared by every listener registered on it, re-arming a one-shot
+ * timer every `interval` ms. This translation unit links ONLY into binaries
+ * whose IR uses fs.watchFile/fs.unwatchFile (the scr_watch.c gating
+ * precedent), and the emitted main calls scr_watch_file_install() before
+ * %main, which registers the exit cleanup. Polling-free programs pay zero
+ * bytes and keep their exact link line.
+ *
+ * Semantics, matched against Node (libuv's poll_cb + Node's StatWatcher)
+ * and pinned by corpus:
+ * - The FIRST poll only records the baseline: a successful first stat
+ * emits nothing (libuv's busy_polling == 0), a failing first stat emits
+ * ONE change with both sides all-zero (Node's kZeroStats).
+ * - Thereafter a stat failure emits once per distinct errno; a success
+ * after a failure, or a success whose snapshot differs (ctime, mtime,
+ * birthtime, size, mode, uid, gid, ino, dev), emits (current,
+ * previous), where previous is the last SUCCESSFUL snapshot.
+ * - Node's onchange suppression: with no status reported yet, a status of
+ * -1 whose nlink matches the previous snapshot reports nothing.
+ * - watchFile on a path that already has a poller ADDS the listener and
+ * ignores its options; the first call's interval and persistent stand.
+ * - unwatchFile(path, listener) removes the LAST listener registered with
+ * that closure (pointer identity, Node's rule); unwatchFile(path)
+ * removes every listener. The last removal stops the poller (its timer
+ * clears), which is what lets the process exit.
+ * - An emit works on a snapshot of the listeners with retained closures,
+ * so a listener may unwatch itself or others mid-emit — the corpus's
+ * own shape.
+ * - persistent: false unrefs the poll timer: the poller still fires while
+ * the loop runs for something else, but never holds the process open. */
+#include "scr_runtime.h"
+
+#include
+#include
+#include
+#include
+
+static void scr_watch_file_oom(void) {
+ fputs("scriptc: out of memory\n", stderr);
+ abort();
+}
+
+/* One registered listener: the closure (owned) and its (current,
+ * previous) adapter. */
+typedef struct {
+ ScrClosure *cb; /* owned */
+ ScrWatchFileFn fn;
+} ScrWatchFileL;
+
+struct ScrWatchFiler {
+ size_t rc;
+ ScrStr *path; /* the resolved path (+1): the registry key and the stat target */
+ double interval; /* the first call's interval, reused for every poll */
+ bool persistent;
+ int state; /* libuv's busy_polling: 0 before the first poll, 1 after
+ * a success, -errno after a failure */
+ int node_status; /* Node's kOldStatus: -1 until a change is reported */
+ ScrStats *last; /* the last successful snapshot (+1), NULL = none yet */
+ ScrWatchFileL *ls;
+ size_t n, cap;
+ double timer; /* the armed poll timer's handle; 0 when none is armed */
+ ScrClosure *pollCb; /* the re-armed poll closure (+1); NULL once stopped */
+ struct ScrWatchFiler *next; /* the poller registry (+1 each) */
+};
+
+typedef struct ScrWatchFiler ScrWatchFiler;
+
+static SCR_TL ScrWatchFiler *scr_watch_filers = NULL; /* the registry (+1 each) */
+
+static ScrWatchFiler *scr_watch_file_retain(ScrWatchFiler *p) {
+ if (p->rc != SIZE_MAX) p->rc++;
+ return p;
+}
+
+static void *scr_watch_file_retain_v(void *p) { return scr_watch_file_retain((ScrWatchFiler *)p); }
+
+static void scr_watch_file_release(ScrWatchFiler *p) {
+ if (!p || p->rc == SIZE_MAX) return;
+ if (--p->rc == 0) {
+ for (size_t i = 0; i < p->n; i++) scr_closure_release(p->ls[i].cb);
+ free(p->ls);
+ if (p->timer != 0) {
+ scr_clear_interval(p->timer);
+ p->timer = 0;
+ }
+ scr_closure_release(p->pollCb);
+ scr_stats_release(p->last);
+ scr_str_release(p->path);
+ free(p);
+ }
+}
+
+static void scr_watch_file_release_v(void *p) { scr_watch_file_release((ScrWatchFiler *)p); }
+
+/* path.resolve over the one argument: the poller's key AND the stat target,
+ * so `./x` and `/abs/x` share one poller, exactly like Node's
+ * resolve-and-match. */
+static ScrStr *scr_watch_file_resolve(ScrStr *path) {
+ ScrArr *parts = scr_arr_new(SCR_ELEM_STR, 1);
+ scr_arr_push_ref(parts, scr_str_retain(path));
+ ScrStr *resolved = scr_path_resolve(parts);
+ scr_arr_release(parts);
+ return resolved;
+}
+
+static ScrWatchFiler *scr_watch_file_find(ScrStr *resolved) {
+ for (ScrWatchFiler *p = scr_watch_filers; p != NULL; p = p->next) {
+ if (scr_str_eq(p->path, resolved)) return p;
+ }
+ return NULL;
+}
+
+/* Arms the next poll: the first one at 0 (libuv stats on a later loop
+ * turn, never synchronously), then one per interval. */
+static void scr_watch_file_arm(ScrWatchFiler *p, double ms) {
+ p->timer = scr_set_timeout_handle(scr_closure_retain(p->pollCb), ms);
+ if (!p->persistent) scr_timer_unref(p->timer);
+}
+
+/* libuv's poll comparison: ctime, mtime and birthtime (the *_ms doubles
+ * carry the nanosecond fraction, so a sub-millisecond change still
+ * differs), size, mode, uid, gid, ino and dev. `uv_stat_t`'s flags and gen
+ * have no ScrStats field, so they are not compared (libuv does). A NULL
+ * side (no successful snapshot yet) never compares equal. */
+static bool scr_stats_same(ScrStats *a, ScrStats *b) {
+ if (a == NULL || b == NULL) return false;
+ return scr_stats_ctime_ms(a) == scr_stats_ctime_ms(b) &&
+ scr_stats_mtime_ms(a) == scr_stats_mtime_ms(b) &&
+ scr_stats_birthtime_ms(a) == scr_stats_birthtime_ms(b) &&
+ scr_stats_size(a) == scr_stats_size(b) && scr_stats_mode(a) == scr_stats_mode(b) &&
+ scr_stats_uid(a) == scr_stats_uid(b) && scr_stats_gid(a) == scr_stats_gid(b) &&
+ scr_stats_ino(a) == scr_stats_ino(b) && scr_stats_dev(a) == scr_stats_dev(b);
+}
+
+/* Emits one change: `cur` is the fresh snapshot (NULL = all-zero),
+ * `prev` the last successful one (NULL = all-zero), `new_status` Node's
+ * status for this poll (0 success, -errno failure). Listeners run in
+ * registration order off a snapshot, each with its own +1 Stats refs. */
+static void scr_watch_file_emit(ScrWatchFiler *p, ScrStats *cur, ScrStats *prev, int new_status) {
+ double ncur = cur != NULL ? scr_stats_nlink(cur) : 0;
+ double nprev = prev != NULL ? scr_stats_nlink(prev) : 0;
+ if (p->node_status == -1 && new_status == -1 && ncur == nprev) return;
+ p->node_status = new_status;
+ if (p->n == 0) return;
+ ScrStats *c = cur != NULL ? scr_stats_retain(cur) : scr_stats_zero();
+ ScrStats *v = prev != NULL ? scr_stats_retain(prev) : scr_stats_zero();
+ ScrWatchFileL *snap = malloc(p->n * sizeof *snap);
+ if (!snap) {
+ scr_stats_release(c);
+ scr_stats_release(v);
+ scr_watch_file_oom();
+ }
+ memcpy(snap, p->ls, p->n * sizeof *snap);
+ size_t n = p->n;
+ for (size_t i = 0; i < n; i++) scr_closure_retain(snap[i].cb);
+ for (size_t i = 0; i < n; i++) {
+ if (!scr_exc_pending()) snap[i].fn(snap[i].cb, scr_stats_retain(c), scr_stats_retain(v));
+ scr_closure_release(snap[i].cb);
+ }
+ free(snap);
+ scr_stats_release(c);
+ scr_stats_release(v);
+}
+
+/* The timer's body: stat, decide (libuv's poll_cb then Node's onchange),
+ * and re-arm unless a listener stopped the poller. */
+static void scr_watch_file_poll(ScrClosure *cb) {
+ ScrWatchFiler *p = scr_box_get_ref(cb->caps[0]);
+ if (p == NULL) return;
+ p->timer = 0; /* the loop consumed this arm */
+ int err = 0;
+ ScrStats *cur = scr_stats_of_path(p->path, &err);
+ if (cur == NULL) {
+ if (p->state != -err) {
+ scr_watch_file_emit(p, NULL, p->last, -err);
+ p->state = -err;
+ }
+ } else {
+ if (p->state != 0 && (p->state < 0 || !scr_stats_same(p->last, cur)))
+ scr_watch_file_emit(p, cur, p->last, 0);
+ scr_stats_release(p->last);
+ p->last = scr_stats_retain(cur);
+ p->state = 1;
+ }
+ scr_stats_release(cur); /* NULL-tolerant */
+ /* A listener may have unwatched the last listener (unwatchFile stops
+ * the poller): only a poller with listeners re-arms. */
+ if (p->n > 0 && p->pollCb != NULL && !scr_exc_pending()) scr_watch_file_arm(p, p->interval);
+ scr_watch_file_release(p);
+}
+
+static void scr_watch_file_add(ScrWatchFiler *p, ScrClosure *cb /*moves*/, ScrWatchFileFn fn) {
+ if (p->n == p->cap) {
+ size_t cap = p->cap != 0 ? p->cap * 2 : 4;
+ ScrWatchFileL *grown = realloc(p->ls, cap * sizeof *grown);
+ if (!grown) {
+ scr_closure_release(cb);
+ scr_watch_file_oom();
+ }
+ p->ls = grown;
+ p->cap = cap;
+ }
+ p->ls[p->n].cb = cb;
+ p->ls[p->n].fn = fn;
+ p->n++;
+}
+
+/* Stops a poller: clears its timer, unlinks it from the registry, and
+ * breaks the poll-closure cycle (closure → box → poller) so the last
+ * external reference frees the poller. Safe from inside a listener: the
+ * poll frame's own reference keeps `p` alive. */
+static void scr_watch_file_stop(ScrWatchFiler *p) {
+ scr_watch_file_retain(p); /* the frame's reference */
+ if (p->timer != 0) {
+ scr_clear_interval(p->timer);
+ p->timer = 0;
+ }
+ for (ScrWatchFiler **pp = &scr_watch_filers; *pp != NULL; pp = &(*pp)->next) {
+ if (*pp == p) {
+ *pp = p->next;
+ p->next = NULL;
+ scr_watch_file_release(p); /* the registry's reference */
+ break;
+ }
+ }
+ ScrClosure *pollCb = p->pollCb;
+ p->pollCb = NULL;
+ scr_closure_release(pollCb); /* drops the box's reference */
+ scr_watch_file_release(p);
+}
+
+/* fs.watchFile(path, interval, persistent, listener) — the listener MOVES
+ * in. A second call on the same resolved path adds the listener to the
+ * existing poller and ignores its options (Node's rule). */
+void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb,
+ ScrWatchFileFn fn) {
+ ScrStr *resolved = scr_watch_file_resolve(path);
+ ScrWatchFiler *existing = scr_watch_file_find(resolved);
+ if (existing != NULL) {
+ scr_str_release(resolved);
+ scr_watch_file_add(existing, cb, fn);
+ return;
+ }
+ ScrWatchFiler *p = malloc(sizeof *p);
+ if (!p) {
+ scr_str_release(resolved);
+ scr_closure_release(cb);
+ scr_watch_file_oom();
+ }
+ p->rc = 1;
+ p->path = resolved;
+ p->interval = interval;
+ p->persistent = persistent;
+ p->state = 0;
+ p->node_status = -1;
+ p->last = NULL;
+ p->ls = NULL;
+ p->n = p->cap = 0;
+ p->timer = 0;
+ /* The poll closure captures the poller through a box, which retains it
+ * from here on (the cycle scr_watch_file_stop breaks). */
+ p->pollCb = scr_closure_new((void *)scr_watch_file_poll, 1);
+ p->pollCb->caps[0] =
+ scr_box_new_obj(scr_watch_file_retain_v, scr_watch_file_release_v, NULL);
+ scr_box_set_ref(p->pollCb->caps[0], p);
+ scr_watch_file_add(p, cb, fn);
+ p->next = scr_watch_filers;
+ scr_watch_filers = p;
+ scr_watch_file_retain(p); /* the registry's reference */
+ scr_watch_file_arm(p, 0);
+ /* The creating reference moved into the box (scr_box_set_ref moves, it
+ * does not retain): the box and the registry are the two owners. */
+}
+
+/* fs.unwatchFile(path[, listener]) — `cb` is borrowed (pointer identity);
+ * NULL removes every listener on the resolved path. Removing the last one
+ * stops the poller. */
+void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) {
+ ScrStr *resolved = scr_watch_file_resolve(path);
+ ScrWatchFiler *p = scr_watch_file_find(resolved);
+ scr_str_release(resolved);
+ if (p == NULL) return;
+ if (cb != NULL) {
+ /* The LAST listener registered with this closure goes (Node's rule). */
+ for (size_t i = p->n; i-- > 0;) {
+ if (p->ls[i].cb == cb) {
+ scr_closure_release(p->ls[i].cb);
+ memmove(&p->ls[i], &p->ls[i + 1], (p->n - i - 1) * sizeof *p->ls);
+ p->n--;
+ break;
+ }
+ }
+ } else {
+ for (size_t i = 0; i < p->n; i++) scr_closure_release(p->ls[i].cb);
+ p->n = 0;
+ }
+ if (p->n == 0) scr_watch_file_stop(p);
+}
+
+/* ── the runtime-provided listener adapters ───────────────────────────
+ * The listener owns its +1 arguments (the universal convention
+ * scr_watch_thunk_event follows). */
+void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
+ (void)current;
+ (void)previous;
+ ((void (*)(ScrClosure *))cb->fn)(cb);
+}
+
+void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
+ (void)previous;
+ ((void (*)(ScrClosure *, ScrStats *))cb->fn)(cb, current);
+}
+
+void scr_watch_file_thunk2(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
+ ((void (*)(ScrClosure *, ScrStats *, ScrStats *))cb->fn)(cb, current, previous);
+}
+
+/* Exit-time registry cleanup (the scr_watch.c precedent): pollers a program
+ * legitimately leaves running at exit release their listeners and timers,
+ * so the RC audit sees a clean heap. */
+static void scr_watch_file_cleanup_atexit(void) {
+ while (scr_watch_filers != NULL) scr_watch_file_stop(scr_watch_filers);
+}
+
+void scr_watch_file_install(void) {
+ static SCR_TL bool installed = false;
+ if (installed) return;
+ installed = true;
+ scr_atexit(scr_watch_file_cleanup_atexit);
+}
diff --git a/tests/corpus/fs-watch-file-names.ts b/tests/corpus/fs-watch-file-names.ts
new file mode 100644
index 0000000000..61c8243326
--- /dev/null
+++ b/tests/corpus/fs-watch-file-names.ts
@@ -0,0 +1,60 @@
+// fs.watch listeners that take the filename, on a FILE watch: the
+// filename is the watched file's basename. The listener can be a runtime
+// value typed WatchListener, and the watcher's EventEmitter
+// surface takes more 'change' listeners (on), a one-shot one (once), an
+// 'error' listener (never called here), and a 'close' listener, which
+// runs on the next tick after close() (before immediates) and only once
+// however often close() is called. Triggers repeat on an interval until
+// the event lands; event counts and coalescing are deliberately
+// unobserved (they differ between inotify, kqueue and FSEvents).
+import * as fs from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+const dir = fs.mkdtempSync(join(tmpdir(), "scr-watch-names-"));
+const head = join(dir, "HEAD");
+fs.writeFileSync(head, "ref: main\n");
+
+let firstName: string | null = "unset";
+let secondName = "unset";
+let onceCalls = 0;
+const listener: fs.WatchListener = (eventType, filename) => {
+ if (firstName !== "unset") return;
+ firstName = filename;
+ console.log("listener value names", filename, eventType === "change" || eventType === "rename");
+};
+const watcher = fs.watch(head, listener);
+// 'change' listener named as any: the @types/node overload declares
+// filename string | Buffer, whose bytes arm has no lowering (Node passes a
+// string with the default encoding), so the checked name arrives as dyn.
+watcher.on("change", (_eventType: string, filename: any) => {
+ if (secondName !== "unset") return;
+ secondName = String(filename);
+ console.log("on('change') names", secondName);
+});
+watcher.once("change", () => {
+ onceCalls++;
+});
+watcher.on("error", (error: Error) => console.log("error", error.message));
+watcher.on("close", () => {
+ console.log("close event", onceCalls);
+ setImmediate(() => {
+ console.log("immediate after close");
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+});
+
+const bound = setTimeout(() => {
+ console.log("timed out");
+ process.exit(1);
+}, 10000);
+const touch = setInterval(() => fs.appendFileSync(head, "x"), 40);
+const poll = setInterval(() => {
+ if (firstName === "unset" || secondName === "unset") return;
+ clearInterval(poll);
+ clearInterval(touch);
+ clearTimeout(bound);
+ watcher.close();
+ watcher.close();
+ console.log("closed");
+}, 10);
diff --git a/tests/corpus/fs-watch-file-polling.ts b/tests/corpus/fs-watch-file-polling.ts
new file mode 100644
index 0000000000..87cddf867f
--- /dev/null
+++ b/tests/corpus/fs-watch-file-polling.ts
@@ -0,0 +1,49 @@
+// fs.watchFile / fs.unwatchFile: stat polling at the given interval. The
+// listener receives (current, previous) Stats whenever a poll sees a
+// difference; the first poll only records the baseline. A missing file
+// polls as all-zero Stats: the first poll reports one change with both
+// sides zeroed, and the creation reports previous.size 0 and previous.mtimeMs 0. Two watchFile calls on one path share one poller;
+// unwatchFile(path, listener) removes one listener, unwatchFile(path)
+// removes the rest, and once no listener is left the poller stops and
+// the process can exit.
+import * as fs from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+const dir = fs.mkdtempSync(join(tmpdir(), "scr-watchfile-"));
+const existing = join(dir, "existing.txt");
+const later = join(dir, "later.txt");
+fs.writeFileSync(existing, "a");
+
+let firstCalls = 0;
+const first = (current: fs.Stats, previous: fs.Stats): void => {
+ firstCalls++;
+ if (firstCalls === 1) console.log("existing grew", previous.size, "->", current.size);
+};
+const second = (current: fs.Stats, previous: fs.Stats): void => {
+ console.log("second listener", previous.size, "->", current.size, current.isFile());
+ fs.unwatchFile(existing, first);
+ fs.unwatchFile(existing);
+ startMissingPhase();
+};
+fs.watchFile(existing, { interval: 20 }, first);
+fs.watchFile(existing, { interval: 1000 }, second);
+setTimeout(() => fs.writeFileSync(existing, "abcd"), 150);
+
+function startMissingPhase(): void {
+ let events = 0;
+ const listener = (current: fs.Stats, previous: fs.Stats): void => {
+ events++;
+ if (events === 1) {
+ // The first poll fails (ENOENT): one change with both sides zeroed.
+ console.log("missing", previous.size, previous.mtimeMs, previous.isFile(), current.size, current.isFile());
+ setTimeout(() => fs.writeFileSync(later, "hello"), 100);
+ return;
+ }
+ console.log("created", previous.size, previous.mtimeMs, "->", current.size, current.isFile());
+ fs.unwatchFile(later, listener);
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log("unwatched");
+ };
+ fs.watchFile(later, { interval: 20, persistent: true }, listener);
+}
diff --git a/tests/harness/corpus-costs/fs-watch-file-names.ts.json b/tests/harness/corpus-costs/fs-watch-file-names.ts.json
new file mode 100644
index 0000000000..91f0b7565f
--- /dev/null
+++ b/tests/harness/corpus-costs/fs-watch-file-names.ts.json
@@ -0,0 +1 @@
+{ "plain": 1300, "san": 3300 }
diff --git a/tests/harness/corpus-costs/fs-watch-file-polling.ts.json b/tests/harness/corpus-costs/fs-watch-file-polling.ts.json
new file mode 100644
index 0000000000..376e9a4e65
--- /dev/null
+++ b/tests/harness/corpus-costs/fs-watch-file-polling.ts.json
@@ -0,0 +1 @@
+{ "plain": 1300, "san": 3800 }