diff --git a/lib/internal/vfs/providers/real.js b/lib/internal/vfs/providers/real.js index df9bd00ac1ad..2f25f582244c 100644 --- a/lib/internal/vfs/providers/real.js +++ b/lib/internal/vfs/providers/real.js @@ -2,6 +2,7 @@ const { ArrayPrototypePush, + MathMin, Promise, StringPrototypeStartsWith, } = primordials; @@ -11,11 +12,21 @@ const fs = require('fs'); const path = require('path'); const { VirtualProvider } = require('internal/vfs/provider'); const { VirtualFileHandle } = require('internal/vfs/file_handle'); -const { getValidatedPath } = require('internal/fs/utils'); +const { + constants: { kWriteFileMaxChunkSize }, + getOptions, + getValidatedPath, +} = require('internal/fs/utils'); +const { isIterable } = require('internal/streams/utils'); +const { isArrayBufferView } = require('internal/util/types'); const { setOwnProperty } = require('internal/util'); +const { parseFileMode, validateBoolean } = require('internal/validators'); const { - ERR_METHOD_NOT_IMPLEMENTED, -} = require('internal/errors').codes; + AbortError, + codes: { + ERR_METHOD_NOT_IMPLEMENTED, + }, +} = require('internal/errors'); const { createEACCES, createEBADF, @@ -24,6 +35,16 @@ const { const kReadFileUnknownBufferLength = 8192; +function isCustomIterable(obj) { + return isIterable(obj) && !isArrayBufferView(obj) && typeof obj !== 'string'; +} + +function checkAborted(signal) { + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } +} + /** * A file handle that wraps a real file descriptor. */ @@ -33,7 +54,6 @@ const kReadFileUnknownBufferLength = 8192; // sync-opened handles can still share one underlying handle for async ops. class RealFileHandle extends VirtualFileHandle { #fd; - #realPath; #checkClosed(syscall) { if (this.closed) { @@ -60,12 +80,10 @@ class RealFileHandle extends VirtualFileHandle { * @param {string} flags The open flags * @param {number} mode The file mode * @param {number} fd The real file descriptor - * @param {string} realPath The real filesystem path */ - constructor(path, flags, mode, fd, realPath) { + constructor(path, flags, mode, fd) { super(path, flags, mode); this.#fd = fd; - this.#realPath = realPath; } readSync(buffer, offset, length, position) { @@ -175,12 +193,72 @@ class RealFileHandle extends VirtualFileHandle { writeFileSync(data, options) { this.#checkClosed('write'); - fs.writeFileSync(this.#realPath, data, options); + fs.writeFileSync(this.#fd, data, options); + } + + // Writes the whole buffer at the descriptor's current position, at most + // kWriteFileMaxChunkSize per call, the way writeFileHandle() does. + async #writeAll(buffer, signal) { + let written = 0; + while (written < buffer.byteLength) { + checkAborted(signal); + const { bytesWritten } = await this.write( + buffer, + written, + MathMin(kWriteFileMaxChunkSize, buffer.byteLength - written), + null); + written += bytesWritten; + } + } + + #fsync() { + this.#checkClosed('fsync'); + return new Promise((resolve, reject) => { + fs.fsync(this.#fd, (err) => { + if (err) reject(err); + else resolve(); + }); + }); } async writeFile(data, options) { this.#checkClosed('write'); - return fs.promises.writeFile(this.#realPath, data, options); + if (!isCustomIterable(data)) { + return new Promise((resolve, reject) => { + fs.writeFile(this.#fd, data, options, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + // The chunks are written one at a time through the descriptor, the way + // writeFileHandle() does. + // `flush` is not part of that: `filehandle.writeFile()` ignores it, but + // reopening the path used to fsync once at the end, so that is kept here + // instead of being multiplied by the number of chunks. + const opts = getOptions(options, { + encoding: 'utf8', + mode: 0o666, + flush: false, + }); + const flush = opts.flush ?? false; + validateBoolean(flush, 'options.flush'); + parseFileMode(opts.mode, 'mode', 0o666); + // An already aborted signal must not end up waiting on a source that + // never yields, so it is read before the first next(). + checkAborted(opts.signal); + + const encoding = opts.encoding || 'utf8'; + for await (const chunk of data) { + await this.#writeAll( + isArrayBufferView(chunk) ? chunk : Buffer.from(chunk, encoding), + opts.signal); + // An abort that arrived while the write was in flight must surface + // before the source is asked for another chunk. + checkAborted(opts.signal); + } + if (flush) await this.#fsync(); } statSync(options) { @@ -335,7 +413,7 @@ class RealFSProvider extends VirtualProvider { openSync(vfsPath, flags, mode) { const realPath = this.#resolvePath(vfsPath); const fd = fs.openSync(realPath, flags, mode); - return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath); + return new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd); } async open(vfsPath, flags, mode) { @@ -343,7 +421,7 @@ class RealFSProvider extends VirtualProvider { return new Promise((resolve, reject) => { fs.open(realPath, flags, mode, (err, fd) => { if (err) reject(err); - else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd, realPath)); + else resolve(new RealFileHandle(vfsPath, flags, mode ?? 0o644, fd)); }); }); } diff --git a/test/parallel/test-vfs-real-provider-handle.js b/test/parallel/test-vfs-real-provider-handle.js index 50d31470b864..e7b5465a594d 100644 --- a/test/parallel/test-vfs-real-provider-handle.js +++ b/test/parallel/test-vfs-real-provider-handle.js @@ -34,12 +34,134 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); assert.strictEqual(handle.statSync().isFile(), true); assert.strictEqual(handle.readFileSync('utf8'), 'zzllo world'); + // Like `filehandle.writeFile()`, this writes from the handle's current + // position rather than replacing the file, so a shorter write over an + // "r+" handle leaves the tail of the old content in place. handle.writeFileSync('replaced'); - assert.strictEqual(handle.readFileSync('utf8'), 'replaced'); + assert.strictEqual(handle.readFileSync('utf8'), 'replacedrld'); myVfs.closeSync(fd); } + // ===== writeFile goes through the file description, not the path ===== + { + fs.writeFileSync(path.join(root, 'renamed-away.txt'), 'aaaaaa'); + const handle = await myVfs.provider.open('/renamed-away.txt', 'r+'); + fs.renameSync(path.join(root, 'renamed-away.txt'), + path.join(root, 'renamed-to.txt')); + + handle.writeFileSync('bb'); + await handle.writeFile('cc'); + await handle.close(); + + assert.strictEqual( + fs.readFileSync(path.join(root, 'renamed-to.txt'), 'utf8'), 'bbccaa'); + assert.strictEqual(fs.existsSync(path.join(root, 'renamed-away.txt')), + false); + } + + // ===== writeFile takes the iterables filehandle.writeFile() takes ===== + { + const handle = await myVfs.provider.open('/iterable.txt', 'w'); + await handle.writeFile(['one ', 'two ']); + await handle.writeFile(async function* () { + yield 'three '; + yield Buffer.from('four'); + }()); + // A chunk that is not a view is converted the way writeFileHandle() does. + await handle.writeFile([[32, 65], Uint8Array.of(66).buffer]); + await handle.close(); + + assert.strictEqual( + fs.readFileSync(path.join(root, 'iterable.txt'), 'utf8'), + 'one two three four AB'); + } + + // ===== options are validated before the source is consumed ===== + { + const handle = await myVfs.provider.open('/opts.txt', 'w'); + + // The signal is read before the first next(), so a source that never + // yields cannot leave the write pending. + const neverYields = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }), + }; + await assert.rejects( + handle.writeFile(neverYields, { signal: AbortSignal.abort() }), + { name: 'AbortError' }); + + // The rest of `options` is validated there too, so a bad value is + // reported instead of waiting on a source that never produces. + await assert.rejects(handle.writeFile(neverYields, { mode: 'invalid' }), + { code: 'ERR_INVALID_ARG_VALUE' }); + + await handle.close(); + } + + // ===== flush costs one fsync per call, not one per chunk ===== + { + const originalFsync = fs.fsync; + let fsyncs = 0; + fs.fsync = function fsync(...args) { + fsyncs++; + return originalFsync.apply(this, args); + }; + + try { + const handle = await myVfs.provider.open('/flushed.txt', 'w'); + await handle.writeFile(['a', 'b', 'c'], { flush: true }); + await handle.close(); + assert.strictEqual(fsyncs, 1); + assert.strictEqual( + fs.readFileSync(path.join(root, 'flushed.txt'), 'utf8'), 'abc'); + + } finally { + fs.fsync = originalFsync; + } + } + + // ===== an abort landing during a write stops the source ===== + { + const handle = await myVfs.provider.open('/abort-mid.txt', 'w'); + const ac = new AbortController(); + const originalWrite = fs.write; + // Abort as the write settles, which is the window the post-write check + // covers. Without it a source of one chunk resolves successfully. + fs.write = function write(fd, buf, off, len, pos, callback) { + return originalWrite.call(this, fd, buf, off, len, pos, (err, n) => { + ac.abort(); + callback(err, n); + }); + }; + + let pulled = 0; + try { + await assert.rejects(handle.writeFile(async function* () { + pulled++; + yield 'first'; + pulled++; + yield 'second'; + }(), { signal: ac.signal }), { name: 'AbortError' }); + } finally { + fs.write = originalWrite; + await handle.close(); + } + assert.strictEqual(pulled, 1); // The source was not asked for more + } + + // ===== writeFile on a handle that was not opened for writing ===== + { + fs.writeFileSync(path.join(root, 'ronly.txt'), 'untouched'); + const handle = await myVfs.provider.open('/ronly.txt', 'r'); + + assert.throws(() => handle.writeFileSync('x'), { code: 'EBADF' }); + await assert.rejects(handle.writeFile('x'), { code: 'EBADF' }); + await handle.close(); + + assert.strictEqual( + fs.readFileSync(path.join(root, 'ronly.txt'), 'utf8'), 'untouched'); + } + // ===== Async read/write/stat/truncate via provider.open ===== { await myVfs.promises.writeFile('/h2.txt', 'abcdef'); @@ -64,10 +186,13 @@ const myVfs = vfs.create(new vfs.RealFSProvider(root)); assert.ok(handle.readFileSync().length > 0); assert.ok((await handle.readFile()).length > 0); + // Each write starts where the previous one left the handle, so the + // second call appends rather than replacing what the first one wrote. handle.writeFileSync('OVERWRITTEN'); assert.strictEqual(handle.readFileSync('utf8'), 'OVERWRITTEN'); await handle.writeFile('async-overwrite'); - assert.strictEqual(await handle.readFile('utf8'), 'async-overwrite'); + assert.strictEqual(await handle.readFile('utf8'), + 'OVERWRITTENasync-overwrite'); handle.truncateSync(3); await handle.truncate(2);