diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 00ee4d636..1f92182d8 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -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; diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index dc9c5c876..82685456e 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -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") { diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6a06fbaad..0ef9029fd 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -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. { diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index ea8295406..922c45e9e 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -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", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json new file mode 100644 index 000000000..15ac693c1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-watch-listener-shapes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_watch.c b/packages/runtime/src/scr_watch.c index 0d5087961..d1730c75b 100644 --- a/packages/runtime/src/scr_watch.c +++ b/packages/runtime/src/scr_watch.c @@ -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; } diff --git a/packages/runtime/src/scr_watch_file.c b/packages/runtime/src/scr_watch_file.c index 6b40e5ed0..b1bee2826 100644 --- a/packages/runtime/src/scr_watch_file.c +++ b/packages/runtime/src/scr_watch_file.c @@ -32,6 +32,7 @@ #include "scr_runtime.h" #include +#include #include #include #include @@ -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); } diff --git a/tests/corpus/fs-watch-file-polling.ts b/tests/corpus/fs-watch-file-polling.ts index 87cddf867..2282c7c6b 100644 --- a/tests/corpus/fs-watch-file-polling.ts +++ b/tests/corpus/fs-watch-file-polling.ts @@ -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 { diff --git a/tests/corpus/fs-watch-listener-shapes.ts b/tests/corpus/fs-watch-listener-shapes.ts new file mode 100644 index 000000000..e6c399115 --- /dev/null +++ b/tests/corpus/fs-watch-listener-shapes.ts @@ -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); +} diff --git a/tests/fixtures/node-types/fenced.ts b/tests/fixtures/node-types/fenced.ts index 33cc56778..3fb41c490 100644 --- a/tests/fixtures/node-types/fenced.ts +++ b/tests/fixtures/node-types/fenced.ts @@ -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 diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index ada6231d4..be6452270 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -43,18 +43,18 @@ fenced.ts:31:21 - error SC2020: 'ReadableStream.from() over Set 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); diff --git a/tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json b/tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json new file mode 100644 index 000000000..c5606ebea --- /dev/null +++ b/tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 9200 } diff --git a/tests/harness/fs-watch-leaks.test.ts b/tests/harness/fs-watch-leaks.test.ts new file mode 100644 index 000000000..424d6ca56 --- /dev/null +++ b/tests/harness/fs-watch-leaks.test.ts @@ -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); + }); + }, +); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index db1faab9b..642c60a5c 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -47,6 +47,7 @@ const features: NativeLinkFeatures = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false, diff --git a/tests/harness/self-hosting-runtime-pack.test.ts b/tests/harness/self-hosting-runtime-pack.test.ts index 859f820da..16f9e23fd 100644 --- a/tests/harness/self-hosting-runtime-pack.test.ts +++ b/tests/harness/self-hosting-runtime-pack.test.ts @@ -44,6 +44,7 @@ const base: NativeLinkFeatures = { dgramDyn: false, textCodecDyn: false, watch: false, + watchFile: false, foreignFfi: false, nodeTest: false, tls: false,