Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
5eb0878950 Add mkdtempDisposable() and mkdtempDisposableSync()
Implements fs.mkdtempDisposable() in node:fs/promises and
fs.mkdtempDisposableSync() in node:fs.

These functions create temporary directories that automatically
clean themselves up when disposed using the `await using` and
`using` keywords respectively.

The returned object has a `path` property containing the created
directory path, a `remove()` method for manual cleanup, and
implements Symbol.asyncDispose (async) or Symbol.dispose (sync)
for automatic cleanup.

Tests:
- test/js/node/test/parallel/test-fs-promises-mkdtempDisposable.js 
- test/js/node/test/parallel/test-fs-mkdtempDisposableSync.js 

Fixes #24499

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 05:10:05 +00:00
4 changed files with 235 additions and 0 deletions

View File

@@ -161,6 +161,28 @@ const exports = {
lstat: asyncWrap(fs.lstat, "lstat"),
mkdir: asyncWrap(fs.mkdir, "mkdir"),
mkdtemp: asyncWrap(fs.mkdtemp, "mkdtemp"),
mkdtempDisposable: async (prefix, options) => {
const cwd = process.cwd();
const path = await fs.mkdtemp(prefix, options);
// Stash the full path in case of process.chdir()
const fullPath = require("node:path").resolve(cwd, path);
const remove = async () => {
await fs.rm(fullPath, {
maxRetries: 0,
recursive: true,
retryDelay: 0,
});
};
return {
__proto__: null,
path,
remove,
async [SymbolAsyncDispose]() {
await remove();
},
};
},
statfs: asyncWrap(fs.statfs, "statfs"),
open: async (path, flags = "r", mode = 0o666) => {
return new private_symbols.FileHandle(await fs.open(path, flags, mode), flags);

View File

@@ -554,6 +554,28 @@ var access = function access(path, mode, callback) {
lstatSync = fs.lstatSync.bind(fs) as unknown as typeof import("node:fs").lstatSync,
mkdirSync = fs.mkdirSync.bind(fs) as unknown as typeof import("node:fs").mkdirSync,
mkdtempSync = fs.mkdtempSync.bind(fs) as unknown as typeof import("node:fs").mkdtempSync,
mkdtempDisposableSync = function mkdtempDisposableSync(prefix, options) {
const cwd = process.cwd();
const path = fs.mkdtempSync(prefix, options);
// Stash the full path in case of process.chdir()
const fullPath = require("node:path").resolve(cwd, path);
const remove = () => {
fs.rmSync(fullPath, {
maxRetries: 0,
recursive: true,
retryDelay: 0,
});
};
return {
__proto__: null,
path,
remove,
[Symbol.dispose]() {
remove();
},
};
},
openSync = fs.openSync.bind(fs) as unknown as typeof import("node:fs").openSync,
readSync = function readSync(fd, buffer, offsetOrOptions, length, position) {
let offset = offsetOrOptions;
@@ -1193,6 +1215,7 @@ var exports = {
mkdirSync,
mkdtemp,
mkdtempSync,
mkdtempDisposableSync,
open,
openSync,
read,
@@ -1343,6 +1366,7 @@ setName(mkdir, "mkdir");
setName(mkdirSync, "mkdirSync");
setName(mkdtemp, "mkdtemp");
setName(mkdtempSync, "mkdtempSync");
setName(mkdtempDisposableSync, "mkdtempDisposableSync");
setName(open, "open");
setName(openSync, "openSync");
setName(read, "read");

View File

@@ -0,0 +1,92 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { isMainThread } = require('worker_threads');
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();
// Basic usage
{
const result = fs.mkdtempDisposableSync(tmpdir.resolve('foo.'));
assert.strictEqual(path.basename(result.path).length, 'foo.XXXXXX'.length);
assert.strictEqual(path.dirname(result.path), tmpdir.path);
assert(fs.existsSync(result.path));
result.remove();
assert(!fs.existsSync(result.path));
// Second removal does not throw error
result.remove();
}
// Usage with [Symbol.dispose]()
{
const result = fs.mkdtempDisposableSync(tmpdir.resolve('foo.'));
assert(fs.existsSync(result.path));
result[Symbol.dispose]();
assert(!fs.existsSync(result.path));
// Second removal does not throw error
result[Symbol.dispose]();
}
// `chdir`` does not affect removal
// Can't use chdir in workers
if (isMainThread) {
const originalCwd = process.cwd();
process.chdir(tmpdir.path);
const first = fs.mkdtempDisposableSync('first.');
const second = fs.mkdtempDisposableSync('second.');
const fullFirstPath = path.join(tmpdir.path, first.path);
const fullSecondPath = path.join(tmpdir.path, second.path);
assert(fs.existsSync(fullFirstPath));
assert(fs.existsSync(fullSecondPath));
process.chdir(fullFirstPath);
second.remove();
assert(!fs.existsSync(fullSecondPath));
process.chdir(tmpdir.path);
first.remove();
assert(!fs.existsSync(fullFirstPath));
process.chdir(originalCwd);
}
// Errors from cleanup are thrown
// It is difficult to arrange for rmdir to fail on windows
if (!common.isWindows && process.getuid() !== 0) {
const base = fs.mkdtempDisposableSync(tmpdir.resolve('foo.'));
// On Unix we can prevent removal by making the parent directory read-only
const child = fs.mkdtempDisposableSync(path.join(base.path, 'bar.'));
const originalMode = fs.statSync(base.path).mode;
fs.chmodSync(base.path, 0o444);
assert.throws(() => {
child.remove();
}, /EACCES|EPERM/);
fs.chmodSync(base.path, originalMode);
// Removal works once permissions are reset
child.remove();
assert(!fs.existsSync(child.path));
base.remove();
assert(!fs.existsSync(base.path));
}

View File

@@ -0,0 +1,97 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const fsPromises = require('fs/promises');
const path = require('path');
const { isMainThread } = require('worker_threads');
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();
async function basicUsage() {
const result = await fsPromises.mkdtempDisposable(tmpdir.resolve('foo.'));
assert.strictEqual(path.basename(result.path).length, 'foo.XXXXXX'.length);
assert.strictEqual(path.dirname(result.path), tmpdir.path);
assert(fs.existsSync(result.path));
await result.remove();
assert(!fs.existsSync(result.path));
// Second removal does not throw error
result.remove();
}
async function symbolAsyncDispose() {
const result = await fsPromises.mkdtempDisposable(tmpdir.resolve('foo.'));
assert(fs.existsSync(result.path));
await result[Symbol.asyncDispose]();
assert(!fs.existsSync(result.path));
// Second removal does not throw error
await result[Symbol.asyncDispose]();
}
async function chdirDoesNotAffectRemoval() {
// Can't use chdir in workers
if (!isMainThread) return;
const originalCwd = process.cwd();
process.chdir(tmpdir.path);
const first = await fsPromises.mkdtempDisposable('first.');
const second = await fsPromises.mkdtempDisposable('second.');
const fullFirstPath = path.join(tmpdir.path, first.path);
const fullSecondPath = path.join(tmpdir.path, second.path);
assert(fs.existsSync(fullFirstPath));
assert(fs.existsSync(fullSecondPath));
process.chdir(fullFirstPath);
await second.remove();
assert(!fs.existsSync(fullSecondPath));
process.chdir(tmpdir.path);
await first.remove();
assert(!fs.existsSync(fullFirstPath));
process.chdir(originalCwd);
}
async function errorsAreReThrown() {
// It is difficult to arrange for rmdir to fail on windows
if (common.isWindows || process.getuid() === 0) return;
const base = await fsPromises.mkdtempDisposable(tmpdir.resolve('foo.'));
// On Unix we can prevent removal by making the parent directory read-only
const child = await fsPromises.mkdtempDisposable(path.join(base.path, 'bar.'));
const originalMode = fs.statSync(base.path).mode;
fs.chmodSync(base.path, 0o444);
await assert.rejects(child.remove(), /EACCES|EPERM/);
fs.chmodSync(base.path, originalMode);
// Removal works once permissions are reset
await child.remove();
assert(!fs.existsSync(child.path));
await base.remove();
assert(!fs.existsSync(base.path));
}
(async () => {
await basicUsage();
await symbolAsyncDispose();
await chdirDoesNotAffectRemoval();
await errorsAreReThrown();
})().then(common.mustCall());