diff --git a/doc/api/fs.md b/doc/api/fs.md index 93e6e7c68a1..d6fe8837d0b 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1743,9 +1743,11 @@ added: v27.0.0-alpha.0.0.0 * `prefix` {string|Buffer|URL} * `options` {string|Object} * `encoding` {string} **Default:** `'utf8'` (or `'buffer'` if `prefix` is a `Buffer`) -* Returns: {Promise} Fulfills with an {Object}: +* Returns: {Promise} Fulfills with an async-disposable {Object}: * `path` {string|Buffer} The path of the created file. * `handle` {FileHandle} The created file, opened for reading and writing. + * `remove` {AsyncFunction} A function which closes the handle and removes the created file. + * `[Symbol.asyncDispose]` {AsyncFunction} The same as `remove`. Creates and opens a unique temporary file. A unique file name is generated by appending six random characters to the end of the provided `prefix`. Due to @@ -1757,6 +1759,18 @@ The file is created and opened in a single operation, so another process cannot create a file with the same name in between. On POSIX systems, the file is only readable and writable by its owner. +When the returned object is disposed, its file handle will be closed and the +file will be removed asynchronously if it still exists. If the file cannot be +deleted, disposal will throw an error. The object has an async `remove()` method +which will perform the same task. + +Both this function and the disposal function on the resulting object are async, +so it can be used with `await` + [`await using`][] as in +`await using file = await fsPromises.mkstemp('prefix')`. + +See the [MDN documentation on `using` statements][`using`] for more information about +explicit resource management. + The optional `options` argument can be a string specifying an encoding, or an object with an `encoding` property specifying the character encoding to use for the returned `path`. @@ -1766,18 +1780,16 @@ import { mkstemp } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -const { path, handle } = await mkstemp(join(tmpdir(), 'foo-')); -try { - await handle.writeFile('some data'); - console.log(path); - // Prints: /tmp/foo-itXde2 or C:\Users\...\AppData\Local\Temp\foo-itXde2 -} finally { - await handle.close(); -} +await using file = await mkstemp(join(tmpdir(), 'foo-')); +await file.handle.writeFile('some data'); +console.log(file.path); +// Prints: /tmp/foo-itXde2 or C:\Users\...\AppData\Local\Temp\foo-itXde2 +// When the scope exits, the file handle is closed and the file is deleted. ``` -The file is not removed automatically. It is the caller's responsibility to -close the {FileHandle} and to remove the file when it is no longer needed. +When not using `await using`, the file can be removed manually by calling +`await file.remove()`, or the handle can be closed manually with +`await file.handle.close()`. As with [`fsPromises.mkdtemp()`][], the random characters are appended directly to `prefix`. To create a file _within_ a directory, `prefix` must end with a @@ -4164,10 +4176,12 @@ added: v27.0.0-alpha.0.0.0 * `encoding` {string} **Default:** `'utf8'` (or `'buffer'` if `prefix` is a `Buffer`) * `callback` {Function} * `err` {Error} - * `file` {Object} + * `file` {Object} A disposable object: * `path` {string|Buffer} The path of the created file. * `fd` {integer} A file descriptor for the created file, opened for reading and writing. + * `remove` {Function} A function which closes the file descriptor and removes the created file. + * `[Symbol.dispose]` {Function} The same as `remove`. Creates and opens a unique temporary file. @@ -4181,6 +4195,11 @@ The file is created and opened in a single operation, so another process cannot create a file with the same name in between. On POSIX systems, the file is only readable and writable by its owner. +When the `file` object is disposed, its file descriptor will be closed and the +file will be removed if it still exists. If the file cannot be deleted, disposal +will throw an error. The object has a `remove()` method which will perform the +same task. + The optional `options` argument can be a string specifying an encoding, or an object with an `encoding` property specifying the character encoding to use for the `path` passed to the callback. @@ -4196,15 +4215,13 @@ mkstemp(join(tmpdir(), 'foo-'), (err, file) => { // Prints: /tmp/foo-itXde2 or C:\Users\...\AppData\Local\Temp\foo-itXde2 write(file.fd, 'some data', (err) => { if (err) throw err; - close(file.fd, (err) => { - if (err) throw err; - }); + file.remove(); }); }); ``` -The file is not removed automatically. It is the caller's responsibility to -close the file descriptor and to remove the file when it is no longer needed. +When not using `file.remove()` or `file[Symbol.dispose]()`, the file descriptor +can be closed manually with `close()` or `closeSync()`. As with [`fs.mkdtemp()`][], the random characters are appended directly to `prefix`. To create a file _within_ a directory, `prefix` must end with a @@ -6706,13 +6723,26 @@ added: v27.0.0-alpha.0.0.0 * `prefix` {string|Buffer|URL} * `options` {string|Object} * `encoding` {string} **Default:** `'utf8'` (or `'buffer'` if `prefix` is a `Buffer`) -* Returns: {Object} +* Returns: {Object} A disposable object: * `path` {string|Buffer} The path of the created file. * `fd` {integer} A file descriptor for the created file, opened for reading and writing. + * `remove` {Function} A function which closes the file descriptor and removes the created file. + * `[Symbol.dispose]` {Function} The same as `remove`. Synchronously creates and opens a unique temporary file. +When the returned object is disposed, its file descriptor will be closed and the +file will be removed if it still exists. If the file cannot be deleted, disposal +will throw an error. The object has a `remove()` method which will perform the +same task. + +This enables the returned object to be used with the [`using`][] syntax as in +`using file = fs.mkstempSync('prefix')`. + +See the [MDN documentation on `using` statements][`using`] for more information about +explicit resource management. + For detailed information, see the documentation of the asynchronous version of this API: [`fs.mkstemp()`][]. diff --git a/lib/fs.js b/lib/fs.js index a5fc9a7571c..294bd25de6c 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -3835,9 +3835,16 @@ function mkdtempDisposableSync(prefix, options) { /** * Creates and opens a unique temporary file. + * The returned value is an object which removes the file and closes its file + * descriptor when disposed. * @param {string | Buffer | URL} prefix * @param {string | { encoding?: string; }} [options] - * @param {(err?: Error, file?: { path: string | Buffer, fd: number }) => any} callback + * @param {(err?: Error, file?: { + * path: string | Buffer, + * fd: number, + * remove: () => void, + * [Symbol.dispose]: () => void, + * }) => any} callback * @returns {void} */ function mkstemp(prefix, options, callback) { @@ -3853,19 +3860,51 @@ function mkstemp(prefix, options, callback) { const h = vfsState.handlers; if (h !== null && vfsResult(h.mkstemp(prefix, options), callback)) return; + const cwd = process.cwd(); const req = new FSReqCallback(); req.oncomplete = (err, result) => { if (err) return callback(err); - callback(null, { path: result[0], fd: result[1] }); + const path = result[0]; + const fd = result[1]; + const fullPath = pathModule.resolve(cwd, BufferIsBuffer(path) ? BufferToString(path) : path); + + const remove = () => { + try { + binding.close(fd); + } catch (err) { + if (err.code !== 'EBADF') throw err; + } + try { + binding.unlink(fullPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }; + + callback(null, { + path, + fd, + remove, + [SymbolDispose]() { + remove(); + }, + }); }; binding.mkstemp(prefix, options.encoding, req); } /** * Synchronously creates and opens a unique temporary file. + * The returned value is an object which removes the file and closes its file + * descriptor when disposed. * @param {string | Buffer | URL} prefix * @param {string | { encoding?: string; }} [options] - * @returns {{ path: string | Buffer, fd: number }} + * @returns {{ + * path: string | Buffer, + * fd: number, + * remove: () => void, + * [Symbol.dispose]: () => void, + * }} */ function mkstempSync(prefix, options) { options = getOptions(options); @@ -3882,7 +3921,31 @@ function mkstempSync(prefix, options) { } const result = binding.mkstemp(prefix, options.encoding); - return { path: result[0], fd: result[1] }; + const path = result[0]; + const fd = result[1]; + const fullPath = pathModule.resolve(process.cwd(), BufferIsBuffer(path) ? BufferToString(path) : path); + + const remove = () => { + try { + binding.close(fd); + } catch (err) { + if (err.code !== 'EBADF') throw err; + } + try { + binding.unlink(fullPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }; + + return { + path, + fd, + remove, + [SymbolDispose]() { + remove(); + }, + }; } /** diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index b79e807fe6c..a5207f4134d 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -2136,6 +2136,19 @@ async function mkdtemp(prefix, options) { ); } +/** + * Creates and opens a unique temporary file asynchronously. + * The returned value is an async-disposable object which removes the file and + * closes its file handle when disposed. + * @param {string | Buffer | URL} prefix + * @param {string | { encoding?: string; }} [options] + * @returns {Promise<{ + * path: string | Buffer, + * handle: FileHandle, + * remove: () => Promise, + * [Symbol.asyncDispose]: () => Promise, + * }>} + */ async function mkstemp(prefix, options) { options = getOptions(options); if (BufferIsBuffer(prefix)) { @@ -2150,12 +2163,34 @@ async function mkstemp(prefix, options) { if (promise !== undefined) return await promise; } + const cwd = process.cwd(); const result = await PromisePrototypeThen( binding.mkstemp(prefix, options.encoding, kUsePromises), undefined, handleErrorFromBinding, ); - return { __proto__: null, path: result[0], handle: new FileHandle(result[1]) }; + const path = result[0]; + const handle = new FileHandle(result[1]); + const fullPath = pathModule.resolve(cwd, BufferIsBuffer(path) ? BufferToString(path) : path); + + const remove = async () => { + await handle.close(); + try { + await unlink(fullPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }; + + return { + __proto__: null, + path, + handle, + remove, + async [SymbolAsyncDispose]() { + await remove(); + }, + }; } async function mkdtempDisposable(prefix, options) { diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 4982542239e..f0061d0a7a2 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -7,6 +7,7 @@ const { StringPrototypeSlice, StringPrototypeStartsWith, Symbol, + SymbolAsyncDispose, SymbolDispose, } = primordials; @@ -620,12 +621,38 @@ class VirtualFileSystem { /** * Creates and opens a unique temporary file synchronously. * @param {string} prefix The prefix for the temp file - * @returns {{ path: string, fd: number }} The full path and a file descriptor + * @returns {{ + * path: string, + * fd: number, + * remove: () => void, + * [Symbol.dispose]: () => void, + * }} The full path and a file descriptor */ mkstempSync(prefix) { const filePath = this.#toProviderPrefix(prefix) + randomSuffix(); const handle = this[kProvider].openSync(filePath, 'wx+', 0o600); - return { path: this.#toMountedPath(filePath), fd: openVirtualFd(handle) }; + const mountedPath = this.#toMountedPath(filePath); + const fd = openVirtualFd(handle); + const remove = () => { + try { + this.closeSync(fd); + } catch (err) { + if (err.code !== 'EBADF') throw err; + } + try { + this.unlinkSync(mountedPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }; + return { + path: mountedPath, + fd, + remove, + [SymbolDispose]() { + remove(); + }, + }; } /** @@ -1349,10 +1376,24 @@ class VirtualFileSystem { async mkstemp(prefix) { const filePath = toProviderPrefix(prefix) + randomSuffix(); const handle = provider.openSync(filePath, 'wx+', 0o600); + const mountedPath = toMountedPath(filePath); + const fd = openVirtualFd(handle); + const remove = async () => { + closeVirtualFd(fd); + try { + await this.unlink(mountedPath); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }; return { __proto__: null, - path: toMountedPath(filePath), - fd: openVirtualFd(handle), + path: mountedPath, + fd, + remove, + async [SymbolAsyncDispose]() { + await remove(); + }, }; }, diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index 1ed74f5d566..01cb684c89b 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -9,6 +9,7 @@ const { String, StringPrototypeEndsWith, StringPrototypeStartsWith, + SymbolAsyncDispose, } = primordials; const { Buffer } = require('buffer'); @@ -891,21 +892,33 @@ function createVfsHandlers() { // Wrapped in async functions so provider throws become rejections. mkstemp(prefix, options) { return vfsOp(prefix, async (vfs, n) => { - const { path, fd } = vfs.mkstempSync(n); - return { - __proto__: null, - path: options?.encoding === 'buffer' ? Buffer.from(path) : path, - fd, - }; + const file = vfs.mkstempSync(n); + if (options?.encoding === 'buffer') { + file.path = Buffer.from(file.path); + } + return file; }); }, promisesMkstemp(prefix, options) { return vfsOp(prefix, async (vfs, n) => { const { path, fd } = vfs.mkstempSync(n); + const handle = createVfsFileHandle(getVirtualFd(fd)); + const remove = async () => { + await handle.close(); + try { + await vfs.promises.unlink(path); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + }; return { __proto__: null, path: options?.encoding === 'buffer' ? Buffer.from(path) : path, - handle: createVfsFileHandle(getVirtualFd(fd)), + handle, + remove, + async [SymbolAsyncDispose]() { + await remove(); + }, }; }); }, diff --git a/test/parallel/test-fs-mkstemp-disposable.js b/test/parallel/test-fs-mkstemp-disposable.js new file mode 100644 index 00000000000..bf92035cb0e --- /dev/null +++ b/test/parallel/test-fs-mkstemp-disposable.js @@ -0,0 +1,251 @@ +'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(); + +// Sync basic usage +{ + const result = fs.mkstempSync(tmpdir.resolve('sync-remove.')); + + assert.strictEqual(path.basename(result.path).length, 'sync-remove.XXXXXX'.length); + assert.strictEqual(path.dirname(result.path), tmpdir.path); + assert.strictEqual(typeof result.fd, 'number'); + assert.strictEqual(typeof result.remove, 'function'); + assert.strictEqual(typeof result[Symbol.dispose], 'function'); + assert(fs.existsSync(result.path)); + + fs.writeSync(result.fd, 'hello'); + result.remove(); + + assert(!fs.existsSync(result.path)); + + // Second removal does not throw error (idempotent) + result.remove(); +} + +// Sync usage with [Symbol.dispose]() +{ + const result = fs.mkstempSync(tmpdir.resolve('sync-dispose.')); + + assert(fs.existsSync(result.path)); + + fs.writeSync(result.fd, 'hello'); + result[Symbol.dispose](); + + assert(!fs.existsSync(result.path)); + + // Second removal does not throw error (idempotent) + result[Symbol.dispose](); +} + +// Sync when fd is closed manually prior to disposal +{ + const result = fs.mkstempSync(tmpdir.resolve('sync-manual-close.')); + + assert(fs.existsSync(result.path)); + fs.closeSync(result.fd); + + result.remove(); + assert(!fs.existsSync(result.path)); +} + +// Sync with Buffer prefix +{ + const result = fs.mkstempSync(Buffer.from(tmpdir.resolve('sync-buffer.'))); + + assert(Buffer.isBuffer(result.path)); + assert(fs.existsSync(result.path.toString())); + + result.remove(); + assert(!fs.existsSync(result.path.toString())); +} + +// Sync `chdir` does not affect removal +if (isMainThread) { + const originalCwd = process.cwd(); + const otherDir = tmpdir.resolve('other-dir-sync'); + fs.mkdirSync(otherDir); + + process.chdir(tmpdir.path); + const first = fs.mkstempSync('first.'); + const second = fs.mkstempSync('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(otherDir); + second.remove(); + + assert(!fs.existsSync(fullSecondPath)); + + process.chdir(tmpdir.path); + first.remove(); + assert(!fs.existsSync(fullFirstPath)); + + process.chdir(originalCwd); +} + +// Callback basic usage +{ + fs.mkstemp(tmpdir.resolve('callback-remove.'), common.mustSucceed((result) => { + assert.strictEqual(typeof result.fd, 'number'); + assert.strictEqual(typeof result.remove, 'function'); + assert.strictEqual(typeof result[Symbol.dispose], 'function'); + assert(fs.existsSync(result.path)); + + fs.writeSync(result.fd, 'hello'); + result.remove(); + + assert(!fs.existsSync(result.path)); + + // Second removal does not throw error (idempotent) + result.remove(); + })); + + fs.mkstemp(tmpdir.resolve('callback-dispose.'), common.mustSucceed((result) => { + assert(fs.existsSync(result.path)); + + fs.writeSync(result.fd, 'hello'); + result[Symbol.dispose](); + + assert(!fs.existsSync(result.path)); + + result[Symbol.dispose](); + })); + + fs.mkstemp(tmpdir.resolve('callback-manual-close.'), common.mustSucceed((result) => { + assert(fs.existsSync(result.path)); + fs.closeSync(result.fd); + + result.remove(); + assert(!fs.existsSync(result.path)); + })); +} + +// Promises basic usage +async function promisesBasicUsage() { + const result = await fsPromises.mkstemp(tmpdir.resolve('promise-remove.')); + + assert.strictEqual(path.basename(result.path).length, 'promise-remove.XXXXXX'.length); + assert.strictEqual(path.dirname(result.path), tmpdir.path); + assert.ok(result.handle); + assert.strictEqual(typeof result.remove, 'function'); + assert.strictEqual(typeof result[Symbol.asyncDispose], 'function'); + assert(fs.existsSync(result.path)); + + await result.handle.writeFile('hello'); + await result.remove(); + + assert(!fs.existsSync(result.path)); + + // Second removal does not throw error (idempotent) + await result.remove(); +} + +// Promises usage with [Symbol.asyncDispose]() +async function promisesSymbolAsyncDispose() { + const result = await fsPromises.mkstemp(tmpdir.resolve('promise-dispose.')); + + assert(fs.existsSync(result.path)); + + await result.handle.writeFile('hello'); + await result[Symbol.asyncDispose](); + + assert(!fs.existsSync(result.path)); + + // Second removal does not throw error (idempotent) + await result[Symbol.asyncDispose](); +} + +// Promises when handle is closed manually prior to disposal +async function promisesManualClose() { + const result = await fsPromises.mkstemp(tmpdir.resolve('promise-manual-close.')); + + assert(fs.existsSync(result.path)); + await result.handle.close(); + + await result.remove(); + assert(!fs.existsSync(result.path)); +} + +// Promises with Buffer prefix +async function promisesBufferPrefix() { + const result = await fsPromises.mkstemp(Buffer.from(tmpdir.resolve('promise-buffer.'))); + + assert(Buffer.isBuffer(result.path)); + assert(fs.existsSync(result.path.toString())); + + await result.remove(); + assert(!fs.existsSync(result.path.toString())); +} + +// Promises `chdir` does not affect removal +async function promisesChdirDoesNotAffectRemoval() { + if (!isMainThread) return; + + const originalCwd = process.cwd(); + const otherDir = tmpdir.resolve('other-dir-promise'); + fs.mkdirSync(otherDir); + + process.chdir(tmpdir.path); + const first = await fsPromises.mkstemp('first-p.'); + const second = await fsPromises.mkstemp('second-p.'); + + 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(otherDir); + await second.remove(); + + assert(!fs.existsSync(fullSecondPath)); + + process.chdir(tmpdir.path); + await first.remove(); + assert(!fs.existsSync(fullFirstPath)); + + process.chdir(originalCwd); +} + +// Errors from cleanup are thrown +// On Unix we can prevent removal by making the parent directory read-only +async function errorsAreReThrown() { + if (common.isWindows || process.getuid() === 0) return; + + const subDir = tmpdir.resolve('readonly-dir'); + fs.mkdirSync(subDir); + + const file = fs.mkstempSync(path.join(subDir, 'child.')); + fs.chmodSync(subDir, 0o444); + + assert.throws(() => { + file.remove(); + }, /EACCES|EPERM/); + + fs.chmodSync(subDir, 0o777); + file.remove(); + assert(!fs.existsSync(file.path)); + + fs.rmdirSync(subDir); +} + +(async () => { + await promisesBasicUsage(); + await promisesSymbolAsyncDispose(); + await promisesManualClose(); + await promisesBufferPrefix(); + await promisesChdirDoesNotAffectRemoval(); + await errorsAreReThrown(); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-mkstemp.js b/test/parallel/test-vfs-mkstemp.js index 5dd805e3643..41719c40429 100644 --- a/test/parallel/test-vfs-mkstemp.js +++ b/test/parallel/test-vfs-mkstemp.js @@ -34,6 +34,17 @@ const vfs = require('node:vfs'); })); assert.rejects(myVfs.promises.mkstemp('/missing/prefix-'), { code: 'ENOENT' }) .then(common.mustCall()); + + // Test disposal on VirtualFileSystem + { + const file = myVfs.mkstempSync('/tmp-disp-'); + assert.strictEqual(typeof file.remove, 'function'); + assert.strictEqual(typeof file[Symbol.dispose], 'function'); + assert.ok(myVfs.statSync(file.path).isFile()); + file.remove(); + assert.throws(() => myVfs.statSync(file.path), { code: 'ENOENT' }); + file.remove(); // idempotent + } } // node:fs on a mounted path @@ -84,8 +95,32 @@ const vfs = require('node:vfs'); const result = await fs.promises.mkstemp(Buffer.from(prefix)); assert.ok(Buffer.isBuffer(result.path)); await result.handle.close(); + + const disposable = await fs.promises.mkstemp(prefix); + assert.strictEqual(typeof disposable.remove, 'function'); + assert.strictEqual(typeof disposable[Symbol.asyncDispose], 'function'); + assert.ok(fs.existsSync(disposable.path)); + await disposable.remove(); + assert.ok(!fs.existsSync(disposable.path)); + await disposable.remove(); // idempotent })().then(common.mustCall()); + { + const file = fs.mkstempSync(prefix); + assert.strictEqual(typeof file.remove, 'function'); + assert.strictEqual(typeof file[Symbol.dispose], 'function'); + assert.ok(fs.existsSync(file.path)); + file.remove(); + assert.ok(!fs.existsSync(file.path)); + file.remove(); // idempotent + } + + { + const file = fs.mkstempSync(prefix); + file[Symbol.dispose](); + assert.ok(!fs.existsSync(file.path)); + } + const missing = path.join(dir, 'missing', 'f-'); assert.throws(() => fs.mkstempSync(missing), { code: 'ENOENT' }); fs.mkstemp(missing, common.expectsError({ code: 'ENOENT' }));