diff --git a/packages/testcontainers/src/reaper/reaper.test.ts b/packages/testcontainers/src/reaper/reaper.test.ts index c90af337c..e022c7e76 100644 --- a/packages/testcontainers/src/reaper/reaper.test.ts +++ b/packages/testcontainers/src/reaper/reaper.test.ts @@ -53,6 +53,24 @@ describe.sequential("Reaper", { timeout: 120_000 }, () => { expect(reaper2.containerId).toBe(reaper.containerId); }); + it("should create new reaper container when existing reaper cannot be reached", async () => { + const reaper = await getReaper(); + vi.resetModules(); + const unreachablePort = await new RandomPortGenerator().generatePort(); + const reaperContainerInfo = (await client.container.list()).filter((c) => c.Id === reaper.containerId)[0]; + reaperContainerInfo.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] = "false"; + const reaperPort = reaperContainerInfo.Ports.find((port) => port.PrivatePort == 8080); + if (!reaperPort) { + throw new Error("Expected Reaper to map exposed port 8080"); + } + reaperPort.PublicPort = unreachablePort; + vi.spyOn(client.container, "list").mockResolvedValue([reaperContainerInfo]); + + const reaper2 = await getReaper(); + + expect(reaper2.containerId).not.toBe(reaper.containerId); + }); + it("should use custom port when TESTCONTAINERS_RYUK_PORT is set", async () => { const customPort = (await new RandomPortGenerator().generatePort()).toString(); vi.stubEnv("TESTCONTAINERS_RYUK_PORT", customPort); diff --git a/packages/testcontainers/src/reaper/reaper.ts b/packages/testcontainers/src/reaper/reaper.ts index e13ba495c..430043bc5 100644 --- a/packages/testcontainers/src/reaper/reaper.ts +++ b/packages/testcontainers/src/reaper/reaper.ts @@ -29,30 +29,44 @@ export async function getReaper(client: ContainerRuntimeClient): Promise } reaper = await withFileLock("testcontainers-node.lock", async () => { - const reaperContainer = await findReaperContainer(client); - sessionId = reaperContainer?.Labels[LABEL_TESTCONTAINERS_SESSION_ID] ?? new RandomUuid().nextUuid(); + const reaperContainers = await findReaperContainers(client); if (process.env.TESTCONTAINERS_RYUK_DISABLED === "true") { + sessionId = new RandomUuid().nextUuid(); return new DisabledReaper(sessionId, ""); - } else if (reaperContainer) { - return await useExistingReaper(reaperContainer, sessionId, client.info.containerRuntime.host); - } else { - return await createNewReaper(sessionId, client.info.containerRuntime.remoteSocketPath); } + + for (const reaperContainer of reaperContainers) { + const existingSessionId = reaperContainer.Labels[LABEL_TESTCONTAINERS_SESSION_ID] ?? new RandomUuid().nextUuid(); + try { + sessionId = existingSessionId; + return await useExistingReaper(reaperContainer, sessionId, client.info.containerRuntime.host); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.warn(`Failed to reuse existing Reaper: ${message}. Trying another Reaper...`, { + containerId: reaperContainer.Id, + }); + } + } + + sessionId = new RandomUuid().nextUuid(); + return await createNewReaper(sessionId, client.info.containerRuntime.remoteSocketPath); }); reaper.addSession(sessionId); return reaper; } -async function findReaperContainer(client: ContainerRuntimeClient): Promise { +async function findReaperContainers(client: ContainerRuntimeClient): Promise { const containers = await client.container.list(); - return containers.find( - (container) => - container.State === "running" && - container.Labels[LABEL_TESTCONTAINERS_RYUK] === "true" && - container.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] !== "true" - ); + return containers + .filter( + (container) => + container.State === "running" && + container.Labels[LABEL_TESTCONTAINERS_RYUK] === "true" && + container.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] !== "true" + ) + .sort((a, b) => b.Created - a.Created); } async function useExistingReaper(reaperContainer: ContainerInfo, sessionId: string, host: string): Promise {