diff --git a/handwritten/storage/src/bucket.ts b/handwritten/storage/src/bucket.ts index 23aefac9e3fe..cddc5c240350 100644 --- a/handwritten/storage/src/bucket.ts +++ b/handwritten/storage/src/bucket.ts @@ -34,12 +34,15 @@ import {paginator} from '@google-cloud/paginator'; import {promisifyAll} from '@google-cloud/promisify'; import * as fs from 'fs'; import * as http from 'http'; -import mime from 'mime'; import * as path from 'path'; -import pLimit from 'p-limit'; import {promisify} from 'util'; import AsyncRetry from 'async-retry'; -import {convertObjKeysToSnakeCase, handleContextValidation} from './util.js'; +import { + convertObjKeysToSnakeCase, + handleContextValidation, + getMime, + getPLimit, +} from './util.js'; import {Acl, AclMetadata} from './acl.js'; import {Channel} from './channel.js'; @@ -1733,118 +1736,128 @@ class Bucket extends ServiceObject { const destinationFile = convertToFile(destination); callback = callback || util.noop; - if (!destinationFile.metadata.contentType) { - const destinationContentType = - mime.getType(destinationFile.name) || undefined; + void (async () => { + try { + if (!destinationFile.metadata.contentType) { + const mime = await getMime(); + const destinationContentType = + mime.getType(destinationFile.name) || undefined; - if (destinationContentType) { - destinationFile.metadata.contentType = destinationContentType; - } - } + if (destinationContentType) { + destinationFile.metadata.contentType = destinationContentType; + } + } - let maxRetries = this.storage.retryOptions.maxRetries; - if ( - (destinationFile?.instancePreconditionOpts?.ifGenerationMatch === - undefined && - options.ifGenerationMatch === undefined && - this.storage.retryOptions.idempotencyStrategy === - IdempotencyStrategy.RetryConditional) || - this.storage.retryOptions.idempotencyStrategy === - IdempotencyStrategy.RetryNever - ) { - maxRetries = 0; - } + let maxRetries = this.storage.retryOptions.maxRetries; + if ( + (destinationFile?.instancePreconditionOpts?.ifGenerationMatch === + undefined && + options.ifGenerationMatch === undefined && + this.storage.retryOptions.idempotencyStrategy === + IdempotencyStrategy.RetryConditional) || + this.storage.retryOptions.idempotencyStrategy === + IdempotencyStrategy.RetryNever + ) { + maxRetries = 0; + } - const deleteSourceObjects = options.deleteSourceObjects; + const deleteSourceObjects = options.deleteSourceObjects; - const requestQueryObject = Object.assign({}, options); - delete requestQueryObject.deleteSourceObjects; + const requestQueryObject = Object.assign({}, options); + delete requestQueryObject.deleteSourceObjects; - if (requestQueryObject.ifGenerationMatch === undefined) { - Object.assign( - requestQueryObject, - destinationFile.instancePreconditionOpts, - requestQueryObject - ); - } + if (requestQueryObject.ifGenerationMatch === undefined) { + Object.assign( + requestQueryObject, + destinationFile.instancePreconditionOpts, + requestQueryObject + ); + } - // Make the request from the destination File object. - destinationFile.request( - { - method: 'POST', - uri: '/compose', - maxRetries, - json: { - destination: { - contentType: destinationFile.metadata.contentType, - contentEncoding: destinationFile.metadata.contentEncoding, - contexts: - requestQueryObject.contexts || destinationFile.metadata.contexts, + // Make the request from the destination File object. + destinationFile.request( + { + method: 'POST', + uri: '/compose', + maxRetries, + json: { + destination: { + contentType: destinationFile.metadata.contentType, + contentEncoding: destinationFile.metadata.contentEncoding, + contexts: + requestQueryObject.contexts || + destinationFile.metadata.contexts, + }, + sourceObjects: (sources as File[]).map(source => { + const sourceObject = { + name: source.name, + } as SourceObject; + + const generation = + source.generation ?? source.metadata?.generation; + if (generation !== undefined) { + sourceObject.generation = parseInt(generation.toString()); + } + + return sourceObject; + }), + }, + qs: requestQueryObject, }, - sourceObjects: (sources as File[]).map(source => { - const sourceObject = { - name: source.name, - } as SourceObject; - - const generation = source.generation ?? source.metadata?.generation; - if (generation !== undefined) { - sourceObject.generation = parseInt(generation.toString()); + (err, resp) => { + this.storage.retryOptions.autoRetry = this.instanceRetryValue; + if (err) { + callback!(err, null, resp); + return; } - return sourceObject; - }), - }, - qs: requestQueryObject, - }, - (err, resp) => { - this.storage.retryOptions.autoRetry = this.instanceRetryValue; - if (err) { - callback!(err, null, resp); - return; - } - - if (deleteSourceObjects) { - const deletePromises = (sources as File[]).map(source => { - const deleteOptions: DeleteOptions = { - ignoreNotFound: true, - userProject: options.userProject, - }; + if (deleteSourceObjects) { + const deletePromises = (sources as File[]).map(source => { + const deleteOptions: DeleteOptions = { + ignoreNotFound: true, + userProject: options.userProject, + }; + + const generation = + source.generation ?? source.metadata?.generation; + if (generation !== undefined) { + deleteOptions.ifGenerationMatch = generation; + } - const generation = source.generation ?? source.metadata?.generation; - if (generation !== undefined) { - deleteOptions.ifGenerationMatch = generation; - } + return source + .delete(deleteOptions) + .catch(deleteErr => deleteErr as Error); + }); - return source - .delete(deleteOptions) - .catch(deleteErr => deleteErr as Error); - }); + void (async () => { + // eslint-disable-next-line promise/no-promise-in-callback + const results = await Promise.all(deletePromises); + const errors = results.filter( + (res): res is Error => res instanceof Error + ); + + if (errors.length > 0) { + const cleanupErr = new ComposeCleanupError( + `Compose operation succeeded, but cleaning up source objects failed. Failed to delete ${errors.length} source object(s).`, + errors, + destinationFile, + resp + ); + callback!(cleanupErr, destinationFile, resp); + return; + } - void (async () => { - // eslint-disable-next-line promise/no-promise-in-callback - const results = await Promise.all(deletePromises); - const errors = results.filter( - (res): res is Error => res instanceof Error - ); - - if (errors.length > 0) { - const cleanupErr = new ComposeCleanupError( - `Compose operation succeeded, but cleaning up source objects failed. Failed to delete ${errors.length} source object(s).`, - errors, - destinationFile, - resp - ); - callback!(cleanupErr, destinationFile, resp); - return; + callback!(null, destinationFile, resp); + })(); + } else { + callback!(null, destinationFile, resp); } - - callback!(null, destinationFile, resp); - })(); - } else { - callback!(null, destinationFile, resp); - } + } + ); + } catch (err) { + callback!(err as Error, null, null); } - ); + })(); } createChannel( @@ -2288,6 +2301,7 @@ class Bucket extends ServiceObject { void (async () => { try { let promises = []; + const pLimit = await getPLimit(); const limit = pLimit(MAX_PARALLEL_LIMIT); const filesStream = this.getFilesStream(query); @@ -4721,6 +4735,7 @@ class Bucket extends ServiceObject { void (async () => { try { const [files] = await this.getFiles(options); + const pLimit = await getPLimit(); const limit = pLimit(MAX_PARALLEL_LIMIT); const promises = files.map(file => { return limit(() => processFile(file)); diff --git a/handwritten/storage/src/file.ts b/handwritten/storage/src/file.ts index c509ae3e60ff..977e3df4a3ca 100644 --- a/handwritten/storage/src/file.ts +++ b/handwritten/storage/src/file.ts @@ -26,7 +26,6 @@ import {promisifyAll} from '@google-cloud/promisify'; import * as crypto from 'crypto'; import * as fs from 'fs'; -import mime from 'mime'; import * as resumableUpload from './resumable-upload.js'; import {Writable, Readable, pipeline, Transform, PipelineSource} from 'stream'; import * as zlib from 'zlib'; @@ -62,6 +61,7 @@ import { formatAsUTCISO, PassThroughShim, handleContextValidation, + getMime, } from './util.js'; import {CRC32C, CRC32CValidatorGenerator} from './crc32c.js'; import {HashStreamValidator} from './hash-stream-validator.js'; @@ -2158,26 +2158,6 @@ class File extends ServiceObject { options!.metadata!.contentType = options.contentType; } - if ( - !options!.metadata!.contentType || - options!.metadata!.contentType === 'auto' - ) { - const detectedContentType = mime.getType(this.name); - if (detectedContentType) { - options!.metadata!.contentType = detectedContentType; - } - } - - let gzip = options.gzip; - - if (gzip === 'auto') { - gzip = COMPRESSIBLE_MIME_REGEX.test(options!.metadata!.contentType || ''); - } - - if (gzip) { - options!.metadata!.contentEncoding = 'gzip'; - } - let crc32c = true; let md5 = false; @@ -2237,36 +2217,12 @@ class File extends ServiceObject { emitStream.destroy(); }); - const transformStreams: Transform[] = []; - - if (gzip) { - transformStreams.push(zlib.createGzip()); - } - const emitStream = new PassThroughShim(); // If `writeStream` is destroyed before the `writing` event, `emitStream` will not have any listeners. This prevents an unhandled error. const noop = () => {}; emitStream.on('error', noop); - let hashCalculatingStream: HashStreamValidator | null = null; - - if (crc32c || md5) { - const crc32cInstance = options.resumeCRC32C - ? CRC32C.from(options.resumeCRC32C) - : undefined; - - hashCalculatingStream = new HashStreamValidator({ - crc32c, - crc32cInstance, - md5, - crc32cGenerator: this.crc32cGenerator, - updateHashesOnly: true, - }); - - transformStreams.push(hashCalculatingStream); - } - const fileWriteStream = duplexify(); let fileWriteStreamMetadataReceived = false; @@ -2280,96 +2236,151 @@ class File extends ServiceObject { fileWriteStreamMetadataReceived = true; }); - writeStream.once('writing', () => { - if (options.resumable === false) { - this.startSimpleUpload_(fileWriteStream, options); - } else { - this.startResumableUpload_(fileWriteStream, options); - } + writeStream.once('writing', async () => { + try { + if ( + !options!.metadata!.contentType || + options!.metadata!.contentType === 'auto' + ) { + const mime = await getMime(); + const detectedContentType = mime.getType(this.name); + if (detectedContentType) { + options!.metadata!.contentType = detectedContentType; + } + } - // remove temporary noop listener as we now create a pipeline that handles the errors - emitStream.removeListener('error', noop); + let gzip = options.gzip; - if (fileWriteStream.destroyed) { - let callbackCalled = false; - const onError = (err: Error) => { - if (!callbackCalled) { - callbackCalled = true; - pipelineCallback(err); - } - }; - fileWriteStream.once('error', onError); - emitStream.destroy(); - - process.nextTick(() => { - fileWriteStream.removeListener('error', onError); - if (!callbackCalled) { - callbackCalled = true; - const err = - (fileWriteStream as Writable & {errored?: Error}).errored || - new Error('Write stream destroyed'); - pipelineCallback(err); - } - }); - return; - } + if (gzip === 'auto') { + gzip = COMPRESSIBLE_MIME_REGEX.test( + options!.metadata!.contentType || '' + ); + } - pipeline( - emitStream, - ...(transformStreams as [Transform]), - fileWriteStream, - async e => { - if (e) { - return pipelineCallback(e); - } + if (gzip) { + options!.metadata!.contentEncoding = 'gzip'; + } - // If this is a partial upload, we don't expect final metadata yet. - if (options.isPartialUpload) { - // Emit CRC32c for this completed chunk if hash validation is active. - if (hashCalculatingStream?.crc32c) { - writeStream.emit('crc32c', hashCalculatingStream.crc32c); + const transformStreams: Transform[] = []; + + if (gzip) { + transformStreams.push(zlib.createGzip()); + } + + let hashCalculatingStream: HashStreamValidator | null = null; + + if (crc32c || md5) { + const crc32cInstance = options.resumeCRC32C + ? CRC32C.from(options.resumeCRC32C) + : undefined; + + hashCalculatingStream = new HashStreamValidator({ + crc32c, + crc32cInstance, + md5, + crc32cGenerator: this.crc32cGenerator, + updateHashesOnly: true, + }); + + transformStreams.push(hashCalculatingStream); + } + + if (options.resumable === false) { + this.startSimpleUpload_(fileWriteStream, options); + } else { + this.startResumableUpload_(fileWriteStream, options); + } + + // remove temporary noop listener as we now create a pipeline that handles the errors + emitStream.removeListener('error', noop); + + if (fileWriteStream.destroyed) { + let callbackCalled = false; + const onError = (err: Error) => { + if (!callbackCalled) { + callbackCalled = true; + pipelineCallback(err); } - // Resolve the pipeline for this *partial chunk*. - return pipelineCallback(); - } + }; + fileWriteStream.once('error', onError); + emitStream.destroy(); + + process.nextTick(() => { + fileWriteStream.removeListener('error', onError); + if (!callbackCalled) { + callbackCalled = true; + const err = + (fileWriteStream as Writable & {errored?: Error}).errored || + new Error('Write stream destroyed'); + pipelineCallback(err); + } + }); + return; + } - // We want to make sure we've received the metadata from the server in order - // to properly validate the object's integrity. Depending on the type of upload, - // the stream could close before the response is returned. - if (!fileWriteStreamMetadataReceived) { - try { - await new Promise((resolve, reject) => { - fileWriteStream.once('metadata', resolve); - fileWriteStream.once('error', reject); - }); - } catch (e) { - return pipelineCallback(e as Error); + pipeline( + emitStream, + ...(transformStreams as [Transform]), + fileWriteStream, + async e => { + if (e) { + return pipelineCallback(e); } - } - // Emit the local CRC32C value for future validation, if validation is enabled. - if (hashCalculatingStream?.crc32c) { - writeStream.emit('crc32c', hashCalculatingStream.crc32c); - } + // If this is a partial upload, we don't expect final metadata yet. + if (options.isPartialUpload) { + // Emit CRC32c for this completed chunk if hash validation is active. + if (hashCalculatingStream?.crc32c) { + writeStream.emit('crc32c', hashCalculatingStream.crc32c); + } + // Resolve the pipeline for this *partial chunk*. + return pipelineCallback(); + } - try { - // Metadata may not be ready if the upload is a partial upload, - // nothing to validate yet. - const metadataNotReady = options.isPartialUpload && !this.metadata; - - if (hashCalculatingStream && !metadataNotReady) { - await this.#validateIntegrity(hashCalculatingStream, { - crc32c, - md5, - }); + // We want to make sure we've received the metadata from the server in order + // to properly validate the object's integrity. Depending on the type of upload, + // the stream could close before the response is returned. + if (!fileWriteStreamMetadataReceived) { + try { + await new Promise((resolve, reject) => { + fileWriteStream.once('metadata', resolve); + fileWriteStream.once('error', reject); + }); + } catch (e) { + return pipelineCallback(e as Error); + } } - pipelineCallback(); - } catch (e) { - pipelineCallback(e as Error); + // Emit the local CRC32C value for future validation, if validation is enabled. + if (hashCalculatingStream?.crc32c) { + writeStream.emit('crc32c', hashCalculatingStream.crc32c); + } + + try { + // Metadata may not be ready if the upload is a partial upload, + // nothing to validate yet. + const metadataNotReady = + options.isPartialUpload && !this.metadata; + + if (hashCalculatingStream && !metadataNotReady) { + await this.#validateIntegrity(hashCalculatingStream, { + crc32c, + md5, + }); + } + + pipelineCallback(); + } catch (e) { + pipelineCallback(e as Error); + } } - } - ); + ); + } catch (err) { + emitStream.removeListener('error', noop); + emitStream.destroy(err as Error); + fileWriteStream.destroy(err as Error); + pipelineCallback(err as Error); + } }); return writeStream; diff --git a/handwritten/storage/src/transfer-manager.ts b/handwritten/storage/src/transfer-manager.ts index 1e04aa080852..0e4882d1da15 100644 --- a/handwritten/storage/src/transfer-manager.ts +++ b/handwritten/storage/src/transfer-manager.ts @@ -24,7 +24,6 @@ import { RequestError, SkipReason, } from './file.js'; -import pLimit from 'p-limit'; import * as path from 'path'; import {createReadStream, existsSync, promises as fsp} from 'fs'; import {CRC32C} from './crc32c.js'; @@ -35,7 +34,11 @@ import {ApiError} from './nodejs-common/index.js'; import {GaxiosResponse, Headers} from 'gaxios'; import {createHash} from 'crypto'; import {GCCL_GCS_CMD_KEY} from './nodejs-common/util.js'; -import {getRuntimeTrackingString, getUserAgentString} from './util.js'; +import { + getRuntimeTrackingString, + getUserAgentString, + getPLimit, +} from './util.js'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import {getPackageJSON} from './package-json-helper.cjs'; @@ -477,6 +480,7 @@ export class TransferManager { }; } + const pLimit = await getPLimit(); const limit = pLimit( options.concurrencyLimit || DEFAULT_PARALLEL_UPLOAD_LIMIT ); @@ -604,6 +608,7 @@ export class TransferManager { filesOrFolder: File[] | string[] | string, options: DownloadManyFilesOptions = {} ): Promise { + const pLimit = await getPLimit(); const limit = pLimit( options.concurrencyLimit || DEFAULT_PARALLEL_DOWNLOAD_LIMIT ); @@ -777,6 +782,7 @@ export class TransferManager { fileOrName: File | string, options: DownloadFileInChunksOptions = {} ): Promise { + const pLimit = await getPLimit(); let chunkSize = options.chunkSizeBytes || DOWNLOAD_IN_CHUNKS_DEFAULT_CHUNK_SIZE; let limit = pLimit( @@ -904,6 +910,7 @@ export class TransferManager { options: UploadFileInChunksOptions = {}, generator: MultiPartHelperGenerator = defaultMultiPartGenerator ): Promise { + const pLimit = await getPLimit(); const chunkSize = options.chunkSizeBytes || UPLOAD_IN_CHUNKS_DEFAULT_CHUNK_SIZE; const limit = pLimit( diff --git a/handwritten/storage/src/util.ts b/handwritten/storage/src/util.ts index 19d6b9efb27e..486c1a5d587f 100644 --- a/handwritten/storage/src/util.ts +++ b/handwritten/storage/src/util.ts @@ -318,3 +318,66 @@ export function handleContextValidation( return Promise.reject(err); } } + +export interface Mime { + getType(path: string): string | null; + getExtension?(mime: string): string | null; + define?(typeMap: {[key: string]: string[]}, force?: boolean): void; +} + +export type Limit = import('p-limit').Limit; +export type PLimit = (concurrency: number) => Limit; + +let mimePromise: Promise | undefined; + +/** + * Lazily loads and returns the `mime` module instance. + * Caches the resolved module so dynamic import is evaluated only once. + * + * @internal + */ +export function getMime(): Promise { + if (!mimePromise) { + mimePromise = import('mime') + .then(mod => { + const modObj = mod as unknown as {default?: Mime} & Partial; + const mime: Mime = + modObj.default && typeof modObj.default.getType === 'function' + ? modObj.default + : (modObj as Mime); + return mime; + }) + .catch(err => { + mimePromise = undefined; + throw err; + }); + } + return mimePromise; +} + +let pLimitPromise: Promise | undefined; + +/** + * Lazily loads and returns the `p-limit` limiter function. + * Caches the resolved module so dynamic import is evaluated only once. + * + * @internal + */ +export function getPLimit(): Promise { + if (!pLimitPromise) { + pLimitPromise = import('p-limit') + .then(mod => { + const modObj = mod as unknown as {default?: PLimit}; + const pLimit: PLimit = + typeof mod === 'function' + ? (mod as PLimit) + : modObj.default || (modObj as PLimit); + return pLimit; + }) + .catch(err => { + pLimitPromise = undefined; + throw err; + }); + } + return pLimitPromise; +} diff --git a/handwritten/storage/test/bucket.ts b/handwritten/storage/test/bucket.ts index 531db6415888..2be7d402df64 100644 --- a/handwritten/storage/test/bucket.ts +++ b/handwritten/storage/test/bucket.ts @@ -227,13 +227,17 @@ describe('Bucket', () => { before(() => { const bucketModule = proxyquire('../src/bucket.js', { fs: fakeFs, - 'p-limit': fakePLimit, '@google-cloud/promisify': fakePromisify, '@google-cloud/paginator': fakePaginator, './nodejs-common': { ServiceObject: FakeServiceObject, util: fakeUtil, }, + './util.js': { + ...require('../src/util.js'), + getPLimit: async () => fakePLimit, + getMime: async () => mime, + }, './acl.js': {Acl: FakeAcl}, './file.js': {File: FakeFile}, './iam.js': {Iam: FakeIam}, diff --git a/handwritten/storage/test/file.ts b/handwritten/storage/test/file.ts index 434bbb472d82..938e8e317bba 100644 --- a/handwritten/storage/test/file.ts +++ b/handwritten/storage/test/file.ts @@ -2367,6 +2367,78 @@ describe('File', () => { writable.write('data'); }); + it('should not call getMime if contentType is provided', done => { + let getMimeCalled = false; + const FileWithStub = proxyquire('../src/file.js', { + './nodejs-common': { + ServiceObject: FakeServiceObject, + util: fakeUtil, + }, + '@google-cloud/promisify': fakePromisify, + fs: fakeFs, + '../src/resumable-upload': fakeResumableUpload, + os: fakeOs, + './signer': fakeSigner, + zlib: fakeZlib, + './util.js': { + ...require('../src/util.js'), + getMime: async () => { + getMimeCalled = true; + return {getType: () => 'image/png'}; + }, + }, + }).File; + + const f = new FileWithStub(STORAGE.bucket('test-bucket'), 'test.png'); + const writable = f.createWriteStream({contentType: 'text/plain'}); + f.startResumableUpload_ = ( + stream: {}, + options: {metadata: {contentType?: string}} + ) => { + assert.strictEqual(options.metadata.contentType, 'text/plain'); + assert.strictEqual(getMimeCalled, false); + done(); + }; + writable.write('data'); + }); + + it('should not call getMime if metadata.contentType is provided', done => { + let getMimeCalled = false; + const FileWithStub = proxyquire('../src/file.js', { + './nodejs-common': { + ServiceObject: FakeServiceObject, + util: fakeUtil, + }, + '@google-cloud/promisify': fakePromisify, + fs: fakeFs, + '../src/resumable-upload': fakeResumableUpload, + os: fakeOs, + './signer': fakeSigner, + zlib: fakeZlib, + './util.js': { + ...require('../src/util.js'), + getMime: async () => { + getMimeCalled = true; + return {getType: () => 'image/png'}; + }, + }, + }).File; + + const f = new FileWithStub(STORAGE.bucket('test-bucket'), 'test.png'); + const writable = f.createWriteStream({ + metadata: {contentType: 'application/json'}, + }); + f.startResumableUpload_ = ( + stream: {}, + options: {metadata: {contentType?: string}} + ) => { + assert.strictEqual(options.metadata.contentType, 'application/json'); + assert.strictEqual(getMimeCalled, false); + done(); + }; + writable.write('data'); + }); + it('should detect contentType with contentType:auto', done => { const writable = file.createWriteStream({contentType: 'auto'}); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/handwritten/storage/test/util.ts b/handwritten/storage/test/util.ts new file mode 100644 index 000000000000..2caff68bb651 --- /dev/null +++ b/handwritten/storage/test/util.ts @@ -0,0 +1,220 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import assert from 'assert'; +import {describe, it, afterEach} from 'mocha'; +import Module from 'module'; + +interface ModuleWithLoad { + _load: (this: unknown, request: string, ...args: unknown[]) => unknown; +} + +const moduleWithLoad = Module as unknown as ModuleWithLoad; + +function loadFreshUtil(): typeof import('../src/util.js') { + const utilPath = require.resolve('../src/util.js'); + delete require.cache[utilPath]; + return module.require(utilPath); +} + +describe('util lazy imports', () => { + const originalLoad = moduleWithLoad._load; + + afterEach(() => { + moduleWithLoad._load = originalLoad; + }); + + describe('getMime', () => { + describe('Promise caching', () => { + it('should return the identical promise instance for concurrent calls', async () => { + const util = loadFreshUtil(); + const p1 = util.getMime(); + const p2 = util.getMime(); + assert.strictEqual(p1, p2); + + const [mime1, mime2] = await Promise.all([p1, p2]); + assert.strictEqual(mime1, mime2); + }); + + it('should return the identical promise instance for sequential calls after resolution', async () => { + const util = loadFreshUtil(); + const p1 = util.getMime(); + const mime1 = await p1; + + const p2 = util.getMime(); + assert.strictEqual(p2, p1); + + const mime2 = await p2; + assert.strictEqual(mime2, mime1); + }); + + it('should resolve to a functional mime module with expected methods', async () => { + const util = loadFreshUtil(); + const mime = await util.getMime(); + + assert.ok(mime); + assert.strictEqual(typeof mime.getType, 'function'); + assert.strictEqual(mime.getType('file.txt'), 'text/plain'); + assert.strictEqual(mime.getType('photo.png'), 'image/png'); + assert.strictEqual(mime.getType('archive.zip'), 'application/zip'); + assert.strictEqual( + mime.getType('unknown.nonexistentextension123'), + null + ); + }); + }); + + describe('Error recovery', () => { + it('should reset the cached promise and allow recovery on subsequent call if import fails', async () => { + const util = loadFreshUtil(); + let shouldFail = true; + + moduleWithLoad._load = function ( + this: unknown, + request: string, + ...args: unknown[] + ) { + if (request === 'mime' && shouldFail) { + throw new Error('Simulated mime import failure'); + } + return originalLoad.call(this, request, ...args); + }; + + await assert.rejects( + async () => { + await util.getMime(); + }, + { + message: 'Simulated mime import failure', + } + ); + + // Verify recovery: after error is resolved, next call creates a new promise and succeeds + shouldFail = false; + const pRecovered = util.getMime(); + const mimeRecovered = await pRecovered; + + assert.ok(mimeRecovered); + assert.strictEqual(typeof mimeRecovered.getType, 'function'); + assert.strictEqual( + mimeRecovered.getType('test.json'), + 'application/json' + ); + + // Subsequent call should now cache and return the recovered promise + assert.strictEqual(util.getMime(), pRecovered); + }); + }); + }); + + describe('getPLimit', () => { + describe('Promise caching', () => { + it('should return the identical promise instance for concurrent calls', async () => { + const util = loadFreshUtil(); + const p1 = util.getPLimit(); + const p2 = util.getPLimit(); + assert.strictEqual(p1, p2); + + const [pLimit1, pLimit2] = await Promise.all([p1, p2]); + assert.strictEqual(pLimit1, pLimit2); + }); + + it('should return the identical promise instance for sequential calls after resolution', async () => { + const util = loadFreshUtil(); + const p1 = util.getPLimit(); + const pLimit1 = await p1; + + const p2 = util.getPLimit(); + assert.strictEqual(p2, p1); + + const pLimit2 = await p2; + assert.strictEqual(pLimit2, pLimit1); + }); + + it('should resolve to a functional p-limit function that throttles concurrency', async () => { + const util = loadFreshUtil(); + const pLimit = await util.getPLimit(); + + assert.ok(pLimit); + assert.strictEqual(typeof pLimit, 'function'); + + const concurrency = 2; + const limit = pLimit(concurrency); + let active = 0; + let maxActive = 0; + + const runTask = async (durationMs: number) => { + return limit(async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise(resolve => setTimeout(resolve, durationMs)); + active--; + }); + }; + + await Promise.all([ + runTask(20), + runTask(20), + runTask(20), + runTask(20), + runTask(20), + ]); + + assert.strictEqual(maxActive, concurrency); + }); + }); + + describe('Error recovery', () => { + it('should reset the cached promise and allow recovery on subsequent call if import fails', async () => { + const util = loadFreshUtil(); + let shouldFail = true; + + moduleWithLoad._load = function ( + this: unknown, + request: string, + ...args: unknown[] + ) { + if (request === 'p-limit' && shouldFail) { + throw new Error('Simulated p-limit import failure'); + } + return originalLoad.call(this, request, ...args); + }; + + await assert.rejects( + async () => { + await util.getPLimit(); + }, + { + message: 'Simulated p-limit import failure', + } + ); + + // Verify recovery: after error is resolved, next call creates a new promise and succeeds + shouldFail = false; + const pRecovered = util.getPLimit(); + const pLimitRecovered = await pRecovered; + + assert.ok(pLimitRecovered); + assert.strictEqual(typeof pLimitRecovered, 'function'); + + const limit = pLimitRecovered(1); + const result = await limit(() => Promise.resolve('recovered')); + assert.strictEqual(result, 'recovered'); + + // Subsequent call should now cache and return the recovered promise + assert.strictEqual(util.getPLimit(), pRecovered); + }); + }); + }); +});