diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index fb107ef99..329f066fc 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2128,6 +2128,10 @@ declare module "fs/promises" { options: import("node:fs").GlobOptionsWithFileTypes, ): AsyncIterableIterator; export function rm(path: string): Promise; + export function rm( + path: string, + options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number }, + ): Promise; export function stat(path: string): Promise; export function statfs( path: string | Uint8Array | URL, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 76b6e88f5..ffce524d8 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -793,6 +793,8 @@ export const LIB_FN_SYMS: Record = { "fsp.rename": "scr_fsp_rename", "fsp.readdir": "scr_fsp_readdir", "fsp.rm": "scr_fsp_rm", + "fsp.rmOpts": "scr_fsp_rm_opts", + "fsp.rmRetry": "scr_fsp_rm_opts_retry", "fsp.stat": "scr_fsp_stat", "fsp.realpath": "scr_fsp_realpath", "fsp.lstat": "scr_fsp_lstat", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 05028e1df..4de69a3f6 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -863,15 +863,22 @@ export function lowerFsPromisesMkdirCall( : { kind: "libCall", fn: "fsp.mkdirRecursive", args: [path], type, loc }; } -// rmSync(p, options): literal { recursive?, force? } booleans — the -// cleanup shape rmSync(dir, { recursive: true, force: true }) — -// plus the maxRetries/retryDelay numbers (the tmpdir-harness shape +// rmSync(p, options) and fs.promises.rm(p, options): literal +// { recursive?, force? } booleans — the cleanup shape +// rmSync(dir, { recursive: true, force: true }) — plus the +// maxRetries/retryDelay numbers (the tmpdir-harness shape // rmSync(p, { maxRetries: 3, recursive: true, force: true })): those // lower as ordinary number expressions into the retry libCall, whose // runtime implements Node's linear-backoff retry on // EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM. The retry-free shape keeps the -// historical rmOptsSync lowering unchanged. -export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { +// historical rmOptsSync lowering unchanged. The promise form runs the same +// removal and rejects with its error. +export function lowerFsRemoveCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, + promise: boolean, +): IrExpr { const opts = literalBoolOptions( expr.arguments.nodes[1]!, ["recursive", "force"], @@ -879,7 +886,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc ); if (opts === null) { lowerer.noLowering( - "rmSync with an options argument beyond { recursive, force, maxRetries, retryDelay }", + `${promise ? "fs.promises.rm" : "rmSync"} with an options argument beyond { recursive, force, maxRetries, retryDelay }`, expr.arguments.nodes[1]!, "the recursive/force flags must be boolean literals; maxRetries/retryDelay are numbers; other options have no lowering", ); @@ -888,12 +895,13 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc const recursive = boolLit(opts.bools["recursive"] === true, loc); const force = boolLit(opts.bools["force"] === true, loc); + const type: IrType = promise ? { kind: "promise", inner: VOID } : VOID; if (opts.exprs["maxRetries"] === undefined && opts.exprs["retryDelay"] === undefined) { return { kind: "libCall", - fn: "fs.rmOptsSync", + fn: promise ? "fsp.rmOpts" : "fs.rmOptsSync", args: [path, recursive, force], - type: VOID, + type, loc, }; } @@ -904,7 +912,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc node ? lowerer.lowerExprExpecting(node, F64) : { kind: "numLit", value: dflt, type: F64, loc }; return { kind: "libCall", - fn: "fs.rmRetrySync", + fn: promise ? "fsp.rmRetry" : "fs.rmRetrySync", args: [ path, recursive, @@ -912,7 +920,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc num(opts.exprs["maxRetries"], 0), num(opts.exprs["retryDelay"], 100), ], - type: VOID, + type, loc, }; } diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index c85f2c777..970a474c7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -292,7 +292,10 @@ export function lowerBuiltinModuleCall( return lowerFsPromisesMkdirCall(lowerer, expr, loc); } if (bi.module === "fs" && bi.member === "rmSync" && expr.arguments.nodes.length === 2) { - return lowerFsRemoveCall(lowerer, expr, loc); + return lowerFsRemoveCall(lowerer, expr, loc, false); + } + if (bi.module === "fs/promises" && bi.member === "rm" && expr.arguments.nodes.length === 2) { + return lowerFsRemoveCall(lowerer, expr, loc, true); } // accessSync(p, mode?) and truncateSync(p, len?), with their promise // spellings: an omitted mode is Node's F_OK (0) and an omitted len is 0. diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 4e08ba401..c77938337 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2104,6 +2104,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< glob: ["fsp.globTypes"], // The Uint8Array/Buffer-data form. appendFile: ["fsp.appendFileBytes"], + // The options forms. + rm: ["fsp.rmOpts", "fsp.rmRetry"], }, crypto: { // The composed randomBytes(n).toString(enc) chain keeps its one-libCall diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index eb2718616..ffe91a6b7 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3139,6 +3139,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * scandir failure becomes its rejection. */ "fsp.readdirTypes": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, "fsp.rm": { argTypes: [STRING], result: { kind: "promise", inner: VOID } }, + /** `fs.promises.rm(path, options)`: fs.rmOptsSync and fs.rmRetrySync + * behind a settled promise; their errors become its rejection. */ + "fsp.rmOpts": { argTypes: [STRING, BOOL, BOOL], result: { kind: "promise", inner: VOID } }, + "fsp.rmRetry": { + argTypes: [STRING, BOOL, BOOL, F64, F64], + result: { kind: "promise", inner: VOID }, + }, "fsp.stat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } }, "fsp.realpath": { argTypes: [STRING], result: { kind: "promise", inner: STRING } }, "fsp.lstat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } }, @@ -4622,8 +4629,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * return value has no lowering (statement position only, frontend- * fenced). rmOptsSync is rmSync with (recursive, force) bools: force * swallows ENOENT, recursive removes trees post-order, a directory - * without recursive throws the EISDIR-worded error (divergence 13's - * wording note). mkdtempSync appends the six X's and returns the + * without recursive throws Node's ERR_FS_EISDIR SystemError. mkdtempSync appends the six X's and returns the * created path (+1). accessSync takes the F_OK/R_OK/W_OK/X_OK bits as * one f64 (the frontend bakes fs.constants.* as literals and completes * an omitted mode to 0). readFdSync/readFdSyncBytes are the @@ -4634,7 +4640,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** rmOptsSync's maxRetries/retryDelay form (path, recursive, force, * maxRetries, retryDelay): Node's linear-backoff retry on * EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM — the tmpdir-harness shape - * rmSync(p, { maxRetries: 3, recursive: true, force: true }). */ + * rmSync(p, { maxRetries: 3, recursive: true, force: true }). Throws + * ERR_OUT_OF_RANGE for a retryDelay outside int32 >= 0 or a maxRetries + * outside uint32, before checking the path. */ "fs.rmRetrySync": { argTypes: [STRING, BOOL, BOOL, F64, F64], result: VOID }, "fs.mkdtempSync": { argTypes: [STRING], result: STRING }, "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-fs-promises-rm-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-fs-promises-rm-options.ts.json new file mode 100644 index 000000000..b4ade9601 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6930-fs-promises-rm-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6930-fs-promises-rm-options.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 6055734b3..12cf81f77 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -2900,6 +2900,19 @@ ScrPromise *scr_fsp_rm(ScrStr *path) { return scr_promise_settled_void(); } +ScrPromise *scr_fsp_rm_opts(ScrStr *path, bool recursive, bool force) { + scr_fs_rm_opts(path, recursive, force); + return scr_promise_settled_void(); +} + +/* fs.promises.rm validates the path before its options, unlike rmSync. */ +ScrPromise *scr_fsp_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, + double retry_delay) { + if (scr_fs_path_ok(path, "path") && scr_fs_rm_retry_options_ok(max_retries, retry_delay)) + scr_fs_rm_opts_retry(path, recursive, force, max_retries, retry_delay); + return scr_promise_settled_void(); +} + ScrPromise *scr_fsp_stat(ScrStr *path) { ScrStats *st = scr_fs_stat(path); return scr_promise_settled_ref(st, &scr_stats_retain_v, &scr_stats_release_v, NULL); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 976847049..db0926690 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4596,6 +4596,13 @@ void scr_fs_rename(ScrStr *oldpath, ScrStr *newpath) { static int scr_rm_unlink(const char *path, size_t len); +/* Node's ERR_FS_EISDIR for rm on a directory without `recursive`. */ +static void scr_fs_rm_throw_eisdir(const ScrStr *path) { + ScrStr *message = scr_str_new("is a directory", 14); + scr_fs_throw_system_error("ERR_FS_EISDIR", "Path is a directory", EISDIR, "rm", message, path); + scr_str_release(message); +} + void scr_fs_rm(ScrStr *path) { if (!scr_fs_path_ok(path, "path")) return; #ifdef _WIN32 @@ -4603,16 +4610,15 @@ void scr_fs_rm(ScrStr *path) { if (link != 0) { if (link < 0) scr_fs_throw(errno, "unlink", path); return; } #endif /* Node's rmSync: lstat first (a missing path reports the lstat syscall), - * refuse directories (Node requires `recursive`, which the scriptc - * surface doesn't declare — the message wording diverges from Node's - * ERR_FS_EISDIR, see SEMANTICS.md), then unlink. */ + * refuse directories (ERR_FS_EISDIR: removing one requires `recursive`), + * then unlink. */ struct stat st; if (lstat(path->data, &st) != 0) { scr_fs_throw(errno, "lstat", path); return; } if (S_ISDIR(st.st_mode)) { - scr_fs_throw(EISDIR, "rm", path); + scr_fs_rm_throw_eisdir(path); return; } if (scr_rm_unlink(path->data, path->len) != 0) scr_fs_throw(errno, "unlink", path); @@ -4766,6 +4772,13 @@ typedef struct { ScrStr *path; } ScrRmFail; +/* Throws the recorded failure and releases its path. */ +static void scr_rm_fail_throw(ScrRmFail *f) { + if (f->err == EISDIR && strcmp(f->op, "rm") == 0) scr_fs_rm_throw_eisdir(f->path); + else scr_fs_throw(f->err, f->op, f->path); + scr_str_release(f->path); +} + static void scr_rm_fail_set(ScrRmFail *f, int err, const char *op, const char *path, size_t len) { if (f->err != 0) return; /* first failure wins, exactly like the old unwind */ f->err = err; @@ -4861,8 +4874,6 @@ static void scr_fs_rm_attempt(ScrStr *path, bool recursive, bool force, ScrRmFai } if (S_ISDIR(st.st_mode)) { if (!recursive) { - /* Node throws ERR_FS_EISDIR here; the EISDIR-prefixed wording is - * divergence 13's documented difference. */ scr_rm_fail_set(f, EISDIR, "rm", path->data, path->len); return; } @@ -4876,18 +4887,24 @@ void scr_fs_rm_opts(ScrStr *path, bool recursive, bool force) { if (!scr_fs_path_ok(path, "path")) return; ScrRmFail f = {0, NULL, NULL}; scr_fs_rm_attempt(path, recursive, force, &f); - if (f.err != 0) { - scr_fs_throw(f.err, f.op, f.path); - scr_str_release(f.path); - } + if (f.err != 0) scr_rm_fail_throw(&f); +} + +/* Node's validateRmdirOptions range checks: retryDelay is an int32 >= 0, + * then maxRetries is a uint32 (ERR_OUT_OF_RANGE). */ +bool scr_fs_rm_retry_options_ok(double max_retries, double retry_delay) { + return scr_bytes_validate_off("options.retryDelay", retry_delay, 2147483647.0) && + scr_bytes_validate_off("options.maxRetries", max_retries, 4294967295.0); } /* rmSync(p, { recursive, force, maxRetries, retryDelay }): Node retries * the operation on EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM up to maxRetries * times, waiting retryDelay ms LONGER on each try (linear backoff — the * fs.rmSync documented semantics). Everything else throws immediately - * with the failing path, exactly like the plain options form. */ + * with the failing path, exactly like the plain options form. rmSync + * validates the options before the path. */ void scr_fs_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, double retry_delay) { + if (!scr_fs_rm_retry_options_ok(max_retries, retry_delay)) return; if (!scr_fs_path_ok(path, "path")) return; long tries = max_retries > 0 ? (long)max_retries : 0; double delay_ms = retry_delay > 0 ? retry_delay : 0; @@ -4912,8 +4929,7 @@ void scr_fs_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_r nanosleep(&ts, NULL); } } - scr_fs_throw(f.err, f.op, f.path); - scr_str_release(f.path); + scr_rm_fail_throw(&f); } #if defined(_WIN32) || defined(__wasi__) diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index bae678292..1b859cea1 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -306,6 +306,7 @@ void scr_fs_rm_opts(ScrStr *path, bool recursive, bool force); * EPERM failures up to maxRetries times with Node's linear backoff * (retryDelay ms longer per try); everything else throws immediately. */ void scr_fs_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, double retry_delay); +bool scr_fs_rm_retry_options_ok(double max_retries, double retry_delay); ScrStr *scr_fs_mkdtemp(ScrStr *prefix); ScrStr *scr_fs_mkdtemp_async(ScrStr *prefix); /* the callback and promise error path */ void scr_fs_access(ScrStr *path, double mode); @@ -493,6 +494,9 @@ ScrPromise *scr_fsp_utimes(const ScrDyn *path, const ScrDyn *atime, const ScrDyn ScrPromise *scr_fsp_lutimes(const ScrDyn *path, const ScrDyn *atime, const ScrDyn *mtime); ScrPromise *scr_fsp_readdir(ScrStr *path); ScrPromise *scr_fsp_rm(ScrStr *path); +ScrPromise *scr_fsp_rm_opts(ScrStr *path, bool recursive, bool force); +ScrPromise *scr_fsp_rm_opts_retry(ScrStr *path, bool recursive, bool force, double max_retries, + double retry_delay); ScrPromise *scr_fsp_stat(ScrStr *path); ScrPromise *scr_fsp_realpath(ScrStr *path); ScrPromise *scr_fsp_lstat(ScrStr *path); diff --git a/packages/runtime/test/lib.test.ts b/packages/runtime/test/lib.test.ts index fbecaff37..2a32202b7 100644 --- a/packages/runtime/test/lib.test.ts +++ b/packages/runtime/test/lib.test.ts @@ -62,7 +62,8 @@ afterAll(async () => { // fs failure through scr_exc_print_uncaught. fs failures are Error // INSTANCES whose messages carry Node's exact shape (": , // ''"), so the uncaught line renders "Error: " — -// the same first line Node prints for an uncaught fs error. Typed catches +// the same first line Node prints for an uncaught fs error (rm on a +// directory is Node's ERR_FS_EISDIR SystemError instead). Typed catches // observe the message itself (`e.message`, corpus-covered differentially); // these lines pin OUR uncaught rendering. test("process + fs library: checks pass, error messages match Node's shape", async () => { @@ -76,7 +77,7 @@ test("process + fs library: checks pass, error messages match Node's shape", asy `Uncaught Error: ENOENT: no such file or directory, scandir '${scratch}/missing.txt'`, `Uncaught Error: EEXIST: file already exists, mkdir '${scratch}/sub'`, `Uncaught Error: ENOENT: no such file or directory, mkdir '${scratch}/nope/deep'`, - `Uncaught Error: EISDIR: illegal operation on a directory, rm '${scratch}/sub'`, + `Uncaught SystemError [ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) ${scratch}/sub`, ``, ].join("\n"), ); diff --git a/tests/corpus/6930-fs-promises-rm-options.ts b/tests/corpus/6930-fs-promises-rm-options.ts new file mode 100644 index 000000000..e6de67fdd --- /dev/null +++ b/tests/corpus/6930-fs-promises-rm-options.ts @@ -0,0 +1,99 @@ +// fs.promises.rm with an options object: { recursive, force } and the +// maxRetries/retryDelay pair, as rmSync accepts them. Failures reject with +// Node's errors: a missing path without force, a directory without +// recursive (ERR_FS_EISDIR), and an out-of-range maxRetries or retryDelay. +// rmSync throws the same errors, checking its options before the path, +// while fs.promises.rm checks the path first. +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fsp-rm-")); + +function report(label: string, error: unknown): void { + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + console.log(label, error.name, code, error.message.split(dir).join("")); + } else { + console.log(label, "non-error"); + } +} + +async function settle(label: string, run: () => Promise): Promise { + try { + await run(); + console.log(label, "fulfilled"); + } catch (error) { + report(label, error); + } +} + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "returned"); + } catch (error) { + report(label, error); + } +} + +function tree(root: string): void { + fs.mkdirSync(path.join(root, "a", "b"), { recursive: true }); + fs.writeFileSync(path.join(root, "a", "b", "leaf.txt"), "leaf"); + fs.writeFileSync(path.join(root, "top.txt"), "top"); +} + +async function main(): Promise { + const missing = path.join(dir, "missing"); + await settle("missing force", () => fsp.rm(missing, { recursive: true, force: true })); + await settle("missing force only", () => fsp.rm(missing, { force: true })); + await settle("missing", () => fsp.rm(missing, { recursive: true })); + await settle("missing empty options", () => fsp.rm(missing, {})); + + const file = path.join(dir, "file.txt"); + fs.writeFileSync(file, "x"); + await settle("file", () => fsp.rm(file, { force: false })); + console.log("file exists", fs.existsSync(file)); + + const nested = path.join(dir, "nested"); + tree(nested); + await settle("dir without recursive", () => fsp.rm(nested, { force: true })); + console.log("dir exists", fs.existsSync(nested)); + await settle("dir recursive", () => fsp.rm(nested, { recursive: true })); + console.log("dir exists", fs.existsSync(nested)); + tree(nested); + await settle("fs.promises.rm", () => fs.promises.rm(nested, { recursive: true, force: true })); + console.log("dir exists", fs.existsSync(nested)); + + const retried = path.join(dir, "retried"); + tree(retried); + await settle("retry", () => fsp.rm(retried, { recursive: true, force: true, maxRetries: 2, retryDelay: 10 })); + console.log("retried exists", fs.existsSync(retried)); + await settle("retry missing", () => fsp.rm(retried, { maxRetries: 1 })); + await settle("negative retries", () => fsp.rm(retried, { force: true, maxRetries: -1 })); + await settle("fractional delay", () => fsp.rm(retried, { force: true, retryDelay: 1.5 })); + + await settle("null byte and range", () => fsp.rm("a\0b", { maxRetries: -1 })); + await settle("too many retries", () => fsp.rm(retried, { force: true, maxRetries: 2 ** 32 })); + await settle("long delay", () => fsp.rm(retried, { force: true, retryDelay: 2 ** 31 })); + await settle("delay before retries", () => fsp.rm(retried, { maxRetries: -1, retryDelay: 0.5 })); + + tree(nested); + attempt("rmSync dir", () => fs.rmSync(nested)); + attempt("rmSync dir force", () => fs.rmSync(nested, { force: true })); + attempt("rmSync null byte and range", () => fs.rmSync("a\0b", { maxRetries: -1 })); + attempt("rmSync negative delay", () => fs.rmSync(nested, { recursive: true, retryDelay: -1 })); + attempt("rmSync retry", () => fs.rmSync(nested, { recursive: true, maxRetries: 1 })); + console.log("dir exists", fs.existsSync(nested)); + + // The promise is created before the await, and a rejection is caught + // through .catch like any other promise. + const pending = fsp.rm(missing, { recursive: false }); + await pending.catch((error: unknown) => report("caught", error)); + + fs.rmSync(dir, { recursive: true, force: true }); + console.log("done", fs.existsSync(dir)); +} + +void main(); diff --git a/tests/harness/corpus-costs/6930-fs-promises-rm-options.ts.json b/tests/harness/corpus-costs/6930-fs-promises-rm-options.ts.json new file mode 100644 index 000000000..738c081aa --- /dev/null +++ b/tests/harness/corpus-costs/6930-fs-promises-rm-options.ts.json @@ -0,0 +1 @@ +{ "plain": 1965, "san": 46298 }