From 88a3c3f8a4bf5cb31f60bca4696431c4e4512b30 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:51:45 +0800 Subject: [PATCH] test(hopper): synchronize startup cancellation with launcher readiness (#662) --- .../hopperClient.startupFailures.test.ts | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts index a0d157e1c..54c8cc61f 100644 --- a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts @@ -73,12 +73,19 @@ class SilentLauncher implements BridgeLauncher { class CancelThenFixtureLauncher implements BridgeLauncher { #launches = 0; + #firstLaunchReady: ((process: ChildProcess) => void) | undefined; + readonly firstLaunch = new Promise((resolve) => { + this.#firstLaunchReady = resolve; + }); - launch(session: BridgeSession) { + async launch(session: BridgeSession) { this.#launches += 1; - return this.#launches === 1 + const launched = await (this.#launches === 1 ? new SilentLauncher().launch() - : new FixtureLauncher().launch(session); + : new FixtureLauncher().launch(session)); + if (this.#launches === 1 && launched.ok) + this.#firstLaunchReady?.(launched.value.process); + return launched; } } @@ -155,23 +162,28 @@ afterEach(async () => { describe("HopperClient startup failures", () => { it("cancels bridge startup without waiting for the startup timeout", async () => { + const launcher = new CancelThenFixtureLauncher(); const client = new HopperClient({ - launcher: new CancelThenFixtureLauncher(), + launcher, // Keep the retry proof independent from scheduler pressure when the // complete boundary project is starting several child-process fixtures. startupTimeoutMs: 30_000, }); clients.push(client); const controller = new AbortController(); - const startedAt = Date.now(); const pending = client.callTool("echo", {}, { signal: controller.signal }); - setTimeout(() => { - controller.abort(); - }, 10); + // Cancel after launch: a timer can fire during runtime-directory creation, + // leaving the silent first launch to be consumed by the retry instead. + const firstProcess = await launcher.firstLaunch; + const startedAt = Date.now(); + controller.abort(); const result = await pending; expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); expect(Date.now() - startedAt).toBeLessThan(500); + expect( + firstProcess.exitCode !== null || firstProcess.signalCode !== null, + ).toBe(true); await expect(client.start()).resolves.toEqual({ ok: true, value: { name: "REA Hopper bridge", version: "1.0.0" }, @@ -184,6 +196,33 @@ describe("HopperClient startup failures", () => { }); }); + it("retries after cancellation before the launcher starts", async () => { + const launcher = new FixtureLauncher(); + const client = new HopperClient({ launcher, startupTimeoutMs: 30_000 }); + clients.push(client); + const controller = new AbortController(); + + const pending = client.callTool("echo", {}, { signal: controller.signal }); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { _tag: "HopperCancelledError" }, + }); + expect(launcher.processes).toHaveLength(0); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + expect(launcher.processes).toHaveLength(1); + await expect( + client.callTool("echo", { value: "retried" }), + ).resolves.toEqual({ + ok: true, + value: { value: "retried" }, + }); + }); + it("applies one deadline to launcher, socket, and health startup phases", async () => { const launcher = new LateSilentLauncher(); const client = new HopperClient({ launcher, startupTimeoutMs: 500 });