fs.watch/fs.watchFile: fix listener removal, adapter ownership and argument checks

- watcher.off/removeListener of a listener that is not the last one moves
  the later listeners down instead of up, so the removed closure leaves
  the list.
- The watchFile adapters for zero- and one-parameter listeners release
  the Stats their listener has no parameter for, and the fs.watch adapter
  releases the event string once a dynamic value holds it.
- A one-parameter fs.watch listener whose eventType is dynamic receives a
  boxed string through the emitted adapter.
- fs.watch, fs.watchFile and fs.unwatchFile throw ERR_INVALID_ARG_VALUE
  for a path holding a null byte, and a new watchFile poller throws
  ERR_OUT_OF_RANGE when interval is not a uint32, as Node does. Both
  watchFile libCalls check for the pending exception.
- fs.watchFile and fs.unwatchFile have surface-manifest entries, so a
  fence can deny them.
- The self-hosted feature records list watchFile, and the node-types
  fence fixture uses fs.openAsBlob in place of the now-lowered
  fs.watchFile.

Tests: tests/corpus/fs-watch-listener-shapes.ts (listener shapes, off of
a middle listener, null-byte path) with its order baseline and cost file,
the validation cases in fs-watch-file-polling.ts, and
tests/harness/fs-watch-leaks.test.ts, which runs both programs under
LeakSanitizer in the sanitized lane.
This commit is contained in:
Chris Tate
2026-10-29 01:47:31 -05:00
parent 7176257136
commit 089a8e1799
15 changed files with 353 additions and 87 deletions
@@ -966,10 +966,12 @@ registerReplay("streamDoneFnFor", (host, args) =>
registerReplay("fsRenameThunkFor", (host, args) =>
fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }),
);
/** The (eventType, filename) fs.watch listener adapter: the runtime hands
/** The fs.watch listener adapter for an (eventType, filename) listener, or
* for an (eventType) listener whose parameter is dynamic: the runtime hands
* the borrowed C strings, the thunk builds what the listener declared. The
* listener owns its +1 arguments (the universal convention
* scr_watch_thunk_event follows). */
* scr_watch_thunk_event follows), so a string the thunk boxes into a dynamic
* value is released once the box holds it. */
export function watchNameThunkFor(
host: LlvmEmitterContext,
cbT: IrType & { kind: "func" },
@@ -989,76 +991,84 @@ export function watchNameThunkFor(
`entry:`,
` %evlen = call ${S} @strlen(ptr %event)`,
` %evstr = call ptr @scr_str_new(ptr %event, ${S} %evlen)`,
` %slot = alloca ptr`,
` %has = icmp ne ptr %name, null`,
` br i1 %has, label %yes, label %no`,
`yes:`,
];
const nameParam = cbT.params[1]!;
if (nameParam.kind === "dyn") {
host.declare(`declare ptr @scr_dyn_new_null()`);
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
const named = cbT.params.length === 2;
if (named) {
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`,
` call void @scr_str_release(ptr %nstr)`,
` store ptr %ndyn, ptr %slot`,
` br label %go`,
`no:`,
` %dn = call ptr @scr_dyn_new_null()`,
` store ptr %dn, ptr %slot`,
` %slot = alloca ptr`,
` %has = icmp ne ptr %name, null`,
` br i1 %has, label %yes, label %no`,
`yes:`,
);
} else {
if (nameParam.kind !== "union")
throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union");
const def = host.unionsById.get(nameParam.unionId);
const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1;
const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
if (strTag < 0 || nullTag < 0)
throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms");
if (host.nullableUnions.has(nameParam.unionId)) {
// `string | null` erases: the owned payload IS the union's value.
const nameParam = cbT.params[1]!;
if (nameParam.kind === "dyn") {
host.declare(`declare ptr @scr_dyn_new_null()`);
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` store ptr %nstr, ptr %slot`,
` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`,
` call void @scr_str_release(ptr %nstr)`,
` store ptr %ndyn, ptr %slot`,
` br label %go`,
`no:`,
` store ptr null, ptr %slot`,
` %dn = call ptr @scr_dyn_new_null()`,
` store ptr %dn, ptr %slot`,
);
} else {
const rc = vAdapters(host.shapeHost, STRING);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`,
` store ptr %nu, ptr %slot`,
` br label %go`,
`no:`,
` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`,
);
if (nameParam.kind !== "union")
throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union");
const def = host.unionsById.get(nameParam.unionId);
const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1;
const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
if (strTag < 0 || nullTag < 0)
throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms");
if (host.nullableUnions.has(nameParam.unionId)) {
// `string | null` erases: the owned payload IS the union's value.
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` store ptr %nstr, ptr %slot`,
` br label %go`,
`no:`,
` store ptr null, ptr %slot`,
);
} else {
const rc = vAdapters(host.shapeHost, STRING);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`,
` store ptr %nu, ptr %slot`,
` br label %go`,
`no:`,
` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`,
);
}
}
d.push(` br label %go`, `go:`);
}
let ev: string;
if (cbT.params[0]?.kind === "dyn") {
// The box retains the string; the thunk's own reference goes.
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
d.push(` br label %go`, `go:`, ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`);
d.push(
` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`,
` call void @scr_str_release(ptr %evstr)`,
);
ev = "%evd";
} else {
d.push(` br label %go`, `go:`);
ev = "%evstr";
}
d.push(
` %arg = load ptr, ptr %slot`,
` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
` %fn = load ptr, ptr %fnp`,
` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`,
` ret void`,
`}`,
``,
);
if (named)
d.push(` %arg = load ptr, ptr %slot`, ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`);
else d.push(` call void %fn(ptr %cb, ptr ${ev})`);
d.push(` ret void`, `}`, ``);
host.resolveThunkDefs.push(...d);
host.journal?.built();
return sym;
@@ -1,7 +1,7 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { DYN, F64, STATS_T, STRING, type IrExpr } from "../../ir/ir.js";
import { DYN, F64, STATS_T, STRING, type IrExpr, type IrType } from "../../ir/ir.js";
import { mangleRecordNew } from "../mangle.js";
import { arrNewCall, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
@@ -173,6 +173,22 @@ function emitObjEntryPairs(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return out;
}
/** The adapter of an fs.watch 'change' listener, by its declared shape: the
* runtime's for no parameters and for a string eventType, and one this
* module emits for a dynamic eventType or an (eventType, filename) listener.
* Only the runtime's adapters need a declaration. */
function watchChangeAdapter(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string {
const runtime =
cbT.params.length === 0
? "scr_watch_thunk0"
: cbT.params.length === 1 && cbT.params[0]!.kind !== "dyn"
? "scr_watch_thunk_event"
: null;
if (runtime === null) return host.watchNameThunkFor(cbT);
host.declare(`declare void @${runtime}(ptr, ptr, ptr)`);
return runtime;
}
export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
const alwaysThrowSym = FS_ALWAYS_THROW_SYMS[e.fn];
@@ -468,15 +484,7 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
const path = host.emitExpr(e.args[0]!);
const cb = host.emitExpr(e.args[1]!);
host.toHeap(cb);
const adapter =
cbT.params.length === 0
? "scr_watch_thunk0"
: cbT.params.length === 1
? "scr_watch_thunk_event"
: host.watchNameThunkFor(cbT);
// The named form is a definition this module emits; only the runtime's
// fixed adapters need a declaration.
if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
const adapter = watchChangeAdapter(host, cbT);
host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`);
const t = B.tmp();
B.line(
@@ -509,16 +517,8 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
let adapter = "null";
let wantsName = false;
if (code === 0) {
adapter =
cbT.params.length === 0
? "scr_watch_thunk0"
: cbT.params.length === 1
? "scr_watch_thunk_event"
: host.watchNameThunkFor(cbT);
adapter = watchChangeAdapter(host, cbT);
wantsName = cbT.params.length === 2;
// The named form is a definition this module emits; only the runtime's
// fixed adapters need a declaration.
if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
} else if (code === 1) {
adapter = cbT.params.length === 0 ? "scr_watch_err_thunk0" : host.watchErrorThunkFor(cbT);
if (cbT.params.length === 0) host.declare(`declare void @scr_watch_err_thunk0(ptr, ptr)`);
@@ -553,7 +553,9 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
// (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.
// re-arms a timer every interval, so this is a timer program. May
// throw (a null byte in the path, or an interval that is not a
// uint32) — the standard pending check.
host.usesTimers = true;
const cbT = e.args[3]!.type;
if (cbT.kind !== "func")
@@ -569,18 +571,21 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
B.line(
`call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`,
);
host.emitPendingCheck();
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.
// the NULL that removes every listener on the path. A null byte in
// the path throws.
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})`,
);
host.emitPendingCheck();
return { name: "", type: e.type };
}
if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") {
@@ -2416,6 +2416,22 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] {
fns: ["fs.callbackValue", "fs.callbackCall", "fs.callbackGlob"],
note: "native filesystem callbacks, including stored platform adapter functions",
},
// fs.watchFile/fs.unwatchFile lower in lowerFsWatchFileCall, ahead of
// the module table.
{
id: "node-builtin.fs.watchFile",
kind: "node-builtin",
name: "fs.watchFile",
fns: ["fs.watchFile"],
note: "stat polling of a resolved path with Stats listeners, in statement position; bigint: true is refused",
},
{
id: "node-builtin.fs.unwatchFile",
kind: "node-builtin",
name: "fs.unwatchFile",
fns: ["fs.unwatchFile"],
note: "removes one listener, or every listener, from a resolved path's poller",
},
// ── the Date slice (lowerDateCall/lowerNew): statics, construction,
// stored read-only values, calendar getters, and ISO formatting.
{
+14
View File
@@ -1279,6 +1279,13 @@
"name": "fs.unlinkSync",
"status": "static"
},
{
"id": "node-builtin.fs.unwatchFile",
"kind": "node-builtin",
"name": "fs.unwatchFile",
"status": "static",
"note": "removes one listener, or every listener, from a resolved path's poller"
},
{
"id": "node-builtin.fs.utimesSync",
"kind": "node-builtin",
@@ -1291,6 +1298,13 @@
"name": "fs.watch",
"status": "static"
},
{
"id": "node-builtin.fs.watchFile",
"kind": "node-builtin",
"name": "fs.watchFile",
"status": "static",
"note": "stat polling of a resolved path with Stats listeners, in statement position; bigint: true is refused"
},
{
"id": "node-builtin.fs.writeFileSync",
"kind": "node-builtin",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/fs-watch-listener-shapes.ts"
],
"diags": []
}
+7 -1
View File
@@ -288,6 +288,12 @@ void scr_watcher_release_v(void *p) { scr_watcher_release((ScrWatcher *)p); }
* filter. cb (nullable) moves in as the first listener. Result +1. */
ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn,
bool wants_name) {
/* getValidatedPath: a null byte throws ERR_INVALID_ARG_VALUE before the
* path reaches the platform call. */
if (!scr_fs_path_ok(path, "path")) {
if (cb != NULL) scr_closure_release(cb);
return NULL; /* exception pending */
}
#ifdef SCR_HAVE_INOTIFY
/* The wd IS the validation: Node's Linux throw comes straight from
* inotify_add_watch's errno, the same message shape as the open below. */
@@ -542,7 +548,7 @@ void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb) {
ScrWatchL *l = &w->ls[i - 1];
if (l->event != (uint8_t)event || l->cb != cb) continue;
scr_closure_release(l->cb);
memmove(l + 1, l, (w->n - i) * sizeof *w->ls);
memmove(l, l + 1, (w->n - i) * sizeof *w->ls);
w->n--;
return;
}
+37 -8
View File
@@ -32,6 +32,7 @@
#include "scr_runtime.h"
#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -230,11 +231,31 @@ static void scr_watch_file_stop(ScrWatchFiler *p) {
scr_watch_file_release(p);
}
/* StatWatcher's validateUint32(interval, 'interval'): false after throwing
* Node's ERR_OUT_OF_RANGE RangeError. */
static bool scr_watch_file_interval_ok(double interval) {
bool integer = isfinite(interval) && trunc(interval) == interval;
if (integer && interval >= 0 && interval <= 4294967295.0) return true;
char recv[48], msg[160];
scr_num_received(interval, recv);
int len = snprintf(msg, sizeof msg,
"The value of \"interval\" is out of range. It must be %s. Received %s",
integer ? ">= 0 && <= 4294967295" : "an integer", recv);
scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE");
return false;
}
/* 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). */
* in. A path holding a null byte throws Node's ERR_INVALID_ARG_VALUE. A
* second call on the same resolved path adds the listener to the existing
* poller and ignores its options (Node's rule); a new poller first checks
* `interval` as a uint32, and registers nothing when that throws. */
void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb,
ScrWatchFileFn fn) {
if (!scr_fs_path_ok(path, "path")) {
scr_closure_release(cb);
return; /* exception pending */
}
ScrStr *resolved = scr_watch_file_resolve(path);
ScrWatchFiler *existing = scr_watch_file_find(resolved);
if (existing != NULL) {
@@ -242,6 +263,11 @@ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosur
scr_watch_file_add(existing, cb, fn);
return;
}
if (!scr_watch_file_interval_ok(interval)) {
scr_str_release(resolved);
scr_closure_release(cb);
return; /* exception pending */
}
ScrWatchFiler *p = malloc(sizeof *p);
if (!p) {
scr_str_release(resolved);
@@ -275,8 +301,10 @@ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosur
/* fs.unwatchFile(path[, listener]) — `cb` is borrowed (pointer identity);
* NULL removes every listener on the resolved path. Removing the last one
* stops the poller. */
* stops the poller. A path holding a null byte throws Node's
* ERR_INVALID_ARG_VALUE. */
void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) {
if (!scr_fs_path_ok(path, "path")) return; /* exception pending */
ScrStr *resolved = scr_watch_file_resolve(path);
ScrWatchFiler *p = scr_watch_file_find(resolved);
scr_str_release(resolved);
@@ -299,16 +327,17 @@ void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) {
}
/* ── the runtime-provided listener adapters ───────────────────────────
* The listener owns its +1 arguments (the universal convention
* scr_watch_thunk_event follows). */
* The adapter receives +1 references and the listener owns those it takes
* (the universal convention scr_watch_thunk_event follows); the adapter
* releases the ones its listener has no parameter for. */
void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
(void)current;
(void)previous;
scr_stats_release(current);
scr_stats_release(previous);
((void (*)(ScrClosure *))cb->fn)(cb);
}
void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
(void)previous;
scr_stats_release(previous);
((void (*)(ScrClosure *, ScrStats *))cb->fn)(cb, current);
}
+24 -2
View File
@@ -5,7 +5,9 @@
// 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.
// the process can exit. A new poller's interval must be a uint32 (a call on
// a watched path ignores its options), and a path holding a null byte
// throws for both calls.
import * as fs from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -15,6 +17,26 @@ const existing = join(dir, "existing.txt");
const later = join(dir, "later.txt");
fs.writeFileSync(existing, "a");
const invalid = join(dir, "invalid.txt");
for (const interval of [-1, 1.5, Number.NaN, 2 ** 32]) {
try {
fs.watchFile(invalid, { interval }, () => {});
console.log("interval", interval, "accepted");
} catch (err) {
console.log("interval", interval, (err as NodeJS.ErrnoException).code, (err as Error).message);
}
}
try {
fs.watchFile("bad\0path", () => {});
} catch (err) {
console.log("watchFile", (err as NodeJS.ErrnoException).code, (err as Error).message);
}
try {
fs.unwatchFile("bad\0path");
} catch (err) {
console.log("unwatchFile", (err as NodeJS.ErrnoException).code, (err as Error).message);
}
let firstCalls = 0;
const first = (current: fs.Stats, previous: fs.Stats): void => {
firstCalls++;
@@ -27,7 +49,7 @@ const second = (current: fs.Stats, previous: fs.Stats): void => {
startMissingPhase();
};
fs.watchFile(existing, { interval: 20 }, first);
fs.watchFile(existing, { interval: 1000 }, second);
fs.watchFile(existing, { interval: -1 }, second);
setTimeout(() => fs.writeFileSync(existing, "abcd"), 150);
function startMissingPhase(): void {
+91
View File
@@ -0,0 +1,91 @@
// fs.watch and fs.watchFile listeners of each shape. watcher.off of a
// 'change' listener that is not the last one leaves the others, in order.
// A one-parameter listener typed any and a two-parameter listener whose
// eventType is any receive the event name as a string. fs.watchFile
// listeners with no parameter and with one parameter run once per change
// a poll sees, like the two-parameter form. Event counts and coalescing are
// deliberately unobserved (they differ between inotify, kqueue and
// FSEvents); each watchFile change waits for the previous one's event.
// A path holding a null byte throws before anything is watched.
import * as fs from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const dir = fs.mkdtempSync(join(tmpdir(), "scr-watch-shapes-"));
const file = join(dir, "data.txt");
fs.writeFileSync(file, "a");
try {
fs.watch("bad\0path", () => {}).close();
console.log("watch accepted");
} catch (err) {
console.log("watch", (err as NodeJS.ErrnoException).code, (err as Error).message);
}
const order: string[] = [];
let anyEvent: any = null;
let dynEvent: any = null;
let named = 0;
const watcher = fs.watch(file, (eventType: any) => {
if (anyEvent === null) anyEvent = eventType;
});
const first = () => {
order.push("first");
};
const second = () => {
order.push("second");
};
const third = () => {
order.push("third");
};
watcher.on("change", first);
watcher.on("change", second);
watcher.on("change", third);
watcher.off("change", second);
watcher.on("change", (eventType: any, filename: any) => {
if (dynEvent === null) dynEvent = eventType;
if (filename !== null) named++;
});
const bound = setTimeout(() => {
console.log("timed out");
process.exit(1);
}, 10000);
const touch = setInterval(() => fs.appendFileSync(file, "x"), 40);
const poll = setInterval(() => {
if (anyEvent === null || dynEvent === null || order.length < 2) return;
clearInterval(poll);
clearInterval(touch);
watcher.close();
console.log("after off", order.slice(0, 2).join(","));
console.log("any eventType", typeof anyEvent, anyEvent === "change" || anyEvent === "rename");
console.log("dynamic eventType", typeof dynEvent, dynEvent === "change" || dynEvent === "rename");
startPolling();
}, 10);
function startPolling(): void {
let zeroCalls = 0;
let oneCalls = 0;
let size = 0;
const zero = (): void => {
zeroCalls++;
};
const one = (current: fs.Stats): void => {
oneCalls++;
size = current.size;
if (oneCalls < 3) {
setTimeout(() => fs.writeFileSync(file, "x".repeat(oneCalls + 10)), 30);
return;
}
setTimeout(() => {
console.log("watchFile calls", zeroCalls === oneCalls, oneCalls, "size", size);
fs.unwatchFile(file);
clearTimeout(bound);
fs.rmSync(dir, { recursive: true, force: true });
console.log("done");
}, 60);
};
fs.watchFile(file, { interval: 20 }, zero);
fs.watchFile(file, { interval: 20 }, one);
setTimeout(() => fs.writeFileSync(file, "x".repeat(10)), 100);
}
+2 -2
View File
@@ -32,10 +32,10 @@ ReadableStream.from(new Set([1, 2]));
/* Members of SUPPORTED builtin modules beyond the lowered tables: they
* typecheck under @types/node and fence with the module-qualified name —
* calls and value reads alike. */
import { watchFile } from "fs";
import { openAsBlob } from "fs";
import { EventEmitter, on } from "node:events";
import { win32 } from "path";
watchFile("x", () => {});
openAsBlob("x");
on(new EventEmitter(), "tick");
console.log(win32.sep);
/* URL getters and serialization lower under @types/node, but object-URL
@@ -43,18 +43,18 @@ fenced.ts:31:21 - error SC2020: 'ReadableStream.from() over Set<number> iterable
hint: arrays, Uint8Array, and strings are supported — spread other synchronous iterables into an array first: [...iterable]
fenced.ts:38:1 - error SC2020: 'fs.watchFile' is typed by @types/node but has no scriptc lowering yet
fenced.ts:38:1 - error SC2020: 'fs.openAsBlob' is typed by @types/node but has no scriptc lowering yet
37 | import { win32 } from "path";
38 | watchFile("x", () => {});
| ^~~~~~~~~~~~~~~~~~~~~~~~
38 | openAsBlob("x");
| ^~~~~~~~~~~~~~~
39 | on(new EventEmitter(), "tick");
hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations)
fenced.ts:39:1 - error SC2020: 'events.on' is typed by @types/node but has no scriptc lowering yet
38 | watchFile("x", () => {});
38 | openAsBlob("x");
39 | on(new EventEmitter(), "tick");
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
40 | console.log(win32.sep);
@@ -0,0 +1 @@
{ "plain": 3100, "san": 9200 }
+64
View File
@@ -0,0 +1,64 @@
/* fs.watch and fs.watchFile listeners under LeakSanitizer. A listener owns
* the arguments its adapter hands it, and the adapter releases the ones the
* listener has no parameter for, including the event string it boxes for a
* dynamic parameter and the Stats a watchFile listener of zero or one
* parameter leaves out. The sanitized lanes otherwise run with
* detect_leaks=0, so this runs the watch programs with LeakSanitizer
* explicitly on. Linux only (Apple's ASan has no LeakSanitizer); runs in
* the SCRIPTC_SAN=1 lane. */
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const programs = [
"tests/corpus/fs-watch-listener-shapes.ts",
"tests/corpus/fs-watch-file-polling.ts",
];
describe.skipIf(!sanitize || process.platform !== "linux")(
"fs watchers under LeakSanitizer",
() => {
test.each(programs)("%s reports no leaks", { timeout: 300_000 }, async (program) => {
const file = join(repoRoot, program);
const key = createHash("sha256")
.update(file)
.update(readFileSync(file))
.update("san")
.digest("hex")
.slice(0, 16);
const outDir = join(cacheDir, `fs-watch-leaks-${key}`);
mkdirSync(outDir, { recursive: true });
const result = await compile(file, {
outPath: join(outDir, "program"),
outDir,
sanitize: true,
backend: "llvm",
});
if (!result.ok) {
throw new Error(
"program failed to compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
const run = await execFileAsync(result.binaryPath, [], {
env: { ...process.env, ASAN_OPTIONS: "detect_leaks=1" },
maxBuffer: 16 * 1024 * 1024,
}).catch((error: { code?: number; stdout?: string; stderr?: string }) => error);
expect(run.stderr).toBe("");
expect("code" in run ? run.code : 0).toBe(0);
const expected = await execFileAsync(process.execPath, ["--no-warnings", file], {
maxBuffer: 16 * 1024 * 1024,
});
expect(run.stdout).toBe(expected.stdout);
});
},
);
@@ -47,6 +47,7 @@ const features: NativeLinkFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -44,6 +44,7 @@ const base: NativeLinkFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,