From 79163baaf93697505aaa0926d6466e0d0156761c Mon Sep 17 00:00:00 2001 From: Tobias Koppers <tobias.koppers@googlemail.com> Date: Fri, 12 Nov 2021 12:30:42 +0100 Subject: [PATCH 1/2] use a faster readFile when file size is already in cache --- lib/CachedInputFileSystem.js | 232 ++++++++++++++++++++++++++++++++--- lib/Resolver.js | 12 +- types.d.ts | 44 ++++++- 3 files changed, 263 insertions(+), 25 deletions(-) diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index 74dbd982..d5347019 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -47,44 +47,56 @@ class OperationMergerBackend { * @param {any} providerContext call context for the provider methods */ constructor(provider, syncProvider, providerContext) { - this._provider = provider; - this._syncProvider = syncProvider; - this._providerContext = providerContext; - this._activeAsyncOperations = new Map(); + const activeAsyncOperations = new Map(); - this.provide = this._provider + this.provide = provider ? (path, options, callback) => { if (typeof options === "function") { callback = options; options = undefined; } if (options) { - return this._provider.call( - this._providerContext, - path, - options, - callback - ); + return provider.call(providerContext, path, options, callback); } if (typeof path !== "string") { callback(new TypeError("path must be a string")); return; } - let callbacks = this._activeAsyncOperations.get(path); + let callbacks = activeAsyncOperations.get(path); if (callbacks) { callbacks.push(callback); return; } - this._activeAsyncOperations.set(path, (callbacks = [callback])); - provider(path, (err, result) => { - this._activeAsyncOperations.delete(path); + activeAsyncOperations.set(path, (callbacks = [callback])); + provider.call(providerContext, path, (err, result) => { + activeAsyncOperations.delete(path); runCallbacks(callbacks, err, result); }); } : null; - this.provideSync = this._syncProvider + this.provideSync = syncProvider ? (path, options) => { - return this._syncProvider.call(this._providerContext, path, options); + return syncProvider.call(providerContext, path, options); + } + : null; + + this.provideCustom = provider + ? (provider, providerContext, path, callback) => { + let callbacks = activeAsyncOperations.get(path); + if (callbacks) { + callbacks.push(callback); + return; + } + activeAsyncOperations.set(path, (callbacks = [callback])); + provider.call(providerContext, path, (err, result) => { + activeAsyncOperations.delete(path); + runCallbacks(callbacks, err, result); + }); + } + : null; + this.provideCustomSync = syncProvider + ? (syncProvider, providerContext, path) => { + return syncProvider.call(providerContext, path); } : null; } @@ -149,6 +161,11 @@ class CacheBackend { this.provide = provider ? this.provide.bind(this) : null; this.provideSync = syncProvider ? this.provideSync.bind(this) : null; + + this.provideCustom = provider ? this.provideCustom.bind(this) : null; + this.provideCustomSync = syncProvider + ? this.provideCustomSync.bind(this) + : null; } provide(path, options, callback) { @@ -243,6 +260,74 @@ class CacheBackend { return result; } + provideCustom(provider, providerContext, path, callback) { + // When in sync mode we can move to async mode + if (this._mode === STORAGE_MODE_SYNC) { + this._enterAsyncMode(); + } + + // Check in cache + let cacheEntry = this._data.get(path); + if (cacheEntry !== undefined) { + if (cacheEntry.err) return nextTick(callback, cacheEntry.err); + return nextTick(callback, null, cacheEntry.result); + } + + // Check if there is already the same operation running + let callbacks = this._activeAsyncOperations.get(path); + if (callbacks !== undefined) { + callbacks.push(callback); + return; + } + this._activeAsyncOperations.set(path, (callbacks = [callback])); + + // Run the operation + provider.call(providerContext, path, (err, result) => { + this._activeAsyncOperations.delete(path); + this._storeResult(path, err, result); + + // Enter async mode if not yet done + this._enterAsyncMode(); + + runCallbacks(callbacks, err, result); + }); + } + + provideCustomSync(provider, providerContext, path) { + // In sync mode we may have to decay some cache items + if (this._mode === STORAGE_MODE_SYNC) { + this._runDecays(); + } + + // Check in cache + let cacheEntry = this._data.get(path); + if (cacheEntry !== undefined) { + if (cacheEntry.err) throw cacheEntry.err; + return cacheEntry.result; + } + + // Get all active async operations + // This sync operation will also complete them + const callbacks = this._activeAsyncOperations.get(path); + this._activeAsyncOperations.delete(path); + + // Run the operation + // When in idle mode, we will enter sync mode + let result; + try { + result = provider.call(providerContext, path); + } catch (err) { + this._storeResult(path, err, undefined); + this._enterSyncModeWhenIdle(); + if (callbacks) runCallbacks(callbacks, err, undefined); + throw err; + } + this._storeResult(path, undefined, result); + this._enterSyncModeWhenIdle(); + if (callbacks) runCallbacks(callbacks, undefined, result); + return result; + } + purge(what) { if (!what) { if (this._mode !== STORAGE_MODE_IDLE) { @@ -395,8 +480,10 @@ module.exports = class CachedInputFileSystem { this.fileSystem ); const stat = this._statBackend.provide; + const customStat = this._statBackend.provideCustom; this.stat = /** @type {FileSystem["stat"]} */ (stat); const statSync = this._statBackend.provideSync; + const customStatSync = this._statBackend.provideCustomSync; this.statSync = /** @type {SyncFileSystem["statSync"]} */ (statSync); this._readdirBackend = createBackend( @@ -412,8 +499,115 @@ module.exports = class CachedInputFileSystem { this._readFileBackend = createBackend( duration, - this.fileSystem.readFile, - this.fileSystem.readFileSync, + this.fileSystem.readFile && + this.fileSystem.fstat && + this.fileSystem.read && + this.fileSystem.open && + this.fileSystem.close && + customStat + ? /** + * @this {{ fstat: NonNullable<FileSystem["fstat"]>, readFile: NonNullable<FileSystem["readFile"]>, open: NonNullable<FileSystem["open"]>, read: NonNullable<FileSystem["read"]>, close: NonNullable<FileSystem["close"]> }} + */ + function (path, options, callback) { + if (typeof options === "function") { + callback = options; + options = undefined; + } + if (typeof options === "object") + return this.readFile(path, options, callback); + this.open(path, "r", (err, fd) => { + if (err) return callback(err); + if (typeof fd !== "number") + return callback(new Error("fd must be a number")); + customStat( + (path, callback) => this.fstat(fd, callback), + null, + path, + (err, stats) => { + if (err) return callback(err); + if (stats.size > 0 && stats.size < 128 * 1024) { + let remaining = stats.size; + const buffer = Buffer.allocUnsafe(remaining); + const afterRead = (err, bytesRead) => { + if (err) { + return this.close(fd, () => { + callback(err); + }); + } + remaining -= bytesRead; + if (bytesRead === 0 || remaining === 0) { + this.close(fd, err => { + if (err) return callback(err); + return callback(null, buffer); + }); + } else { + this.read( + fd, + buffer, + stats.size - remaining, + remaining, + -1, + afterRead + ); + } + }; + this.read(fd, buffer, 0, remaining, -1, afterRead); + } else { + this.readFile(fd, options, (err, buffer) => { + this.close(fd, closeErr => { + if (err) return callback(err); + if (closeErr) return callback(closeErr); + callback(null, buffer); + }); + }); + } + } + ); + }); + } + : this.fileSystem.readFile, + this.fileSystem.readFileSync && + this.fileSystem.fstatSync && + this.fileSystem.readSync && + this.fileSystem.openSync && + this.fileSystem.closeSync && + customStatSync + ? /** + * @this {{ fstatSync: NonNullable<SyncFileSystem["fstatSync"]>, readFileSync: NonNullable<SyncFileSystem["readFileSync"]>, openSync: NonNullable<SyncFileSystem["openSync"]>, readSync: NonNullable<SyncFileSystem["readSync"]>, closeSync: NonNullable<SyncFileSystem["closeSync"]> }} + */ + function (path, options) { + if (typeof options === "object") + return this.readFileSync(path, options); + const fd = this.openSync(path, "r"); + if (typeof fd !== "number") throw new Error("fd must be a number"); + const stats = customStatSync(() => this.fstatSync(fd), null, path); + if (stats.size > 0 && stats.size < 128 * 1024) { + let remaining = stats.size; + const buffer = Buffer.allocUnsafe(remaining); + try { + let bytesRead = this.readSync(fd, buffer, 0, remaining, -1); + remaining -= bytesRead; + while (bytesRead !== 0 && remaining !== 0) { + bytesRead = this.readSync( + fd, + buffer, + stats.size - remaining, + remaining, + -1 + ); + remaining -= bytesRead; + } + return buffer; + } finally { + this.closeSync(fd); + } + } else { + const buffer = this.readFileSync(fd); + this.closeSync(fd); + return buffer; + } + } + : this.fileSystem.readFileSync, this.fileSystem ); const readFile = this._readFileBackend.provide; diff --git a/lib/Resolver.js b/lib/Resolver.js index a3120081..610a3329 100644 --- a/lib/Resolver.js +++ b/lib/Resolver.js @@ -47,7 +47,11 @@ const { /** * @typedef {Object} FileSystem - * @property {(function(string, FileSystemCallback<Buffer | string>): void) & function(string, object, FileSystemCallback<Buffer | string>): void} readFile + * @property {(function(string | number, FileSystemCallback<Buffer | string>): void) & function(string | number, object, FileSystemCallback<Buffer | string>): void} readFile + * @property {(function(string, string | number, FileSystemCallback<number>): void) & function(string, string | number, string | number, FileSystemCallback<number>): void=} open + * @property {(function(number, FileSystemCallback<FileSystemStats>): void) & function(number, object, FileSystemCallback<Buffer | string>): void=} fstat + * @property {function(number, Buffer | Uint8Array, number, number, number, function(PossibleFileSystemError & Error | null | undefined, number=): void): void=} read + * @property {function(number, function(PossibleFileSystemError & Error | null | undefined): void): void=} close * @property {(function(string, FileSystemCallback<(Buffer | string)[] | FileSystemDirent[]>): void) & function(string, object, FileSystemCallback<(Buffer | string)[] | FileSystemDirent[]>): void} readdir * @property {((function(string, FileSystemCallback<object>): void) & function(string, object, FileSystemCallback<object>): void)=} readJson * @property {(function(string, FileSystemCallback<Buffer | string>): void) & function(string, object, FileSystemCallback<Buffer | string>): void} readlink @@ -57,7 +61,11 @@ const { /** * @typedef {Object} SyncFileSystem - * @property {function(string, object=): Buffer | string} readFileSync + * @property {function(string | number, object=): Buffer | string} readFileSync + * @property {function(string, string | number, string | number=): number=} openSync + * @property {function(number, object=): FileSystemStats=} fstatSync + * @property {function(number, Buffer | Uint8Array, number, number, number): number=} readSync + * @property {function(number): void=} closeSync * @property {function(string, object=): (Buffer | string)[] | FileSystemDirent[]} readdirSync * @property {(function(string, object=): object)=} readJsonSync * @property {function(string, object=): Buffer | string} readlinkSync diff --git a/types.d.ts b/types.d.ts index f392bd1c..0212a5c3 100644 --- a/types.d.ts +++ b/types.d.ts @@ -61,14 +61,14 @@ declare class CachedInputFileSystem { arg1?: object ) => (string | Buffer)[] | FileSystemDirent[]; readFile: { - (arg0: string, arg1: FileSystemCallback<string | Buffer>): void; + (arg0: string | number, arg1: FileSystemCallback<string | Buffer>): void; ( - arg0: string, + arg0: string | number, arg1: object, arg2: FileSystemCallback<string | Buffer> ): void; }; - readFileSync: (arg0: string, arg1?: object) => string | Buffer; + readFileSync: (arg0: string | number, arg1?: object) => string | Buffer; readJson?: { (arg0: string, arg1: FileSystemCallback<object>): void; (arg0: string, arg1: object, arg2: FileSystemCallback<object>): void; @@ -93,13 +93,49 @@ declare class CloneBasenamePlugin { } declare interface FileSystem { readFile: { - (arg0: string, arg1: FileSystemCallback<string | Buffer>): void; + (arg0: string | number, arg1: FileSystemCallback<string | Buffer>): void; + ( + arg0: string | number, + arg1: object, + arg2: FileSystemCallback<string | Buffer> + ): void; + }; + open?: { ( arg0: string, + arg1: string | number, + arg2: FileSystemCallback<number> + ): void; + ( + arg0: string, + arg1: string | number, + arg2: string | number, + arg3: FileSystemCallback<number> + ): void; + }; + fstat?: { + (arg0: number, arg1: FileSystemCallback<FileSystemStats>): void; + ( + arg0: number, arg1: object, arg2: FileSystemCallback<string | Buffer> ): void; }; + read?: ( + arg0: number, + arg1: Buffer | Uint8Array, + arg2: number, + arg3: number, + arg4: number, + arg5: ( + arg0?: null | (PossibleFileSystemError & Error), + arg1?: number + ) => void + ) => void; + close?: ( + arg0: number, + arg1: (arg0?: null | (PossibleFileSystemError & Error)) => void + ) => void; readdir: { ( arg0: string, From a7c161eeb141bfc7fda375df36c13df006a8cf76 Mon Sep 17 00:00:00 2001 From: Tobias Koppers <tobias.koppers@googlemail.com> Date: Mon, 15 Nov 2021 11:17:09 +0100 Subject: [PATCH 2/2] add detection logic for race conditions due to cached file size --- lib/CachedInputFileSystem.js | 54 ++++++++++++++++++++++++++++++++--- test/CachedInputFileSystem.js | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/lib/CachedInputFileSystem.js b/lib/CachedInputFileSystem.js index d5347019..b1573e93 100644 --- a/lib/CachedInputFileSystem.js +++ b/lib/CachedInputFileSystem.js @@ -526,7 +526,7 @@ module.exports = class CachedInputFileSystem { (err, stats) => { if (err) return callback(err); if (stats.size > 0 && stats.size < 128 * 1024) { - let remaining = stats.size; + let remaining = stats.size + 1; const buffer = Buffer.allocUnsafe(remaining); const afterRead = (err, bytesRead) => { if (err) { @@ -535,17 +535,63 @@ module.exports = class CachedInputFileSystem { }); } remaining -= bytesRead; - if (bytesRead === 0 || remaining === 0) { + if (bytesRead === 0 || remaining === 1) { this.close(fd, err => { if (err) return callback(err); - return callback(null, buffer); + return callback( + null, + buffer.slice(0, buffer.length - remaining) + ); }); + } else if (remaining === 0) { + // The file size has changed from the cached info + // We keep reading until the end is found + let buf = Buffer.allocUnsafe(16 * 1024); + let bufPos = 0; + const buffers = [buffer]; + const afterUnknownRead = (err, bytesRead) => { + if (err) { + return this.close(fd, () => { + callback(err); + }); + } + bufPos += bytesRead; + if (bytesRead === 0) { + if (bufPos > 0) buffers.push(buf.slice(0, bufPos)); + this.close(fd, err => { + if (err) return callback(err); + return callback(null, Buffer.concat(buffers)); + }); + } else { + if (bufPos === buf.length) { + buffers.push(buf); + buf = Buffer.allocUnsafe(16 * 1024); + bufPos = 0; + } + this.read( + fd, + buf, + bufPos, + buf.length - bufPos, + -1, + afterUnknownRead + ); + } + }; + this.read( + fd, + buf, + bufPos, + buf.length - bufPos, + -1, + afterUnknownRead + ); } else { this.read( fd, buffer, stats.size - remaining, - remaining, + remaining + 1, -1, afterRead ); diff --git a/test/CachedInputFileSystem.js b/test/CachedInputFileSystem.js index 21aea5aa..c0caf27f 100644 --- a/test/CachedInputFileSystem.js +++ b/test/CachedInputFileSystem.js @@ -364,3 +364,56 @@ describe("CachedInputFileSystem CacheBackend", function () { next(); }); }); + +describe("CachedInputFileSystem cached stats", function () { + this.timeout(3000); + it("should fallback to reading full file when size has changed", done => { + let filePos = 0; + const fs = new CachedInputFileSystem( + { + stat: (path, callback) => { + callback(null, { + size: 100 + }); + }, + readFile: (path, callback) => { + callback(new Error("shouldn't be used")); + }, + fstat: (fd, callback) => { + callback(new Error("shouldn't be used")); + }, + open: (path, flag, callback) => { + path.should.be.eql("/any"); + flag.should.be.eql("r"); + filePos = 0; + callback(null, 123); + }, + read: (fd, buffer, offset, length, position, callback) => { + fd.should.be.eql(123); + length = Math.min(length, 32123 - filePos); + filePos += length; + buffer.fill(1, offset, offset + length); + process.nextTick(callback, null, length); + }, + close: (fd, callback) => { + fd.should.be.eql(123); + callback(); + } + }, + 10000 + ); + fs.stat("/any", (err, stats) => { + if (err) return done(err); + fs.readFile("/any", (err, buffer) => { + if (err) return done(err); + if (!buffer || typeof buffer === "string") + return done(new Error("no buffer")); + buffer.length.should.be.eql(32123); + for (let i = 0; i < buffer.length; i++) { + buffer[i].should.be.eql(1); + } + done(); + }); + }); + }); +});