mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
fs.watch/fs.watchFile: fix listener removal, adapter ownership and argument checks
- watcher.off/removeListener of a listener that is not the last one moves the later listeners down instead of up, so the removed closure leaves the list. - The watchFile adapters for zero- and one-parameter listeners release the Stats their listener has no parameter for, and the fs.watch adapter releases the event string once a dynamic value holds it. - A one-parameter fs.watch listener whose eventType is dynamic receives a boxed string through the emitted adapter. - fs.watch, fs.watchFile and fs.unwatchFile throw ERR_INVALID_ARG_VALUE for a path holding a null byte, and a new watchFile poller throws ERR_OUT_OF_RANGE when interval is not a uint32, as Node does. Both watchFile libCalls check for the pending exception. - fs.watchFile and fs.unwatchFile have surface-manifest entries, so a fence can deny them. - The self-hosted feature records list watchFile, and the node-types fence fixture uses fs.openAsBlob in place of the now-lowered fs.watchFile. Tests: tests/corpus/fs-watch-listener-shapes.ts (listener shapes, off of a middle listener, null-byte path) with its order baseline and cost file, the validation cases in fs-watch-file-polling.ts, and tests/harness/fs-watch-leaks.test.ts, which runs both programs under LeakSanitizer in the sanitized lane.
This commit is contained in:
@@ -966,10 +966,12 @@ registerReplay("streamDoneFnFor", (host, args) =>
|
||||
registerReplay("fsRenameThunkFor", (host, args) =>
|
||||
fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }),
|
||||
);
|
||||
/** The (eventType, filename) fs.watch listener adapter: the runtime hands
|
||||
/** The fs.watch listener adapter for an (eventType, filename) listener, or
|
||||
* for an (eventType) listener whose parameter is dynamic: the runtime hands
|
||||
* the borrowed C strings, the thunk builds what the listener declared. The
|
||||
* listener owns its +1 arguments (the universal convention
|
||||
* scr_watch_thunk_event follows). */
|
||||
* scr_watch_thunk_event follows), so a string the thunk boxes into a dynamic
|
||||
* value is released once the box holds it. */
|
||||
export function watchNameThunkFor(
|
||||
host: LlvmEmitterContext,
|
||||
cbT: IrType & { kind: "func" },
|
||||
@@ -989,76 +991,84 @@ export function watchNameThunkFor(
|
||||
`entry:`,
|
||||
` %evlen = call ${S} @strlen(ptr %event)`,
|
||||
` %evstr = call ptr @scr_str_new(ptr %event, ${S} %evlen)`,
|
||||
` %slot = alloca ptr`,
|
||||
` %has = icmp ne ptr %name, null`,
|
||||
` br i1 %has, label %yes, label %no`,
|
||||
`yes:`,
|
||||
];
|
||||
const nameParam = cbT.params[1]!;
|
||||
if (nameParam.kind === "dyn") {
|
||||
host.declare(`declare ptr @scr_dyn_new_null()`);
|
||||
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
|
||||
const named = cbT.params.length === 2;
|
||||
if (named) {
|
||||
d.push(
|
||||
` %nlen = call ${S} @strlen(ptr %name)`,
|
||||
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
|
||||
` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`,
|
||||
` call void @scr_str_release(ptr %nstr)`,
|
||||
` store ptr %ndyn, ptr %slot`,
|
||||
` br label %go`,
|
||||
`no:`,
|
||||
` %dn = call ptr @scr_dyn_new_null()`,
|
||||
` store ptr %dn, ptr %slot`,
|
||||
` %slot = alloca ptr`,
|
||||
` %has = icmp ne ptr %name, null`,
|
||||
` br i1 %has, label %yes, label %no`,
|
||||
`yes:`,
|
||||
);
|
||||
} else {
|
||||
if (nameParam.kind !== "union")
|
||||
throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union");
|
||||
const def = host.unionsById.get(nameParam.unionId);
|
||||
const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1;
|
||||
const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
|
||||
if (strTag < 0 || nullTag < 0)
|
||||
throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms");
|
||||
if (host.nullableUnions.has(nameParam.unionId)) {
|
||||
// `string | null` erases: the owned payload IS the union's value.
|
||||
const nameParam = cbT.params[1]!;
|
||||
if (nameParam.kind === "dyn") {
|
||||
host.declare(`declare ptr @scr_dyn_new_null()`);
|
||||
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
|
||||
d.push(
|
||||
` %nlen = call ${S} @strlen(ptr %name)`,
|
||||
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
|
||||
` store ptr %nstr, ptr %slot`,
|
||||
` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`,
|
||||
` call void @scr_str_release(ptr %nstr)`,
|
||||
` store ptr %ndyn, ptr %slot`,
|
||||
` br label %go`,
|
||||
`no:`,
|
||||
` store ptr null, ptr %slot`,
|
||||
` %dn = call ptr @scr_dyn_new_null()`,
|
||||
` store ptr %dn, ptr %slot`,
|
||||
);
|
||||
} else {
|
||||
const rc = vAdapters(host.shapeHost, STRING);
|
||||
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
|
||||
d.push(
|
||||
` %nlen = call ${S} @strlen(ptr %name)`,
|
||||
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
|
||||
` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`,
|
||||
` store ptr %nu, ptr %slot`,
|
||||
` br label %go`,
|
||||
`no:`,
|
||||
` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`,
|
||||
);
|
||||
if (nameParam.kind !== "union")
|
||||
throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union");
|
||||
const def = host.unionsById.get(nameParam.unionId);
|
||||
const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1;
|
||||
const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
|
||||
if (strTag < 0 || nullTag < 0)
|
||||
throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms");
|
||||
if (host.nullableUnions.has(nameParam.unionId)) {
|
||||
// `string | null` erases: the owned payload IS the union's value.
|
||||
d.push(
|
||||
` %nlen = call ${S} @strlen(ptr %name)`,
|
||||
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
|
||||
` store ptr %nstr, ptr %slot`,
|
||||
` br label %go`,
|
||||
`no:`,
|
||||
` store ptr null, ptr %slot`,
|
||||
);
|
||||
} else {
|
||||
const rc = vAdapters(host.shapeHost, STRING);
|
||||
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
|
||||
d.push(
|
||||
` %nlen = call ${S} @strlen(ptr %name)`,
|
||||
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
|
||||
` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`,
|
||||
` store ptr %nu, ptr %slot`,
|
||||
` br label %go`,
|
||||
`no:`,
|
||||
` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
d.push(` br label %go`, `go:`);
|
||||
}
|
||||
let ev: string;
|
||||
if (cbT.params[0]?.kind === "dyn") {
|
||||
// The box retains the string; the thunk's own reference goes.
|
||||
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
|
||||
d.push(` br label %go`, `go:`, ` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`);
|
||||
d.push(
|
||||
` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`,
|
||||
` call void @scr_str_release(ptr %evstr)`,
|
||||
);
|
||||
ev = "%evd";
|
||||
} else {
|
||||
d.push(` br label %go`, `go:`);
|
||||
ev = "%evstr";
|
||||
}
|
||||
d.push(
|
||||
` %arg = load ptr, ptr %slot`,
|
||||
` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
|
||||
` %fn = load ptr, ptr %fnp`,
|
||||
` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`,
|
||||
` ret void`,
|
||||
`}`,
|
||||
``,
|
||||
);
|
||||
if (named)
|
||||
d.push(` %arg = load ptr, ptr %slot`, ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`);
|
||||
else d.push(` call void %fn(ptr %cb, ptr ${ev})`);
|
||||
d.push(` ret void`, `}`, ``);
|
||||
host.resolveThunkDefs.push(...d);
|
||||
host.journal?.built();
|
||||
return sym;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Focused LLVM library-call emission extracted from emitter.ts. */
|
||||
import { InternalCompilerError } from "../../errors.js";
|
||||
import { undefinedArmTag } from "../../ir/analysis.js";
|
||||
import { DYN, F64, STATS_T, STRING, type IrExpr } from "../../ir/ir.js";
|
||||
import { DYN, F64, STATS_T, STRING, type IrExpr, type IrType } from "../../ir/ir.js";
|
||||
import { mangleRecordNew } from "../mangle.js";
|
||||
import { arrNewCall, traceArg, vAdapters } from "./shapes.js";
|
||||
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
|
||||
@@ -173,6 +173,22 @@ function emitObjEntryPairs(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The adapter of an fs.watch 'change' listener, by its declared shape: the
|
||||
* runtime's for no parameters and for a string eventType, and one this
|
||||
* module emits for a dynamic eventType or an (eventType, filename) listener.
|
||||
* Only the runtime's adapters need a declaration. */
|
||||
function watchChangeAdapter(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string {
|
||||
const runtime =
|
||||
cbT.params.length === 0
|
||||
? "scr_watch_thunk0"
|
||||
: cbT.params.length === 1 && cbT.params[0]!.kind !== "dyn"
|
||||
? "scr_watch_thunk_event"
|
||||
: null;
|
||||
if (runtime === null) return host.watchNameThunkFor(cbT);
|
||||
host.declare(`declare void @${runtime}(ptr, ptr, ptr)`);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
|
||||
const B = host.B;
|
||||
const alwaysThrowSym = FS_ALWAYS_THROW_SYMS[e.fn];
|
||||
@@ -468,15 +484,7 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
|
||||
const path = host.emitExpr(e.args[0]!);
|
||||
const cb = host.emitExpr(e.args[1]!);
|
||||
host.toHeap(cb);
|
||||
const adapter =
|
||||
cbT.params.length === 0
|
||||
? "scr_watch_thunk0"
|
||||
: cbT.params.length === 1
|
||||
? "scr_watch_thunk_event"
|
||||
: host.watchNameThunkFor(cbT);
|
||||
// The named form is a definition this module emits; only the runtime's
|
||||
// fixed adapters need a declaration.
|
||||
if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
|
||||
const adapter = watchChangeAdapter(host, cbT);
|
||||
host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`);
|
||||
const t = B.tmp();
|
||||
B.line(
|
||||
@@ -509,16 +517,8 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
|
||||
let adapter = "null";
|
||||
let wantsName = false;
|
||||
if (code === 0) {
|
||||
adapter =
|
||||
cbT.params.length === 0
|
||||
? "scr_watch_thunk0"
|
||||
: cbT.params.length === 1
|
||||
? "scr_watch_thunk_event"
|
||||
: host.watchNameThunkFor(cbT);
|
||||
adapter = watchChangeAdapter(host, cbT);
|
||||
wantsName = cbT.params.length === 2;
|
||||
// The named form is a definition this module emits; only the runtime's
|
||||
// fixed adapters need a declaration.
|
||||
if (cbT.params.length < 2) host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
|
||||
} else if (code === 1) {
|
||||
adapter = cbT.params.length === 0 ? "scr_watch_err_thunk0" : host.watchErrorThunkFor(cbT);
|
||||
if (cbT.params.length === 0) host.declare(`declare void @scr_watch_err_thunk0(ptr, ptr)`);
|
||||
@@ -553,7 +553,9 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
|
||||
// (path, interval, persistent, listener) — the listener MOVES into
|
||||
// the poller's registry and the adapter is runtime-provided per
|
||||
// listener shape (zero, one or two Stats parameters). The poller
|
||||
// re-arms a timer every interval, so this is a timer program.
|
||||
// re-arms a timer every interval, so this is a timer program. May
|
||||
// throw (a null byte in the path, or an interval that is not a
|
||||
// uint32) — the standard pending check.
|
||||
host.usesTimers = true;
|
||||
const cbT = e.args[3]!.type;
|
||||
if (cbT.kind !== "func")
|
||||
@@ -569,18 +571,21 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
|
||||
B.line(
|
||||
`call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`,
|
||||
);
|
||||
host.emitPendingCheck();
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
if (e.fn === "fs.unwatchFile") {
|
||||
// Removal is by closure identity: the listener stays borrowed
|
||||
// (never heap-moved), exactly like watcher.off's; an absent one is
|
||||
// the NULL that removes every listener on the path.
|
||||
// the NULL that removes every listener on the path. A null byte in
|
||||
// the path throws.
|
||||
const path = host.emitExpr(e.args[0]!);
|
||||
const cb = e.args[1] === undefined ? null : host.emitExpr(e.args[1]!);
|
||||
host.declare(`declare void @scr_fs_unwatch_file(ptr, ptr)`);
|
||||
B.line(
|
||||
`call void @scr_fs_unwatch_file(ptr ${path.name}, ptr ${cb === null ? "null" : cb.name})`,
|
||||
);
|
||||
host.emitPendingCheck();
|
||||
return { name: "", type: e.type };
|
||||
}
|
||||
if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") {
|
||||
|
||||
@@ -2416,6 +2416,22 @@ function buildAmbientSurfaceFns(): readonly AmbientSurfaceRow[] {
|
||||
fns: ["fs.callbackValue", "fs.callbackCall", "fs.callbackGlob"],
|
||||
note: "native filesystem callbacks, including stored platform adapter functions",
|
||||
},
|
||||
// fs.watchFile/fs.unwatchFile lower in lowerFsWatchFileCall, ahead of
|
||||
// the module table.
|
||||
{
|
||||
id: "node-builtin.fs.watchFile",
|
||||
kind: "node-builtin",
|
||||
name: "fs.watchFile",
|
||||
fns: ["fs.watchFile"],
|
||||
note: "stat polling of a resolved path with Stats listeners, in statement position; bigint: true is refused",
|
||||
},
|
||||
{
|
||||
id: "node-builtin.fs.unwatchFile",
|
||||
kind: "node-builtin",
|
||||
name: "fs.unwatchFile",
|
||||
fns: ["fs.unwatchFile"],
|
||||
note: "removes one listener, or every listener, from a resolved path's poller",
|
||||
},
|
||||
// ── the Date slice (lowerDateCall/lowerNew): statics, construction,
|
||||
// stored read-only values, calendar getters, and ISO formatting.
|
||||
{
|
||||
|
||||
@@ -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-listener-shapes.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -288,6 +288,12 @@ void scr_watcher_release_v(void *p) { scr_watcher_release((ScrWatcher *)p); }
|
||||
* filter. cb (nullable) moves in as the first listener. Result +1. */
|
||||
ScrWatcher *scr_fs_watch(ScrStr *path, ScrClosure *cb /*moves, nullable*/, ScrWatchFn fn,
|
||||
bool wants_name) {
|
||||
/* getValidatedPath: a null byte throws ERR_INVALID_ARG_VALUE before the
|
||||
* path reaches the platform call. */
|
||||
if (!scr_fs_path_ok(path, "path")) {
|
||||
if (cb != NULL) scr_closure_release(cb);
|
||||
return NULL; /* exception pending */
|
||||
}
|
||||
#ifdef SCR_HAVE_INOTIFY
|
||||
/* The wd IS the validation: Node's Linux throw comes straight from
|
||||
* inotify_add_watch's errno, the same message shape as the open below. */
|
||||
@@ -542,7 +548,7 @@ void scr_watcher_off(ScrWatcher *w, double event, ScrClosure *cb) {
|
||||
ScrWatchL *l = &w->ls[i - 1];
|
||||
if (l->event != (uint8_t)event || l->cb != cb) continue;
|
||||
scr_closure_release(l->cb);
|
||||
memmove(l + 1, l, (w->n - i) * sizeof *w->ls);
|
||||
memmove(l, l + 1, (w->n - i) * sizeof *w->ls);
|
||||
w->n--;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
#include "scr_runtime.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -230,11 +231,31 @@ static void scr_watch_file_stop(ScrWatchFiler *p) {
|
||||
scr_watch_file_release(p);
|
||||
}
|
||||
|
||||
/* StatWatcher's validateUint32(interval, 'interval'): false after throwing
|
||||
* Node's ERR_OUT_OF_RANGE RangeError. */
|
||||
static bool scr_watch_file_interval_ok(double interval) {
|
||||
bool integer = isfinite(interval) && trunc(interval) == interval;
|
||||
if (integer && interval >= 0 && interval <= 4294967295.0) return true;
|
||||
char recv[48], msg[160];
|
||||
scr_num_received(interval, recv);
|
||||
int len = snprintf(msg, sizeof msg,
|
||||
"The value of \"interval\" is out of range. It must be %s. Received %s",
|
||||
integer ? ">= 0 && <= 4294967295" : "an integer", recv);
|
||||
scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* fs.watchFile(path, interval, persistent, listener) — the listener MOVES
|
||||
* in. A second call on the same resolved path adds the listener to the
|
||||
* existing poller and ignores its options (Node's rule). */
|
||||
* in. A path holding a null byte throws Node's ERR_INVALID_ARG_VALUE. A
|
||||
* second call on the same resolved path adds the listener to the existing
|
||||
* poller and ignores its options (Node's rule); a new poller first checks
|
||||
* `interval` as a uint32, and registers nothing when that throws. */
|
||||
void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosure *cb,
|
||||
ScrWatchFileFn fn) {
|
||||
if (!scr_fs_path_ok(path, "path")) {
|
||||
scr_closure_release(cb);
|
||||
return; /* exception pending */
|
||||
}
|
||||
ScrStr *resolved = scr_watch_file_resolve(path);
|
||||
ScrWatchFiler *existing = scr_watch_file_find(resolved);
|
||||
if (existing != NULL) {
|
||||
@@ -242,6 +263,11 @@ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosur
|
||||
scr_watch_file_add(existing, cb, fn);
|
||||
return;
|
||||
}
|
||||
if (!scr_watch_file_interval_ok(interval)) {
|
||||
scr_str_release(resolved);
|
||||
scr_closure_release(cb);
|
||||
return; /* exception pending */
|
||||
}
|
||||
ScrWatchFiler *p = malloc(sizeof *p);
|
||||
if (!p) {
|
||||
scr_str_release(resolved);
|
||||
@@ -275,8 +301,10 @@ void scr_fs_watch_file(ScrStr *path, double interval, bool persistent, ScrClosur
|
||||
|
||||
/* fs.unwatchFile(path[, listener]) — `cb` is borrowed (pointer identity);
|
||||
* NULL removes every listener on the resolved path. Removing the last one
|
||||
* stops the poller. */
|
||||
* stops the poller. A path holding a null byte throws Node's
|
||||
* ERR_INVALID_ARG_VALUE. */
|
||||
void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) {
|
||||
if (!scr_fs_path_ok(path, "path")) return; /* exception pending */
|
||||
ScrStr *resolved = scr_watch_file_resolve(path);
|
||||
ScrWatchFiler *p = scr_watch_file_find(resolved);
|
||||
scr_str_release(resolved);
|
||||
@@ -299,16 +327,17 @@ void scr_fs_unwatch_file(ScrStr *path, ScrClosure *cb) {
|
||||
}
|
||||
|
||||
/* ── the runtime-provided listener adapters ───────────────────────────
|
||||
* The listener owns its +1 arguments (the universal convention
|
||||
* scr_watch_thunk_event follows). */
|
||||
* The adapter receives +1 references and the listener owns those it takes
|
||||
* (the universal convention scr_watch_thunk_event follows); the adapter
|
||||
* releases the ones its listener has no parameter for. */
|
||||
void scr_watch_file_thunk0(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
|
||||
(void)current;
|
||||
(void)previous;
|
||||
scr_stats_release(current);
|
||||
scr_stats_release(previous);
|
||||
((void (*)(ScrClosure *))cb->fn)(cb);
|
||||
}
|
||||
|
||||
void scr_watch_file_thunk1(ScrClosure *cb, ScrStats *current, ScrStats *previous) {
|
||||
(void)previous;
|
||||
scr_stats_release(previous);
|
||||
((void (*)(ScrClosure *, ScrStats *))cb->fn)(cb, current);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// sides zeroed, and the creation reports previous.size 0 and previous.mtimeMs 0. Two watchFile calls on one path share one poller;
|
||||
// unwatchFile(path, listener) removes one listener, unwatchFile(path)
|
||||
// removes the rest, and once no listener is left the poller stops and
|
||||
// the process can exit.
|
||||
// the process can exit. A new poller's interval must be a uint32 (a call on
|
||||
// a watched path ignores its options), and a path holding a null byte
|
||||
// throws for both calls.
|
||||
import * as fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -15,6 +17,26 @@ const existing = join(dir, "existing.txt");
|
||||
const later = join(dir, "later.txt");
|
||||
fs.writeFileSync(existing, "a");
|
||||
|
||||
const invalid = join(dir, "invalid.txt");
|
||||
for (const interval of [-1, 1.5, Number.NaN, 2 ** 32]) {
|
||||
try {
|
||||
fs.watchFile(invalid, { interval }, () => {});
|
||||
console.log("interval", interval, "accepted");
|
||||
} catch (err) {
|
||||
console.log("interval", interval, (err as NodeJS.ErrnoException).code, (err as Error).message);
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.watchFile("bad\0path", () => {});
|
||||
} catch (err) {
|
||||
console.log("watchFile", (err as NodeJS.ErrnoException).code, (err as Error).message);
|
||||
}
|
||||
try {
|
||||
fs.unwatchFile("bad\0path");
|
||||
} catch (err) {
|
||||
console.log("unwatchFile", (err as NodeJS.ErrnoException).code, (err as Error).message);
|
||||
}
|
||||
|
||||
let firstCalls = 0;
|
||||
const first = (current: fs.Stats, previous: fs.Stats): void => {
|
||||
firstCalls++;
|
||||
@@ -27,7 +49,7 @@ const second = (current: fs.Stats, previous: fs.Stats): void => {
|
||||
startMissingPhase();
|
||||
};
|
||||
fs.watchFile(existing, { interval: 20 }, first);
|
||||
fs.watchFile(existing, { interval: 1000 }, second);
|
||||
fs.watchFile(existing, { interval: -1 }, second);
|
||||
setTimeout(() => fs.writeFileSync(existing, "abcd"), 150);
|
||||
|
||||
function startMissingPhase(): void {
|
||||
|
||||
@@ -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": 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