From d540a649e6a3563357d66d73addd7c9d2628e3c2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:41:17 -0700 Subject: [PATCH 1/4] fs.watch: hand listeners the changed entry name and an EventEmitter surface An fs.watch listener may now take (), (eventType), or (eventType, filename). On a directory watch the filename is the entry that changed; a file watch names the watched file's basename. The parameter must admit null (Node passes null when the platform names nothing). macOS directory watches that ask for the name are refused with SC2020 until the FSEvents slice lands. FSWatcher gains its EventEmitter surface for "change", "error" and "close" (on, once, addListener, prependListener, prependOnceListener, off, removeListener). The name and error adapters are emitted per program because each builds the listener's own parameter shapes, and the erased `string | null` nullable union takes the payload directly instead of a union box. The runtime keeps one listener list per event with the once flag and prepend ordering; a once listener's registration reference leaves when it fires. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/fs.d.ts | 28 +- packages/compiler/src/backend/llvm/emitter.ts | 10 + .../src/backend/llvm/expr-stream-callbacks.ts | 149 +++++++++- .../src/backend/llvm/lib-filesystem.ts | 83 +++++- .../frontend/lowering/builtins/filesystem.ts | 128 +++++++-- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 7 + packages/runtime/src/scr_watch.c | 270 ++++++++++++++++-- packages/runtime/src/scr_watch.h | 41 ++- 9 files changed, 649 insertions(+), 69 deletions(-) diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 534fad940..46c38a315 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -555,11 +555,29 @@ declare module "node:fs" { ): void; /* fs.watch — the file-watching slice (scr_watch.c, kqueue EVFILT_VNODE): * "rename"/"change" events on ONE path, watcher.close() to stop. The - * listener takes zero parameters or the eventType string (the filename - * parameter has no lowering); an open watcher keeps the loop alive. A - * path that cannot be opened THROWS Node's fs error synchronously. */ + * listener takes (), (eventType) or (eventType, filename); an open + * watcher keeps the loop alive. A path that cannot be opened THROWS + * Node's fs error synchronously. */ + export type WatchEventType = "rename" | "change"; + /* (eventType, filename): filename is the changed entry's name (null when + * the platform does not provide one). */ + export type WatchListener = (event: WatchEventType, filename: T | null) => void; export interface FSWatcher { close(): void; + on(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "close", listener: () => void): this; + once(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this; + once(event: "error", listener: (error: Error) => void): this; + once(event: "close", listener: () => void): this; + addListener( + event: "change", + listener: (eventType: string, filename: string | Buffer) => void, + ): this; + addListener(event: "error", listener: (error: Error) => void): this; + addListener(event: "close", listener: () => void): this; + off(event: "change" | "error" | "close", listener: (...args: any[]) => void): this; + removeListener(event: "change" | "error" | "close", listener: (...args: any[]) => void): this; } /* The options-record stance (see http.RequestOptions): persistent: true, * recursive: false, and encoding: "utf8" state the lowered behavior and @@ -571,11 +589,11 @@ declare module "node:fs" { encoding?: string; [option: string]: unknown; } - export function watch(path: string, listener?: (eventType: string) => void): FSWatcher; + export function watch(path: string, listener?: WatchListener): FSWatcher; export function watch( path: string, options: WatchOptions, - listener?: (eventType: string) => void, + listener?: WatchListener, ): FSWatcher; /* fs.promises IS the fs/promises module (Node's rule) — the namespace- * import form `fs.promises.readFile(...)` lowers through the same diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6980c8f45..1addb5cb7 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -285,6 +285,8 @@ import { cryptoIntThunkFor, type BytesThunkValue, fsRenameThunkFor, + watchNameThunkFor, + watchErrorThunkFor, streamCbThunkFor, zlibBytesThunkFor, } from "./expr-stream-callbacks.js"; @@ -8157,6 +8159,14 @@ export class LlEmitter { return fsRenameThunkFor(this, cbT); } + watchNameThunkFor(cbT: IrType & { kind: "func" }): string { + return watchNameThunkFor(this, cbT); + } + + watchErrorThunkFor(cbT: IrType & { kind: "func" }): string { + return watchErrorThunkFor(this, cbT); + } + cryptoBytesThunkFor( cbT: IrType & { kind: "func" }, arrayBuffer = false, diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 3d9d8da76..00ee4d636 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -1,7 +1,7 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { type IrType, isRefCounted, typeKey } from "../../ir/ir.js"; -import { FN_ATTRS, releaseSym, retainSym, traceArg } from "./shapes.js"; +import { type IrType, isRefCounted, STRING, typeKey } from "../../ir/ir.js"; +import { FN_ATTRS, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { unionPeekLines, unionTagLines } from "./union-repr.js"; import { registerReplay } from "./emit-journal.js"; @@ -966,6 +966,151 @@ 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 borrowed C strings, the thunk builds what the listener declared. The + * listener owns its +1 arguments (the universal convention + * scr_watch_thunk_event follows). */ +export function watchNameThunkFor( + host: LlvmEmitterContext, + cbT: IrType & { kind: "func" }, +): string { + const key = `watchname:${typeKey(cbT)}`; + let sym = host.resolveThunks.get(key); + if (sym) return sym; + sym = host.contentSymbol("sc_watchname_", key); + host.resolveThunks.set(key, sym); + host.journal?.allocated(sym, "watchNameThunkFor", [cbT]); + const S = host.sizeType; + host.declare(`declare ${S} @strlen(ptr)`); + host.declare(`declare ptr @scr_str_new(ptr, ${S})`); + host.declare(`declare void @scr_str_release(ptr)`); + const d: string[] = [ + `define internal void @${sym}(ptr %cb, ptr %event, ptr %name) ${FN_ATTRS} { ; fs.watch listener ${typeKey(cbT)}`, + `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)`); + 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`, + ); + } 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. + 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`, + ); + } + } + let ev: string; + if (cbT.params[0]?.kind === "dyn") { + host.declare(`declare ptr @scr_dyn_new_str(ptr)`); + d.push(` br label %go`, `go:`, ` %evd = call ptr @scr_dyn_new_str(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`, + `}`, + ``, + ); + host.resolveThunkDefs.push(...d); + host.journal?.built(); + return sym; +} + +/** The FSWatcher 'error' listener adapter: the runtime hands a borrowed + * ScrError, the listener receives the declared dyn value or an Error + * object it owns. A zero-parameter listener uses the runtime's + * scr_watch_err_thunk0 and never reaches here. */ +export function watchErrorThunkFor( + host: LlvmEmitterContext, + cbT: IrType & { kind: "func" }, +): string { + const key = `watcherr:${typeKey(cbT)}`; + let sym = host.resolveThunks.get(key); + if (sym) return sym; + sym = host.contentSymbol("sc_watcherr_", key); + host.resolveThunks.set(key, sym); + host.journal?.allocated(sym, "watchErrorThunkFor", [cbT]); + const param = cbT.params[0]!; + const d: string[] = [ + `define internal void @${sym}(ptr %cb, ptr %err) ${FN_ATTRS} { ; FSWatcher error listener ${typeKey(cbT)}`, + `entry:`, + ]; + if (param.kind === "dyn") { + host.declare(`declare ptr @scr_dyn_from_error(ptr)`); + d.push(` %de = call ptr @scr_dyn_from_error(ptr %err)`); + } else if (param.kind === "object" && param.className === "%Error") { + host.declare(`declare ptr @scr_error_retain(ptr)`); + d.push(` %de = call ptr @scr_error_retain(ptr %err)`); + } else { + throw new InternalCompilerError("llvm emitter bug: FSWatcher error param is not an Error"); + } + d.push( + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ` call void %fn(ptr %cb, ptr %de)`, + ` ret void`, + `}`, + ``, + ); + host.resolveThunkDefs.push(...d); + host.journal?.built(); + return sym; +} + +registerReplay("watchNameThunkFor", (host, args) => + watchNameThunkFor(host, args[0] as IrType & { kind: "func" }), +); +registerReplay("watchErrorThunkFor", (host, args) => + watchErrorThunkFor(host, args[0] as IrType & { kind: "func" }), +); registerReplay("cryptoIntThunkFor", (host, args) => cryptoIntThunkFor(host, args[0] as IrType & { kind: "func" }), ); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 9174f4775..310f2cb57 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -448,17 +448,19 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): // loop (usesTimers). host.usesTimers = true; const path = host.emitExpr(e.args[0]!); - host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`); + host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`); const t = B.tmp(); - B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null)`); + B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null, i1 false)`); const out = host.own({ name: t, type: e.type }); host.emitPendingCheck(); return out; } if (e.fn === "fs.watchCb") { // The callback MOVES into the watcher's registry; the adapter is - // runtime-provided per listener shape (zero-param, or the eventType - // string). May throw (ENOENT) — the standard pending check. + // runtime-provided per listener shape (zero-param, the eventType + // string, or the compiler's (eventType, filename) adapter). May + // throw (ENOENT, or the macOS directory-names refusal) — the + // standard pending check. host.usesTimers = true; const cbT = e.args[1]!.type; if (cbT.kind !== "func") @@ -466,15 +468,80 @@ 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" : "scr_watch_thunk_event"; - host.declare(`declare void @${adapter}(ptr, ptr)`); - host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`); + 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)`); + host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`); const t = B.tmp(); - B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter})`); + B.line( + `${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${cbT.params.length === 2 ? "true" : "false"})`, + ); const out = host.own({ name: t, type: e.type }); host.emitPendingCheck(); return out; } + if (e.fn === "watcher.on") { + // (recv, event, flags, listener) — flags: 1 once, 2 prepend. The + // listener MOVES in; the adapter follows the event (0 change, 1 + // error, 2 close — CLOSE takes no adapter, a plain zero-arg call). + // Registration can throw (the macOS directory-names refusal). + const flagsArg = e.args[2]!; + if (flagsArg.kind !== "numLit") + throw new InternalCompilerError("llvm emitter bug: watcher.on flags not a literal"); + const flags = flagsArg.value; + const codeArg = e.args[1]!; + if (codeArg.kind !== "numLit") + throw new InternalCompilerError("llvm emitter bug: watcher.on event not a literal"); + const code = codeArg.value; + const cbT = e.args[3]!.type; + if (cbT.kind !== "func") + throw new InternalCompilerError("llvm emitter bug: watcher.on listener not a func"); + const w = host.emitExpr(e.args[0]!); + const event = host.emitExpr(e.args[1]!); + const cb = host.emitExpr(e.args[3]!); + host.toHeap(cb); + 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); + 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)`); + } + host.declare( + `declare void @scr_watcher_on(ptr, double, ptr, ptr, i1 zeroext, i1 zeroext, i1 zeroext)`, + ); + B.line( + `call void @scr_watcher_on(ptr ${w.name}, double ${event.name}, ptr ${cb.name}, ptr ${adapter === "null" ? "null" : `@${adapter}`}, i1 ${flags & 1 ? "true" : "false"}, i1 ${flags & 2 ? "true" : "false"}, i1 ${wantsName ? "true" : "false"})`, + ); + host.emitPendingCheck(); + return { name: "", type: e.type }; + } + if (e.fn === "watcher.off") { + // Removal is by pointer identity: the listener stays borrowed + // (never heap-moved), exactly like process.offExit's. + const w = host.emitExpr(e.args[0]!); + const event = host.emitExpr(e.args[1]!); + const cb = host.emitExpr(e.args[2]!); + host.declare(`declare void @scr_watcher_off(ptr, double, ptr)`); + B.line(`call void @scr_watcher_off(ptr ${w.name}, double ${event.name}, ptr ${cb.name})`); + return { name: "", type: e.type }; + } if (e.fn === "watcher.close") { // Idempotent; never throws. const w = host.emitExpr(e.args[0]!); diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index d8272c795..d248caa5e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -1110,13 +1110,37 @@ export function lowerStatsMethodCall( ); } +/** The watch-listener shapes: () , (eventType: string), and + * (eventType: string, filename: string | null) — the filename parameter + * must admit null (Node passes null when the platform names nothing), or + * be the checked dynamic. Returns the parameter count, or null when the + * shape has no lowering. */ +function watchListenerArity(lowerer: Lowerer, cb: IrExpr): 0 | 1 | 2 | null { + if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 2) + return null; + const [event, name] = cb.type.params; + if (event !== undefined && event.kind !== "string" && event.kind !== "dyn") return null; + if (name !== undefined && name.kind !== "dyn") { + const def = name.kind === "union" ? lowerer.unions.get(name.unionId) : undefined; + if ( + !def || + !def.arms.some((a) => a.kind === "nullT") || + !def.arms.some((a) => a.kind === "string") || + !def.arms.every((a) => a.kind === "nullT" || a.kind === "undefinedT" || a.kind === "string") + ) + return null; + } + return cb.type.params.length as 0 | 1 | 2; +} + /** `fs.watch(path[, options][, listener])` → the fs.watch libCall * (scr_watch.c — kqueue EVFILT_VNODE on the opened path; an unopenable * path THROWS Node's fs error synchronously, the polling-fallback catch * shape). The listener fires with "rename"/"change" and takes zero - * parameters or the eventType string — the filename parameter has no - * lowering (kqueue watches the inode, not the directory entry; you - * watched one path). The options record follows the options-record + * parameters, the eventType string, or (eventType, filename: string | + * null) — the filename is the entry name on a directory watch and the + * watched file's basename otherwise (Linux inotify names both; a macOS + * directory watch has no name source until F3). The options record follows the options-record * stance: persistent: true, recursive: false, and encoding: "utf8" * state the lowered behavior and are accepted; persistent: false * (a watcher that does NOT hold the loop), recursive: true (kqueue @@ -1187,7 +1211,7 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: lowerer.noLowering( "fs.watch with a non-utf8 encoding", prop, - "the encoding applies to the filename argument, which has no lowering — utf8 (the default) is accepted", + "the filename argument is a string — utf8 (the default) is the accepted encoding", ); } if (key === "signal") { @@ -1219,19 +1243,11 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: : null; if (listenerArg !== null) { const cb = lowerer.lowerExpr(listenerArg); - if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { + if (watchListenerArity(lowerer, cb) === null) { lowerer.noLowering( "fs.watch with this listener shape", listenerArg, - "the listener takes () or (eventType: string) — the filename parameter has no lowering (you watched one path)", - ); - } - const param = cb.type.params[0]; - if (param !== undefined && param.kind !== "string") { - lowerer.unsupported( - "SC1090", - listenerArg, - `watch listeners whose parameter is not the eventType string (got '${lowerer.fmt(param)}')`, + "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)", ); } args.push(cb); @@ -1245,10 +1261,12 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc: }; } -/** Method calls on FSWatcher receivers: close() — idempotent, statement - * position (Node returns void there too). Everything else @types/node - * declares (ref/unref, the EventEmitter surface) fences member- - * qualified. Null for non-watcher receivers. */ +/** 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/ + * prependOnceListener/off/removeListener, registered as their own + * statement). Everything else @types/node declares (ref/unref, other + * events) fences member-qualified. Null for non-watcher receivers. */ export function lowerWatcherMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -1263,10 +1281,82 @@ export function lowerWatcherMethodCall( const receiver = lowerer.lowerExpr(access.expression); return { kind: "libCall", fn: "watcher.close", args: [receiver], type: VOID, loc }; } + // The EventEmitter surface for the three events the watcher emits: + // "change" (the listener shapes fs.watch takes), "error", and "close". + const ON = ["on", "once", "addListener", "prependListener", "prependOnceListener"]; + if ( + (ON.includes(name) || name === "off" || name === "removeListener") && + call.arguments.nodes.length === 2 + ) { + const evT = lowerer.typeOf(call.arguments.nodes[0]!); + const event = evT.isStringLiteralType() ? evT.value : null; + const code = event === "change" ? 0 : event === "error" ? 1 : event === "close" ? 2 : -1; + if (code < 0) { + lowerer.noLowering( + `FSWatcher.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, + call.arguments.nodes[0]!, + '"change", "error", and "close" are the supported FSWatcher events (as literals)', + ); + } + const parent = call.parent; + if ( + !ts.isExpressionStatement(parent) && + !(ts.isArrowFunction(parent) && parent.body === call) + ) { + lowerer.unsupported( + "SC1090", + call, + `using the result of FSWatcher.${name} (register it as its own statement)`, + ); + } + const receiver = lowerer.lowerExpr(access.expression); + const cb = lowerer.lowerExpr(call.arguments.nodes[1]!); + if (name === "off" || name === "removeListener") { + return { + kind: "libCall", + fn: "watcher.off", + args: [receiver, numLit(code, loc), cb], + type: VOID, + loc, + }; + } + const ok = + code === 0 + ? watchListenerArity(lowerer, cb) !== null + : code === 1 + ? cb.type.kind === "func" && + cb.type.ret.kind === "void" && + cb.type.params.length <= 1 && + (cb.type.params[0] === undefined || + cb.type.params[0].kind === "dyn" || + (cb.type.params[0].kind === "object" && cb.type.params[0].className === "%Error")) + : cb.type.kind === "func" && cb.type.ret.kind === "void" && cb.type.params.length === 0; + if (!ok) { + lowerer.noLowering( + `FSWatcher.${name}("${event}", ...) with this listener shape`, + call.arguments.nodes[1]!, + code === 0 + ? "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)" + : code === 1 + ? "the listener takes () or (error: Error)" + : "the listener takes ()", + ); + } + const flags = + (name === "once" || name === "prependOnceListener" ? 1 : 0) | + (name.startsWith("prepend") ? 2 : 0); + return { + kind: "libCall", + fn: "watcher.on", + args: [receiver, numLit(code, loc), numLit(flags, loc), cb], + type: VOID, + loc, + }; + } lowerer.noLowering( `FSWatcher.${name}`, call, - "close() is the supported FSWatcher member", + 'close(), and on/once/addListener/prependListener/prependOnceListener/off/removeListener for "change", "error", and "close" are the supported FSWatcher members', lowerer.checker.getSymbolAtLocation(access.name), ); } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 60f46e7ee..48580f4fe 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -1088,6 +1088,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.closeSync", "fs.watch", "fs.watchCb", + "watcher.on", + "watcher.off", "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 024f11277..47514c561 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1304,6 +1304,13 @@ function buildLibFnSignatures() { "fs.watch": { argTypes: [STRING], result: FSWATCHER_T }, "fs.watchCb": { argTypes: [STRING, null], result: FSWATCHER_T }, "watcher.close": { argTypes: [FSWATCHER_T], result: VOID }, + // FSWatcher's EventEmitter surface: (receiver, event code, once|prepend + // flags, listener) for on/once/addListener/prependListener/ + // prependOnceListener, and (receiver, event code, listener) for + // off/removeListener. The listener's func type is program-dependent; + // 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 }, /** 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/runtime/src/scr_watch.c b/packages/runtime/src/scr_watch.c index f86adb219..0d5087961 100644 --- a/packages/runtime/src/scr_watch.c +++ b/packages/runtime/src/scr_watch.c @@ -57,12 +57,21 @@ #include #include #include +#include #include #if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || \ defined(__OpenBSD__) || defined(__DragonFly__) #define SCR_HAVE_KQUEUE 1 #include +#include +/* Darwin directory watches deliver through FSEvents in libuv, which names + * entries; the kqueue arm watches the directory inode and cannot. Until + * slice F3 lands FSEvents, a listener that reads the filename has no + * lowering there. */ +#if defined(__APPLE__) +#define SCR_WATCH_DIR_NAMES_REFUSED 1 +#endif #elif defined(__linux__) #define SCR_HAVE_INOTIFY 1 #include @@ -92,14 +101,35 @@ static void scr_watch_oom(void) { abort(); } +enum { SCR_WATCH_EV_CHANGE = 0, SCR_WATCH_EV_ERROR = 1, SCR_WATCH_EV_CLOSE = 2 }; + +/* One registered listener: the event it answers, its adapter, and whether + * it is one-shot (once/prependOnceListener — removed before it runs). */ typedef struct { - ScrClosure *cb; /* owned */ - ScrWatchFn fn; /* adapter: event name borrowed */ + ScrClosure *cb; /* owned */ + void *fn; /* CHANGE: ScrWatchFn; ERROR: ScrWatchErrFn; CLOSE: NULL (zero-arg call) */ + uint8_t event; + bool once; } ScrWatchL; +/* The 'close' emission: the moved-out listeners, called once on the next + * tick in registration order. The batch owns them, so a watcher released + * before the tick leaves nothing dangling. */ +typedef struct { + size_t rc; + ScrWatchL *ls; + size_t n; +} ScrWatchCloseBatch; + +static void *scr_watch_batch_retain(void *p); +static void scr_watch_batch_release(void *p); + struct ScrWatcher { size_t rc; int fd; /* -1 once closed, or after a failed reopen (no further events) */ +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + bool is_dir; /* the opened path is a directory (the F3 gap) */ +#endif #ifdef SCR_HAVE_INOTIFY int wd; /* the inotify watch descriptor; -1 once dead (IN_IGNORED * arrived or close() removed it) */ @@ -124,6 +154,24 @@ struct ScrWatcher { static SCR_TL ScrWatcher *scr_watchers = NULL; static SCR_TL size_t scr_watchers_open = 0; /* liveness: open (unclosed) watchers */ +static void scr_watch_close_tick(ScrClosure *cb); + +#if defined(SCR_HAVE_INOTIFY) || defined(SCR_HAVE_KQUEUE) +/* libuv's uv__basename_r: the part after the last '/', or the whole path. */ +static const char *scr_watch_basename(const char *path) { + const char *slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} +#endif + +#if defined(SCR_WATCH_DIR_NAMES_REFUSED) +static void scr_watch_refuse_dir_names(void) { + const char *m = + "fs.watch of a directory with a listener that reads the filename has no lowering on macOS yet [SC2020]"; + scr_throw_error_msg_code(SCR_ERR_ERROR, m, strlen(m), "SC2020"); +} +#endif + #ifdef SCR_HAVE_KQUEUE static SCR_TL int scr_watch_kq = -1; /* created with the first watch; lives forever */ @@ -238,7 +286,8 @@ void scr_watcher_release_v(void *p) { scr_watcher_release((ScrWatcher *)p); } /* fs.watch(path, cb?) — opens the path NOW (throwing Node's fs error on * failure), registers the watcher with the loop, and arms the vnode * filter. cb (nullable) moves in as the first listener. Result +1. */ -ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn) { +ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn, + bool wants_name) { #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. */ @@ -295,6 +344,22 @@ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWa scr_fs_throw(errno, "watch", path); return NULL; /* exception pending */ } +#endif +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + /* The path opened: a DIRECTORY watch on macOS has no name source before + * F3, so a listener that reads the filename is refused HERE, before the + * watcher is registered. */ + bool is_dir = false; + { + struct stat st; + if (fstat(fd, &st) == 0 && S_ISDIR(st.st_mode)) is_dir = true; + } + if (wants_name && is_dir) { + close(fd); + if (cb != NULL) scr_closure_release(cb); + scr_watch_refuse_dir_names(); + return NULL; /* exception pending */ + } #endif ScrWatcher *w = calloc(1, sizeof *w); if (!w) scr_watch_oom(); @@ -305,8 +370,13 @@ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWa if (!w->ls) scr_watch_oom(); w->ls[0].cb = cb; w->ls[0].fn = fn; + w->ls[0].event = SCR_WATCH_EV_CHANGE; + w->ls[0].once = false; w->n = w->cap = 1; } +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + w->is_dir = is_dir; +#endif #ifdef SCR_HAVE_INOTIFY w->fd = -1; /* inotify holds the watch; there is no per-watcher fd */ w->wd = wd; @@ -348,6 +418,21 @@ ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWa void scr_watcher_close(ScrWatcher *w) { if (w->closed) return; w->closed = true; + /* The 'close' listeners leave the LIVE list and run on the next tick, + * in registration order, exactly once (Node emits 'close' after the + * first close() returns). */ + ScrWatchL *closes = NULL; + size_t ncloses = 0; + if (w->n > 0) { + closes = malloc(w->n * sizeof *closes); + if (!closes) scr_watch_oom(); + size_t kept = 0; + for (size_t i = 0; i < w->n; i++) { + if (w->ls[i].event == SCR_WATCH_EV_CLOSE) closes[ncloses++] = w->ls[i]; + else w->ls[kept++] = w->ls[i]; + } + w->n = kept; + } #ifdef SCR_HAVE_INOTIFY if (w->wd >= 0) { /* The queued IN_IGNORED this rm produces finds no open watcher and is @@ -366,6 +451,20 @@ void scr_watcher_close(ScrWatcher *w) { } scr_watcher_drop_listeners(w); scr_watchers_open--; + if (ncloses > 0) { + ScrWatchCloseBatch *batch = malloc(sizeof *batch); + if (!batch) scr_watch_oom(); + batch->rc = 1; /* the box owns it */ + batch->ls = closes; + batch->n = ncloses; + ScrClosure *tick = scr_closure_new((void *)scr_watch_close_tick, 1); + tick->caps[0] = + scr_box_new_obj(scr_watch_batch_retain, scr_watch_batch_release, NULL); + scr_box_set_ref(tick->caps[0], batch); + scr_next_tick(tick); + } else { + free(closes); + } } /* Unlinks and releases closed watchers — called only where no fetched @@ -385,30 +484,118 @@ static void scr_watch_sweep(void) { } /* The runtime-provided listener adapters. */ -void scr_watch_thunk0(ScrClosure *cb, const char *event) { +void scr_watch_thunk0(ScrClosure *cb, const char *event, const char *filename) { (void)event; + (void)filename; ((void (*)(ScrClosure *))cb->fn)(cb); } -void scr_watch_thunk_event(ScrClosure *cb, const char *event) { +void scr_watch_thunk_event(ScrClosure *cb, const char *event, const char *filename) { + (void)filename; /* The listener owns its +1 param per the universal convention. */ ScrStr *e = scr_str_new(event, strlen(event)); ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, e); } +/* The zero-parameter 'error' listener: the error itself is dropped. */ +void scr_watch_err_thunk0(ScrClosure *cb, ScrError *err) { + (void)err; + ((void (*)(ScrClosure *))cb->fn)(cb); +} -/* Fires one watcher's listeners with the event name (snapshot — a - * listener closing the watcher mid-emit still finishes this delivery, - * and close() drops only the LIVE list). */ -static void scr_watch_fire(ScrWatcher *w, const char *event) { - size_t n = w->n; - if (n == 0) return; - ScrWatchL *snap = malloc(n * sizeof *snap); - if (!snap) scr_watch_oom(); - for (size_t i = 0; i < n; i++) { - snap[i] = w->ls[i]; - scr_closure_retain(snap[i].cb); +/* watcher.on/once/addListener/prependListener(event, listener). A closed + * watcher can never fire again, so the listener leaves at once. */ +void scr_watcher_on(ScrWatcher *w, double event, ScrClosure *cb, void *fn, bool once, bool prepend, + bool wants_name) { + if (w->closed) { + scr_closure_release(cb); + return; } +#ifdef SCR_WATCH_DIR_NAMES_REFUSED + if (event == SCR_WATCH_EV_CHANGE && wants_name && w->is_dir) { + scr_closure_release(cb); + scr_watch_refuse_dir_names(); + return; + } +#else + (void)wants_name; +#endif + if (w->n == w->cap) { + size_t cap = w->cap == 0 ? 1 : w->cap * 2; + ScrWatchL *grown = realloc(w->ls, cap * sizeof *grown); + if (!grown) scr_watch_oom(); + w->ls = grown; + w->cap = cap; + } + ScrWatchL entry = {cb, fn, (uint8_t)event, once}; + if (prepend) { + memmove(w->ls + 1, w->ls, w->n * sizeof *w->ls); + w->ls[0] = entry; + } else { + w->ls[w->n] = entry; + } + w->n++; +} + +/* watcher.off/removeListener(event, listener): the most recently added + * listener of that event with the same closure identity leaves. */ +void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb) { + for (size_t i = w->n; i > 0; i--) { + 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); + w->n--; + return; + } +} + +static void *scr_watch_batch_retain(void *p) { + ScrWatchCloseBatch *b = p; + if (b->rc != SIZE_MAX) b->rc++; + return b; +} + +static void scr_watch_batch_release(void *p) { + ScrWatchCloseBatch *b = p; + if (!b || b->rc == SIZE_MAX) return; + if (--b->rc != 0) return; + for (size_t i = 0; i < b->n; i++) scr_closure_release(b->ls[i].cb); + free(b->ls); + free(b); +} + +static void scr_watch_close_tick(ScrClosure *cb) { + ScrWatchCloseBatch *b = scr_box_get_ref(cb->caps[0]); + if (b == NULL) return; + for (size_t i = 0; i < b->n; i++) { + if (!scr_exc_pending()) ((void (*)(ScrClosure *))b->ls[i].cb->fn)(b->ls[i].cb); + } + scr_watch_batch_release(b); +} + +/* Fires one watcher's CHANGE listeners with the event name and the entry + * name (NULL when the platform named nothing). The snapshot follows the + * existing shape — a listener closing the watcher mid-emit still finishes + * this delivery — and a one-shot listener leaves the LIVE list before it + * runs (EventEmitter's rule). */ +static void scr_watch_fire(ScrWatcher *w, const char *event, const char *filename) { + if (w->n == 0) return; + ScrWatchL *snap = malloc(w->n * sizeof *snap); + if (!snap) scr_watch_oom(); + size_t n = 0, kept = 0; + for (size_t i = 0; i < w->n; i++) { + ScrWatchL l = w->ls[i]; + if (l.event != SCR_WATCH_EV_CHANGE) { + w->ls[kept++] = l; + continue; + } + snap[n++] = l; + scr_closure_retain(l.cb); + if (!l.once) w->ls[kept++] = l; + else scr_closure_release(l.cb); /* the live list's own reference leaves now */ + } + w->n = kept; for (size_t i = 0; i < n; i++) { - if (!scr_exc_pending()) snap[i].fn(snap[i].cb, event); + if (!scr_exc_pending()) ((ScrWatchFn)snap[i].fn)(snap[i].cb, event, filename); scr_closure_release(snap[i].cb); } free(snap); @@ -448,6 +635,16 @@ static void scr_watch_dispatch(void) { if (w == NULL || w->closed || w->fd < 0) continue; uint32_t ff = evs[i].fflags; bool rename = (ff & (NOTE_RENAME | NOTE_DELETE | NOTE_REVOKE)) != 0; + /* The entry name BEFORE the reopen dance closes the old fd: kqueue + * names the watched inode by its CURRENT path (libuv's + * F_GETPATH rule). A directory watch names the directory itself — + * only the zero/one-parameter listeners, which ignore it, see it. */ + const char *name = NULL; +#ifdef F_GETPATH + char pathbuf[MAXPATHLEN]; + if (w->fd >= 0 && fcntl(w->fd, F_GETPATH, pathbuf) == 0) + name = scr_watch_basename(pathbuf); +#endif if (rename) { /* The inode left the name: reopen best-effort so the atomic * replace-by-rename idiom keeps delivering on the new file. The @@ -459,9 +656,9 @@ static void scr_watch_dispatch(void) { close(w->fd); w->fd = -1; } - scr_watch_fire(w, "rename"); + scr_watch_fire(w, "rename", name); } else if (ff & (NOTE_WRITE | NOTE_EXTEND | NOTE_ATTRIB)) { - scr_watch_fire(w, "change"); + scr_watch_fire(w, "change", name); } if (scr_exc_pending()) return; /* the loop surfaces it */ } @@ -489,7 +686,10 @@ static void scr_watch_dispatch(void) { bool change = (e->mask & (IN_ATTRIB | IN_MODIFY)) != 0; if (e->mask & IN_IGNORED) w->wd = -1; /* the kernel dropped the watch: * no further events (no reopen), like Node */ - scr_watch_fire(w, change ? "change" : "rename"); + /* libuv's filename rule: the entry name for a directory event, the + * watched path's basename otherwise (uv__basename_r). */ + const char *name = e->len ? e->name : scr_watch_basename(w->path->data); + scr_watch_fire(w, change ? "change" : "rename", name); if (scr_exc_pending()) return; /* the loop surfaces it */ } if (scr_loop_has_ready()) return; /* microtasks interleave first */ @@ -510,18 +710,36 @@ static void scr_watch_dispatch(void) { * overflow notification, delivered as a filterless "rename" burst * signal the same way libuv degrades. */ if (ok && bytes == 0) { - scr_watch_fire(w, "rename"); + scr_watch_fire(w, "rename", NULL); /* the overflow notification names nothing */ if (scr_exc_pending()) return; } else if (ok) { char *p = w->buf; for (;;) { FILE_NOTIFY_INFORMATION *e = (FILE_NOTIFY_INFORMATION *)p; + size_t wlen = e->FileNameLength / sizeof(WCHAR); /* not NUL-terminated */ bool match = w->filter == NULL || - CompareStringOrdinal(e->FileName, (int)(e->FileNameLength / sizeof(WCHAR)), - w->filter, -1, TRUE) == CSTR_EQUAL; + CompareStringOrdinal(e->FileName, (int)wlen, w->filter, -1, TRUE) == CSTR_EQUAL; if (match) { - scr_watch_fire(w, e->Action == FILE_ACTION_MODIFIED ? "change" : "rename"); + char stackbuf[1024]; + char *heap = NULL; + char *namebuf = stackbuf; + size_t namecap = sizeof stackbuf; + if (wlen * 4 + 1 > namecap) { + heap = malloc(wlen * 4 + 1); + if (!heap) scr_watch_oom(); + namebuf = heap; + namecap = wlen * 4 + 1; + } + int nbytes = WideCharToMultiByte(CP_UTF8, 0, e->FileName, (int)wlen, namebuf, + (int)namecap - 1, NULL, NULL); + char *name = NULL; + if (nbytes > 0) { + namebuf[nbytes] = '\0'; + name = namebuf; + } + scr_watch_fire(w, e->Action == FILE_ACTION_MODIFIED ? "change" : "rename", name); + free(heap); if (scr_exc_pending()) return; if (w->closed) break; /* a listener closed its own watcher */ } @@ -530,6 +748,10 @@ static void scr_watch_dispatch(void) { } } if (!w->closed && w->dh != INVALID_HANDLE_VALUE && !scr_watch_issue(w)) { + /* TODO(windows): EPERM 'error' event — Node emits one when the + * watched directory disappears (GetOverlappedResult fails with + * ERROR_ACCESS_DENIED, libuv's UV_EPERM). Unverified on Windows; + * the watcher stops emitting, like the kqueue exhaustion stance. */ scr_watch_backend_drop(w); /* reissue refused: no further events, like kqueue exhaustion */ } } diff --git a/packages/runtime/src/scr_watch.h b/packages/runtime/src/scr_watch.h index c038dbe55..c2118d1cd 100644 --- a/packages/runtime/src/scr_watch.h +++ b/packages/runtime/src/scr_watch.h @@ -1,9 +1,12 @@ -/* scriptc runtime — fs.watch (scr_watch.c). Included by scr_runtime.h, which - * says where new declarations go. */ +/* scriptc runtime — fs.watch (scr_watch.c) and fs.watchFile + * (scr_watch_file.c). Included by scr_runtime.h, which says where new + * declarations go. */ #ifndef SCR_WATCH_H #define SCR_WATCH_H #include "scr_bytes.h" +#include "scr_error.h" +#include "scr_lib.h" /* ── fs.watch (scr_watch.c — compiled only when the program uses it; * design note atop the file). FSWatcher handles over the unit's own @@ -12,9 +15,12 @@ * close/exit — the child ownership story), an open watcher keeps the * loop alive until close() (Node's persistent default). */ typedef struct ScrWatcher ScrWatcher; -/* The event adapter: the name ("rename"/"change") arrives BORROWED - * (static storage); adapters build what their listener declared. */ -typedef void (*ScrWatchFn)(ScrClosure *cb, const char *event); +/* The event adapter: the event name ("rename"/"change") and the entry + * name (NULL when the platform names nothing) arrive BORROWED (static + * storage); adapters build what their listener declared. */ +typedef void (*ScrWatchFn)(ScrClosure *cb, const char *event, const char *filename); +/* The 'error' adapter: the ScrError arrives borrowed. */ +typedef void (*ScrWatchErrFn)(ScrClosure *cb, ScrError *err); ScrWatcher *scr_watcher_retain(ScrWatcher *w); void scr_watcher_release(ScrWatcher *w); @@ -23,13 +29,26 @@ void scr_watcher_release_v(void *p); /* fs.watch(path, listener?) — path borrowed, the callback (nullable) * MOVES. Opens the path NOW and THROWS Node's fs error when it cannot - * ("ENOENT: ..., watch 'x'" — the polling-fallback catch shape). +1. */ -ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb, ScrWatchFn fn); + * ("ENOENT: ..., watch 'x'" — the polling-fallback catch shape). A + * CHANGE listener that reads the filename (wants_name) on a macOS + * directory watch has no lowering yet and THROWS [SC2020]. +1. */ +ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb, ScrWatchFn fn, bool wants_name); void scr_watcher_close(ScrWatcher *w); /* idempotent */ -/* The runtime-provided listener adapters (zero-param, and the one-param - * (eventType: string) shape). */ -void scr_watch_thunk0(ScrClosure *cb, const char *event); -void scr_watch_thunk_event(ScrClosure *cb, const char *event); +/* watcher.on/once/addListener/prependListener(event, listener): cb MOVES + * in. On a closed watcher the listener is released at once (it can never + * run). Directory watches on macOS cannot name entries (slice F3), so a + * CHANGE listener that reads the filename (wants_name) THROWS there. */ +void scr_watcher_on(ScrWatcher *w, double event, ScrClosure *cb, void *fn, bool once, bool prepend, + bool wants_name); +/* watcher.off/removeListener(event, listener): removes the most recently + * added listener of that event whose closure is `cb` (pointer identity, + * EventEmitter's rule); cb is borrowed. */ +void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb); +/* The runtime-provided listener adapters: the zero- and one-parameter + * fs.watch shapes, and the zero-parameter 'error' listener. */ +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); /* The loop-side registration (scr_async.c, always linked) — the net * hook's exact shape, one more nullable slot set. */ From 7176257136cc294d12db5431b22b3caf5ced36a9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:41:23 -0700 Subject: [PATCH 2/4] fs.watchFile: poll a path from its own gated runtime unit fs.watchFile(path[, options], listener) polls the resolved path every `interval` milliseconds (5007 by default, `persistent` by default) and calls its listener with (current, previous) Stats when a poll sees a difference. The first poll only records the baseline, a path that cannot be stat'd polls as all-zero Stats, and a stat failure that repeats for the same errno is not re-reported. One poller per RESOLVED path is shared by every listener on it, and fs.unwatchFile(path[, listener]) removes the last listener registered with that callback, or every listener when called without one. The call is a statement, since Node's StatWatcher has no lowering, and bigint: true is refused. The implementation is scr_watch_file.c, a new gated runtime unit switched by moduleUsesFsWatchFile, so programs that do not poll link none of it. The feature travels the usual chain (runtime-features, executable-features, the native link info, the executable cache keys, the runtime-pack matrix) and the validator learns that fs.unwatchFile's trailing listener slot may be omitted. Evidence: - tests/corpus/fs-watch-file-names.ts and fs-watch-file-polling.ts match Node on stdout, stderr and exit status in the plain and sanitized lanes - 1564-fs-watch.ts, 1751-options-record-stance.ts and the `fs` corpus stay green; the new order baselines and corpus cost files are recorded Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 2 + .../builtin-apis/file-watching.mdx | 5 + .../src/generated/node-v24-compatibility.json | 18 +- .../generated/node-v24-internal.json | 30 +- internal/compatibility/static-support.json | 3 +- .../compiler/ambient/node-fallback/fs.d.ts | 22 ++ .../src/backend/executable-features.ts | 1 + packages/compiler/src/backend/library-pack.ts | 1 + .../compiler/src/backend/llvm/emit-module.ts | 8 + .../src/backend/llvm/lib-filesystem.ts | 34 ++ .../src/backend/native-link-info-core.ts | 2 + .../compiler/src/backend/native/contracts.ts | 5 + .../compiler/src/backend/native/executable.ts | 3 + .../src/backend/runtime-pack-targets.test.ts | 1 + .../compiler/src/backend/runtime-pack.test.ts | 1 + packages/compiler/src/backend/warm-cache.ts | 1 + .../src/executable/executable-cache.test.ts | 1 + .../src/executable/executable-cache.ts | 2 + .../src/frontend/lowering/builtin-calls.ts | 8 + .../frontend/lowering/builtins/filesystem.ts | 123 +++++++ .../src/frontend/lowering/surfaces.ts | 7 + packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/api.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 10 + packages/compiler/src/ir/runtime-features.ts | 13 + packages/compiler/src/ir/validate.ts | 9 +- packages/compiler/src/native/cli.ts | 1 + packages/compiler/src/native/prepare.ts | 1 + .../compiler/src/native/sanitizer.test.ts | 1 + .../tests/corpus/fs-watch-file-names.ts.json | 6 + .../corpus/fs-watch-file-polling.ts.json | 6 + .../runtime-pack-matrix.mjs | 1 + packages/runtime/src/scr_lib.c | 37 +- packages/runtime/src/scr_lib.h | 5 + packages/runtime/src/scr_watch.h | 19 + packages/runtime/src/scr_watch_file.c | 331 ++++++++++++++++++ tests/corpus/fs-watch-file-names.ts | 60 ++++ tests/corpus/fs-watch-file-polling.ts | 49 +++ .../corpus-costs/fs-watch-file-names.ts.json | 1 + .../fs-watch-file-polling.ts.json | 1 + 41 files changed, 811 insertions(+), 22 deletions(-) create mode 100644 docs/content/limitations/builtin-apis/file-watching.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-names.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-file-polling.ts.json create mode 100644 packages/runtime/src/scr_watch_file.c create mode 100644 tests/corpus/fs-watch-file-names.ts create mode 100644 tests/corpus/fs-watch-file-polling.ts create mode 100644 tests/harness/corpus-costs/fs-watch-file-names.ts.json create mode 100644 tests/harness/corpus-costs/fs-watch-file-polling.ts.json diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 88b2ca5fc..ad8912a40 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 000000000..2d2b386f0 --- /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 387f5b21c..391d7a5f8 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 00d6f8db3..a93e7b34c 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 1242ad9a7..8afe8af22 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 46c38a315..70172f384 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 f4290a15a..bb4542573 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 14ffdff28..7b74dc808 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 6f261d4bb..bcdb985f4 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 310f2cb57..dc9c5c876 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 6a40a1be7..10d665790 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 f093d6483..aca6000b4 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 3515bc387..e8ae31067 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 fcbe3f08e..510e046b6 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 a12c137b2..0fc279d20 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 c42ceb79e..25254934f 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 1f5625fb8..9c81ccc19 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 25a77c10d..c7daf3f68 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 d2e4b53d5..33c4502bc 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 d248caa5e..318d58ee0 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 03b65bd54..6a06fbaad 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 0719112cc..fdf2335a8 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 600cdb051..8ea02cfe6 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 48580f4fe..5733525cd 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 47514c561..622abfd94 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 da4f11623..d583ea62a 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 995603bcd..f5312713c 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 bc33e683a..5c8a91022 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 bf496aa99..009818cd7 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 b1d03b52d..509602d64 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 000000000..88ec75941 --- /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 000000000..7f11fb9bc --- /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 72e888dce..d4dff88c3 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 67f18c0d8..8a5f46a10 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 fa88873af..1a099c84d 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 c2118d1cd..83f02c917 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 000000000..6b40e5ed0 --- /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 000000000..61c824332 --- /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 000000000..87cddf867 --- /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 000000000..91f0b7565 --- /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 000000000..376e9a4e6 --- /dev/null +++ b/tests/harness/corpus-costs/fs-watch-file-polling.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 3800 } From 089a8e17996876f2f8194cb68d403482aaff054e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 01:47:31 -0500 Subject: [PATCH 3/4] 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. --- .../src/backend/llvm/expr-stream-callbacks.ts | 108 ++++++++++-------- .../src/backend/llvm/lib-filesystem.ts | 47 ++++---- .../src/frontend/lowering/surfaces.ts | 16 +++ packages/compiler/surface-manifest.json | 14 +++ .../corpus/fs-watch-listener-shapes.ts.json | 6 + packages/runtime/src/scr_watch.c | 8 +- packages/runtime/src/scr_watch_file.c | 45 ++++++-- tests/corpus/fs-watch-file-polling.ts | 26 ++++- tests/corpus/fs-watch-listener-shapes.ts | 91 +++++++++++++++ tests/fixtures/node-types/fenced.ts | 4 +- .../__snapshots__/node-types-fenced.txt | 8 +- .../fs-watch-listener-shapes.ts.json | 1 + tests/harness/fs-watch-leaks.test.ts | 64 +++++++++++ .../self-hosting-native-toolchain.test.ts | 1 + .../harness/self-hosting-runtime-pack.test.ts | 1 + 15 files changed, 353 insertions(+), 87 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-watch-listener-shapes.ts.json create mode 100644 tests/corpus/fs-watch-listener-shapes.ts create mode 100644 tests/harness/corpus-costs/fs-watch-listener-shapes.ts.json create mode 100644 tests/harness/fs-watch-leaks.test.ts 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, From ad8f8daccd5e193cb54fa239d07d9167a9f7671e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 20:56:10 -0700 Subject: [PATCH 4/4] Keep scr_lib.c's default object byte-identical for the fs.watchFile poll Adding scr_stats_of_path and scr_stats_zero to scr_lib.c moved every program's executable: scr_lib.o is linked everywhere, so its new symbols and strings shifted addresses even for programs that never watch a file. The two entry points now live in an SCR_FS_WATCH_FILE build of scr_lib.c, the matrix's variant for programs whose feature set has watchFile, and the poll unit calls the renamed scr_stats_poll_path. Programs that do not poll link the default object unchanged: compiled without the define, scr_lib.o is byte-identical to the base (verified by comparing the two objects). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/native/executable.ts | 2 ++ .../runtime-pack-common/runtime-pack-matrix.mjs | 11 +++++++++++ packages/runtime/src/scr_lib.c | 14 ++++++++------ packages/runtime/src/scr_lib.h | 6 ++++-- packages/runtime/src/scr_watch_file.c | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 8ecac9923..8222bd9b7 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -426,6 +426,8 @@ const RUNTIME_DEFINES: readonly { { define: "SCR_NATIVE_REACTIONS", when: (opts) => opts.webStreams }, { define: "SCR_HANDLE_CLASSES", when: (opts) => opts.webStreams }, { define: "SCR_YIN", when: (opts) => opts.youngNursery }, + // scr_lib.c's path stat and zero Stats for the fs.watchFile poll. + { define: "SCR_FS_WATCH_FILE", when: (opts) => opts.watchFile }, { define: "SCR_NODE_ISLANDS", when: (opts) => opts.nodeIsland }, ]; diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6fff2b653..41eef2b23 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -274,6 +274,17 @@ export function variantsFor(source) { defines: ["SCR_TEXT_DECODER_LEGACY"], }); } + // fs.watchFile's poll needs a path stat that reports the errno (its failed + // poll IS a reportable event). The Stats snapshot is private to scr_lib.c, + // so the two entry points live in a build of that unit only programs that + // poll link; every other program keeps the default object, byte for byte. + if (source === "scr_lib.c") { + variants.push({ + id: "fs-watch-file", + when: { watchFile: true }, + defines: ["SCR_FS_WATCH_FILE"], + }); + } // Programs that assign process.emitWarning link the replacement-aware // warning paths; every other program keeps the default object. if (source === "scr_async_dyn.c") { diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index c47339a68..f10b4f5fa 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5584,7 +5584,7 @@ static bool scr_stats_link_target(HANDLE h, DWORD tag, double *size_out, ScrStr return true; } -static ScrStats *scr_stats_win_handle_stats(const ScrStr *path, const char *op, +static ScrStats *scr_stats_of_path(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); @@ -5630,7 +5630,7 @@ static ScrStats *scr_stats_win_handle_stats(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_win_handle_stats(path, op, false); + return scr_stats_of_path(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. @@ -5638,7 +5638,7 @@ static ScrStats *scr_stats_win_handle_stats(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_win_handle_stats(path, op, false); + return scr_stats_of_path(path, op, false); } is_link = true; } @@ -6081,7 +6081,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_win_handle_stats(path, "stat", false); + return scr_stats_of_path(path, "stat", false); #else int error = 0; ScrStats *s = scr_stats_posix(path->data, -1, false, &error); /* follows symlinks */ @@ -6093,7 +6093,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_win_handle_stats(path, "lstat", true); + return scr_stats_of_path(path, "lstat", true); #else int error = 0; ScrStats *s = scr_stats_posix(path->data, -1, true, &error); /* no follow */ @@ -6135,10 +6135,11 @@ ScrStats *scr_fs_fstat(double fd) { return s; } +#ifdef SCR_FS_WATCH_FILE /* 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) { +ScrStats *scr_stats_poll_path(ScrStr *path, int *error) { if (!scr_fs_path_ok(path, "path")) { *error = EINVAL; return NULL; @@ -6161,6 +6162,7 @@ ScrStats *scr_stats_of_path(ScrStr *path, int *error) { * field 0, so isFile()/isDirectory() and the size/date reads all answer * zero/false. +1. */ ScrStats *scr_stats_zero(void) { return scr_stats_new(); } +#endif /* SCR_FS_WATCH_FILE */ /* ── Stats objects ─────────────────────────────────────────────────── * Node's Stats and BigIntStats over a snapshot. The integers and the *Ms diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 775f04e33..69c470217 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -507,8 +507,10 @@ double scr_stats_big_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); + * +1, or NULL with *error set to the errno. Both this and scr_stats_zero + * exist only in the SCR_FS_WATCH_FILE build of scr_lib.c, which programs + * that poll link; every other program links the default object. */ +ScrStats *scr_stats_poll_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 diff --git a/packages/runtime/src/scr_watch_file.c b/packages/runtime/src/scr_watch_file.c index b1bee2826..325e10adb 100644 --- a/packages/runtime/src/scr_watch_file.c +++ b/packages/runtime/src/scr_watch_file.c @@ -171,7 +171,7 @@ static void scr_watch_file_poll(ScrClosure *cb) { if (p == NULL) return; p->timer = 0; /* the loop consumed this arm */ int err = 0; - ScrStats *cur = scr_stats_of_path(p->path, &err); + ScrStats *cur = scr_stats_poll_path(p->path, &err); if (cur == NULL) { if (p->state != -err) { scr_watch_file_emit(p, NULL, p->last, -err);