mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
fs.watch names the changed entry; fs.watchFile polls in its own gated unit (#2507)
This commit is contained in:
@@ -100,6 +100,8 @@ title: "Limitations"
|
||||
|
||||
<include>../limitations/builtin-apis/file-streams.mdx</include>
|
||||
|
||||
<include>../limitations/builtin-apis/file-watching.mdx</include>
|
||||
|
||||
<include>../limitations/builtin-apis/crypto.mdx</include>
|
||||
|
||||
<include>../limitations/builtin-apis/assertions.mdx</include>
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
|
||||
+45
-5
@@ -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<T> = (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<string>): FSWatcher;
|
||||
export function watch(
|
||||
path: string,
|
||||
options: WatchOptions,
|
||||
listener?: (eventType: string) => void,
|
||||
listener?: WatchListener<string>,
|
||||
): 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"`. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function compilePackedLibrary(
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -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]})`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ const features = {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -62,6 +62,7 @@ const BASE: NativeLinkFeatures = {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function warmNativeCaches(
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -60,6 +60,7 @@ const native: EarlyExecutableNativeFeatures = {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +443,13 @@ export const FS_WATCH_DOCUMENTED_OPTIONS: ReadonlySet<string> = new Set([
|
||||
"signal",
|
||||
]);
|
||||
|
||||
/** fs.watchFile's documented option keys. */
|
||||
export const FS_WATCH_FILE_DOCUMENTED_OPTIONS: ReadonlySet<string> = new Set([
|
||||
"interval",
|
||||
"persistent",
|
||||
"bigint",
|
||||
]);
|
||||
|
||||
/** fs.readdirSync's documented option keys. */
|
||||
export const FS_READDIR_DOCUMENTED_OPTIONS: ReadonlySet<string> = 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.
|
||||
{
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
moduleUsesQs,
|
||||
moduleUsesParseArgs,
|
||||
moduleUsesFsWatch,
|
||||
moduleUsesFsWatchFile,
|
||||
moduleUsesNodeTest,
|
||||
moduleUsesDgram,
|
||||
moduleUsesHttpServer,
|
||||
|
||||
@@ -1094,6 +1094,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
|
||||
"fs.closeSync",
|
||||
"fs.watch",
|
||||
"fs.watchCb",
|
||||
"watcher.on",
|
||||
"watcher.off",
|
||||
"fs.watchFile",
|
||||
"fs.unwatchFile",
|
||||
"crypto.x509Fingerprint",
|
||||
"crypto.x509FingerprintStr",
|
||||
"crypto.x509ValidFrom",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -194,6 +194,7 @@ async function main(): Promise<number> {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -287,6 +287,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures {
|
||||
"dgramDyn",
|
||||
"textCodecDyn",
|
||||
"watch",
|
||||
"watchFile",
|
||||
"foreignFfi",
|
||||
"nodeTest",
|
||||
"tls",
|
||||
|
||||
@@ -66,6 +66,7 @@ const features = {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/fs-watch-file-names.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/fs-watch-file-polling.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/fs-watch-listener-shapes.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -57,12 +57,21 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || \
|
||||
defined(__OpenBSD__) || defined(__DragonFly__)
|
||||
#define SCR_HAVE_KQUEUE 1
|
||||
#include <sys/event.h>
|
||||
#include <sys/param.h>
|
||||
/* 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 <sys/inotify.h>
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 <errno.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<string>, 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<string> = (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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+2
-2
@@ -32,10 +32,10 @@ ReadableStream.from(new Set([1, 2]));
|
||||
/* Members of SUPPORTED builtin modules beyond the lowered tables: they
|
||||
* typecheck under @types/node and fence with the module-qualified name —
|
||||
* calls and value reads alike. */
|
||||
import { watchFile } from "fs";
|
||||
import { openAsBlob } from "fs";
|
||||
import { EventEmitter, on } from "node:events";
|
||||
import { win32 } from "path";
|
||||
watchFile("x", () => {});
|
||||
openAsBlob("x");
|
||||
on(new EventEmitter(), "tick");
|
||||
console.log(win32.sep);
|
||||
/* URL getters and serialization lower under @types/node, but object-URL
|
||||
|
||||
@@ -43,18 +43,18 @@ fenced.ts:31:21 - error SC2020: 'ReadableStream.from() over Set<number> iterable
|
||||
|
||||
hint: arrays, Uint8Array, and strings are supported — spread other synchronous iterables into an array first: [...iterable]
|
||||
|
||||
fenced.ts:38:1 - error SC2020: 'fs.watchFile' is typed by @types/node but has no scriptc lowering yet
|
||||
fenced.ts:38:1 - error SC2020: 'fs.openAsBlob' is typed by @types/node but has no scriptc lowering yet
|
||||
|
||||
37 | import { win32 } from "path";
|
||||
38 | watchFile("x", () => {});
|
||||
| ^~~~~~~~~~~~~~~~~~~~~~~~
|
||||
38 | openAsBlob("x");
|
||||
| ^~~~~~~~~~~~~~~
|
||||
39 | on(new EventEmitter(), "tick");
|
||||
|
||||
hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations)
|
||||
|
||||
fenced.ts:39:1 - error SC2020: 'events.on' is typed by @types/node but has no scriptc lowering yet
|
||||
|
||||
38 | watchFile("x", () => {});
|
||||
38 | openAsBlob("x");
|
||||
39 | on(new EventEmitter(), "tick");
|
||||
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
40 | console.log(win32.sep);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1300, "san": 3300 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1300, "san": 3800 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3100, "san": 9200 }
|
||||
@@ -0,0 +1,64 @@
|
||||
/* fs.watch and fs.watchFile listeners under LeakSanitizer. A listener owns
|
||||
* the arguments its adapter hands it, and the adapter releases the ones the
|
||||
* listener has no parameter for, including the event string it boxes for a
|
||||
* dynamic parameter and the Stats a watchFile listener of zero or one
|
||||
* parameter leaves out. The sanitized lanes otherwise run with
|
||||
* detect_leaks=0, so this runs the watch programs with LeakSanitizer
|
||||
* explicitly on. Linux only (Apple's ASan has no LeakSanitizer); runs in
|
||||
* the SCRIPTC_SAN=1 lane. */
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { compile } from "@scriptc/compiler";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = join(import.meta.dirname, "../..");
|
||||
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
|
||||
const sanitize = process.env["SCRIPTC_SAN"] === "1";
|
||||
|
||||
const programs = [
|
||||
"tests/corpus/fs-watch-listener-shapes.ts",
|
||||
"tests/corpus/fs-watch-file-polling.ts",
|
||||
];
|
||||
|
||||
describe.skipIf(!sanitize || process.platform !== "linux")(
|
||||
"fs watchers under LeakSanitizer",
|
||||
() => {
|
||||
test.each(programs)("%s reports no leaks", { timeout: 300_000 }, async (program) => {
|
||||
const file = join(repoRoot, program);
|
||||
const key = createHash("sha256")
|
||||
.update(file)
|
||||
.update(readFileSync(file))
|
||||
.update("san")
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const outDir = join(cacheDir, `fs-watch-leaks-${key}`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const result = await compile(file, {
|
||||
outPath: join(outDir, "program"),
|
||||
outDir,
|
||||
sanitize: true,
|
||||
backend: "llvm",
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
"program failed to compile:\n" +
|
||||
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
const run = await execFileAsync(result.binaryPath, [], {
|
||||
env: { ...process.env, ASAN_OPTIONS: "detect_leaks=1" },
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}).catch((error: { code?: number; stdout?: string; stderr?: string }) => error);
|
||||
expect(run.stderr).toBe("");
|
||||
expect("code" in run ? run.code : 0).toBe(0);
|
||||
const expected = await execFileAsync(process.execPath, ["--no-warnings", file], {
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
expect(run.stdout).toBe(expected.stdout);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -47,6 +47,7 @@ const features: NativeLinkFeatures = {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
@@ -44,6 +44,7 @@ const base: NativeLinkFeatures = {
|
||||
dgramDyn: false,
|
||||
textCodecDyn: false,
|
||||
watch: false,
|
||||
watchFile: false,
|
||||
foreignFfi: false,
|
||||
nodeTest: false,
|
||||
tls: false,
|
||||
|
||||
Reference in New Issue
Block a user