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 044926914..4aca82386 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -604,11 +604,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 @@ -620,12 +638,34 @@ 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.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 15cab8778..47c2856cb 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -67,6 +67,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 092a87bf1..fba260a63 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/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e6a72807c..7263554bf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -286,6 +286,8 @@ import { cryptoIntThunkFor, type BytesThunkValue, fsRenameThunkFor, + watchNameThunkFor, + watchErrorThunkFor, streamCbThunkFor, zlibBytesThunkFor, } from "./expr-stream-callbacks.js"; @@ -8161,6 +8163,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 a90764464..78dbc3639 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"; @@ -994,6 +994,161 @@ registerReplay("streamDoneFnFor", (host, args) => registerReplay("fsRenameThunkFor", (host, args) => fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }), ); +/** 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), 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" }, +): 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)`, + ]; + const named = cbT.params.length === 2; + if (named) { + d.push( + ` %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`, + ); + } + } + 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( + ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`, + ` call void @scr_str_release(ptr %evstr)`, + ); + ev = "%evd"; + } else { + ev = "%evstr"; + } + d.push( + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ); + 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; +} + +/** 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 ea275738e..2c44bb8ea 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, STRING, type IrExpr } from "../../ir/ir.js"; +import { DYN, F64, 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]; @@ -448,17 +464,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 +484,64 @@ 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 = watchChangeAdapter(host, cbT); + 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 = watchChangeAdapter(host, cbT); + wantsName = cbT.params.length === 2; + } 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]!); @@ -482,6 +549,45 @@ 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. 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") + 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})`, + ); + 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. 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") { // 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 ded050bce..207bea531 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -120,6 +120,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 5026fd5f2..fa0c864f9 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -258,6 +258,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 2f372c885..da0516224 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -346,6 +346,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"), @@ -425,6 +428,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/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 457f96749..ee45ef84f 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -62,6 +62,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 a6dcc69d8..b6e04f66c 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -111,6 +111,7 @@ export interface EarlyExecutableNativeFeatures { dgramDyn: boolean; textCodecDyn: boolean; watch: boolean; + watchFile: boolean; foreignFfi: boolean; nodeTest: boolean; tls: boolean; @@ -299,6 +300,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 7a2b262da..5506c45b1 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"; @@ -1127,13 +1128,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 @@ -1204,7 +1229,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") { @@ -1236,19 +1261,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); @@ -1262,10 +1279,134 @@ 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. */ +/** `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/ + * 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, @@ -1280,10 +1421,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/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index d2724b52e..8c5288cae 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", @@ -2485,6 +2492,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/src/index.ts b/packages/compiler/src/index.ts index a4354be74..e50bfca63 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -753,6 +753,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 134556c3c..22a97cc02 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -1094,6 +1094,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fs.closeSync", "fs.watch", "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 910794498..9663a3b53 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1319,6 +1319,23 @@ 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 }, + /** 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 dda0f1aaa..97e59cde5 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1081,6 +1081,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"] }), @@ -1302,6 +1305,7 @@ function noRuntimeFeatures(): RuntimeFeatures { fetch: false, fileHandle: false, fsWatch: false, + fsWatchFile: false, http2: false, http: false, inspect: false, @@ -1647,6 +1651,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 f591c6dd4..8d3a735ee 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4189,7 +4189,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 753ad1281..87dfebc0c 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -287,6 +287,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/surface-manifest.json b/packages/compiler/surface-manifest.json index f101e8b6d..292bdaadf 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-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/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-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index e76ea44f3..640ea00b8 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -184,6 +184,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"], @@ -275,6 +276,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 8ad21e218..3bb9a0725 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -6208,6 +6208,35 @@ 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_poll_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(); } +#endif /* SCR_FS_WATCH_FILE */ + /* ── 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 a5a8e4ff3..217f78561 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -513,6 +513,13 @@ double scr_stats_big_ctime(ScrStats *s); 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. 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 * binding's order, lazy Date accessors and the type tests on the * prototypes. Borrows s; +1. */ diff --git a/packages/runtime/src/scr_watch.c b/packages/runtime/src/scr_watch.c index f86adb219..d1730c75b 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,14 @@ 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) { + /* 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. */ @@ -295,6 +350,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 +376,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 +424,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 +457,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 +490,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, l + 1, (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 +641,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 +662,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 +692,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 +716,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 +754,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..83f02c917 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,14 +29,46 @@ 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); + +/* ── 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..325e10adb --- /dev/null +++ b/packages/runtime/src/scr_watch_file.c @@ -0,0 +1,360 @@ +/* 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 +#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_poll_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); +} + +/* 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 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) { + scr_str_release(resolved); + 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); + 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. 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); + 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 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) { + scr_stats_release(current); + scr_stats_release(previous); + ((void (*)(ScrClosure *))cb->fn)(cb); +} + +void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous) { + scr_stats_release(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..2282c7c6b --- /dev/null +++ b/tests/corpus/fs-watch-file-polling.ts @@ -0,0 +1,71 @@ +// 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. 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"; + +const dir = fs.mkdtempSync(join(tmpdir(), "scr-watchfile-")); +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++; + 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: -1 }, 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/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 073a031e5..126cdec25 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-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 } 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,