diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts index 7aee5bacb4..d3015865f6 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts @@ -5,7 +5,12 @@ * @module extensions/ext-bundler-esbuild/esbuild-bundler.test */ -import { assertEquals, assertExists, assertRejects, assertStringIncludes } from "@std/assert"; +import { + assertEquals, + assertExists, + assertRejects, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { createRequire } from "node:module"; import type { BuildContext } from "veryfront/extensions/bundler"; @@ -1479,6 +1484,68 @@ describe("EsbuildBundler service crash recovery", () => { } }); + it("allows the final budgeted stop loss before the child close event clears tracking", async () => { + const observation = observeEsbuildServices(); + const { services } = observation; + const bundler = new EsbuildBundler(); + let finalStop: Promise | undefined; + const finalStopStarted = Promise.withResolvers(); + + try { + await bundler.transform({ code: "export const seed: number = 0;", loader: "ts" }); + + for (let restart = 1; restart < MAX_SERVICE_RESTARTS; restart++) { + const current = services[services.length - 1]!; + current.child.ref(); + current.child.kill("SIGKILL"); + await current.close; + + await bundler.stop(); + const recovered = await bundler.transform({ + code: `export const stopReset${restart}: number = ${restart};`, + loader: "ts", + }); + assertStringIncludes(recovered.code, `stopReset${restart} = ${restart}`); + } + + const finalBudgeted = services[services.length - 1]!; + finalBudgeted.child.once("exit", () => { + finalStop = bundler.stop(); + finalStopStarted.resolve(); + }); + finalBudgeted.child.ref(); + finalBudgeted.child.kill("SIGKILL"); + + await finalStopStarted.promise; + await finalStop; + await finalBudgeted.close; + + const recovered = await bundler.transform({ + code: "export const afterFinalStopLoss: number = 3;", + loader: "ts", + }); + assertStringIncludes(recovered.code, "afterFinalStopLoss = 3"); + + const exhausted = services[services.length - 1]!; + exhausted.child.ref(); + exhausted.child.kill("SIGKILL"); + await exhausted.close; + + const stopError = await assertRejects(() => bundler.stop()); + assertStringIncludes((stopError as Error).message, "module-wide adapter"); + assertStringIncludes((stopError as Error).message, "exited unexpectedly"); + } finally { + await finalStop?.catch(() => undefined); + await bundler.stop().catch(() => undefined); + __resetServiceRecoveryForTests(); + try { + await bundler.stop(); + } finally { + observation.restore(); + } + } + }); + it("latches exhaustion when stop observes a closed lost service", async () => { const observation = observeEsbuildServices(); const { services } = observation; diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts index d666d7a736..b04773a7f1 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts @@ -816,8 +816,10 @@ export class EsbuildBundler implements Bundler { uninstallServiceLossSpawnGuard(); throw error; } + let chargedLostService = false; if (esbuildServiceLost) { remainingServiceRestarts -= 1; + chargedLostService = true; uninstallServiceLossSpawnGuard(); } @@ -825,7 +827,7 @@ export class EsbuildBundler implements Bundler { const trackedService = esbuildService; if ( trackedService && !trackedService.expectedClose && !isLiveService(trackedService) && - remainingServiceRestarts <= 0 + remainingServiceRestarts <= 0 && !chargedLostService ) { // A dead managed child within the restart budget is a crash, which a // stop resets anyway; only an exhausted budget still means giving up.