Support fs.promises.rm options

fs.promises.rm(path, options) accepts the { recursive, force, maxRetries,
retryDelay } literal options that rmSync accepts. It runs the same removal
and rejects with its errors.

Removing a directory without recursive now throws Node's ERR_FS_EISDIR
SystemError from rmSync and fs.promises.rm. A retryDelay outside int32 >= 0
or a maxRetries outside uint32 throws ERR_OUT_OF_RANGE. rmSync checks these
options before the path, and fs.promises.rm checks the path first, as Node
does.

Fixes #1615

Co-authored-by: jlucaso1 <55464917+jlucaso1@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-10-21 09:13:12 -05:00
co-authored by jlucaso1
parent 0237d70df0
commit 973884eabe
13 changed files with 196 additions and 29 deletions
+4
View File
@@ -2128,6 +2128,10 @@ declare module "fs/promises" {
options: import("node:fs").GlobOptionsWithFileTypes,
): AsyncIterableIterator<import("node:fs").Dirent>;
export function rm(path: string): Promise<void>;
export function rm(
path: string,
options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number },
): Promise<void>;
export function stat(path: string): Promise<import("node:fs").Stats>;
export function statfs(
path: string | Uint8Array | URL,
@@ -793,6 +793,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -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,
};
}
@@ -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.
@@ -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
+11 -3
View File
@@ -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 },
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6930-fs-promises-rm-options.ts"
],
"diags": []
}
+13
View File
@@ -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);
+29 -13
View File
@@ -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__)
+4
View File
@@ -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);
+3 -2
View File
@@ -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 ("<ERRNO>: <text>,
// <syscall> '<path>'"), so the uncaught line renders "Error: <message>" —
// 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"),
);
@@ -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("<dir>"));
} else {
console.log(label, "non-error");
}
}
async function settle(label: string, run: () => Promise<void>): Promise<void> {
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<void> {
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();
@@ -0,0 +1 @@
{ "plain": 1965, "san": 46298 }