nodejs/node · #65885
vfs: write RealFSProvider files to open fd
lib/internal/vfs/providers/real.js89 + / 11 −
@@ -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,15 +413,15 @@ 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) { const realPath = this.#resolvePath(vfsPath); 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)); }); }); }test/parallel/test-vfs-real-provider-handle.js127 + / 2 −
@@ -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);