diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts index d827d9e52b..8d89781d1b 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts @@ -88,6 +88,21 @@ describe("EsbuildBundler.transform", () => { await bundler.stop(); } }); + + it("coordinates concurrent transforms while the service is first captured", async () => { + const bundler = new EsbuildBundler(); + try { + const [first, second] = await Promise.all([ + bundler.transform({ code: "export const first: number = 1;", loader: "ts" }), + bundler.transform({ code: "export const second: number = 2;", loader: "ts" }), + ]); + + assertStringIncludes(first.code, "first"); + assertStringIncludes(second.code, "second"); + } finally { + await bundler.stop(); + } + }); }); describe("esbuild service lifecycle", () => { diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts index 1092a02dca..4b82b7bf90 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts @@ -54,6 +54,7 @@ interface MappedBundleOptions { let esbuildModule: EsbuildModule | null = null; let esbuildService: EsbuildService | null = null; +let esbuildServiceCapture: Promise | null = null; let esbuildOwnershipError: Error | null = null; let esbuildShutdownError: Error | null = null; let pluginDisposalError: Error | null = null; @@ -233,14 +234,33 @@ function isLiveService(service: EsbuildService): boolean { } function invokeEsbuild>(operation: () => T): T { + if (esbuildService && isLiveService(esbuildService)) return operation(); + + if (esbuildServiceCapture) { + return esbuildServiceCapture.then(() => invokeEsbuild(operation)) as T; + } + const originalSpawn = childProcess.spawn; let capturedService: EsbuildService | null = null; - let result: T; + const capture = Promise.withResolvers(); + esbuildServiceCapture = capture.promise; + void capture.promise.catch(() => undefined); + + const finishCapture = (error?: unknown): void => { + if (esbuildServiceCapture === capture.promise) esbuildServiceCapture = null; + if (error === undefined) capture.resolve(); + else capture.reject(error); + }; + + const restoreSpawn = (): void => { + if (childProcess.spawn === trackedSpawn) childProcess.spawn = originalSpawn; + }; // esbuild does not expose its service child, and stop() resolves before that - // child closes. esbuild 0.28 starts it synchronously with --service and - // --ping, so keep interception to this operation and restore the shared - // binding with compare-and-swap. + // child closes. In compiled Deno runtimes that spawn may happen after the + // transform call returns, so keep one capture window open until the promise + // settles. Other initial operations wait for that window instead of nesting + // global child_process.spawn interception. const trackedSpawn = ((...spawnArgs: unknown[]) => { const child = Reflect.apply(originalSpawn, childProcess, spawnArgs) as ChildProcess; if (isEsbuildServiceSpawn(spawnArgs)) { @@ -256,32 +276,40 @@ function invokeEsbuild>(operation: () => T): T { }); capturedService = service; esbuildService = service; - if (childProcess.spawn === trackedSpawn) childProcess.spawn = originalSpawn; + restoreSpawn(); + finishCapture(); } return child; }) as typeof childProcess.spawn; childProcess.spawn = trackedSpawn; + let result: T; try { result = operation(); - } finally { - if (childProcess.spawn === trackedSpawn) childProcess.spawn = originalSpawn; + } catch (error) { + restoreSpawn(); + finishCapture(error); + throw error; } - const ownedService = capturedService ?? esbuildService; - if (!ownedService || !isLiveService(ownedService)) { - const ownershipError = recordOwnershipError(); - return result.then( - () => { + return result.then( + (value) => { + restoreSpawn(); + const ownedService = capturedService ?? esbuildService; + if (!ownedService || !isLiveService(ownedService)) { + const ownershipError = recordOwnershipError(); + finishCapture(ownershipError); throw ownershipError; - }, - (cause) => { - throw recordOwnershipError(cause); - }, - ) as unknown as T; - } - - return result; + } + finishCapture(); + return value; + }, + (cause) => { + restoreSpawn(); + finishCapture(capturedService ? undefined : cause); + throw cause; + }, + ) as T; } async function waitForServiceClose(service: EsbuildService): Promise {