Skip to content

Commit b6a71db

Browse files
fix(reaper): only adopt reapers started by this binding
findReaperContainers matched any running Ryuk on the host, so on a CI host shared between testcontainers-node and another language binding (e.g. testcontainers-python), Node workers adopted the other binding's reaper every worker minted a fresh session id and asked the adopted reaper to watch a session it was never durably told about — leaking every container created under it. Narrow the adoption predicate: require org.testcontainers.lang === "node" (this library already labels everything it creates with it via createLabels()) and require a durable org.testcontainers.session-id label. A reaper whose session cannot be identified is left to the binding that owns it, and the node run starts its own reaper instead of silently losing reaping. Fixes #1442 Signed-off-by: kilisamemarisaaa <1798456934@qq.com> Co-Authored-By: EvoX <evox@evomap.ai>
1 parent 99ff0a2 commit b6a71db

4 files changed

Lines changed: 134 additions & 10 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { beforeAll, describe, expect, it } from "vitest";
2+
3+
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
4+
import { GenericContainer } from "../generic-container/generic-container";
5+
import { LABEL_TESTCONTAINERS_LANG, LABEL_TESTCONTAINERS_RYUK } from "../utils/labels";
6+
import { findReaperContainers } from "./reaper";
7+
8+
describe("Reaper discovery against a shared host", () => {
9+
let client: ContainerRuntimeClient;
10+
let dockerAvailable = true;
11+
12+
beforeAll(async () => {
13+
try {
14+
client = await getContainerRuntimeClient();
15+
await client.container.list();
16+
} catch {
17+
dockerAvailable = false;
18+
}
19+
});
20+
21+
it("does not adopt a reaper container started by another language binding (#1442)", async () => {
22+
if (!dockerAvailable) return;
23+
24+
const foreignReaper = await new GenericContainer("alpine:3.20")
25+
.withCommand(["sleep", "60"])
26+
.withLabels({
27+
[LABEL_TESTCONTAINERS_RYUK]: "true",
28+
[LABEL_TESTCONTAINERS_LANG]: "python",
29+
})
30+
.start();
31+
32+
try {
33+
const result = await findReaperContainers(client);
34+
expect(result.map((container) => container.Id)).not.toContain(foreignReaper.getId());
35+
} finally {
36+
await foreignReaper.stop({ timeout: 1000 });
37+
}
38+
});
39+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { ContainerInfo } from "dockerode";
4+
import { ContainerRuntimeClient } from "../container-runtime";
5+
import { LABEL_TESTCONTAINERS_LANG, LABEL_TESTCONTAINERS_RYUK, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
6+
import { findReaperContainers } from "./reaper";
7+
8+
function stubClient(containers: Partial<ContainerInfo>[]): ContainerRuntimeClient {
9+
return { container: { list: async () => containers as ContainerInfo[] } } as unknown as ContainerRuntimeClient;
10+
}
11+
12+
function reaperFixture(id: string, labels: Record<string, string>, state = "running"): Partial<ContainerInfo> {
13+
return {
14+
Id: id,
15+
State: state,
16+
Labels: {
17+
[LABEL_TESTCONTAINERS_RYUK]: "true",
18+
...labels,
19+
},
20+
Created: 123,
21+
};
22+
}
23+
24+
describe("findReaperContainers", () => {
25+
it("does not adopt reapers started by other language bindings (#1442)", async () => {
26+
const foreignReaper = reaperFixture("foreign", { [LABEL_TESTCONTAINERS_LANG]: "python" });
27+
const nodeReaper = reaperFixture("node", {
28+
[LABEL_TESTCONTAINERS_LANG]: "node",
29+
[LABEL_TESTCONTAINERS_SESSION_ID]: "0123456789ab",
30+
});
31+
32+
const result = await findReaperContainers(stubClient([foreignReaper, nodeReaper]));
33+
34+
expect(result.map((container) => container.Id)).toEqual(["node"]);
35+
});
36+
37+
it("does not adopt a reaper without an identifiable session id", async () => {
38+
const anonymousReaper = reaperFixture("anonymous", { [LABEL_TESTCONTAINERS_LANG]: "node" });
39+
40+
const result = await findReaperContainers(stubClient([anonymousReaper]));
41+
42+
expect(result).toEqual([]);
43+
});
44+
45+
it("does not adopt non-running containers or test reapers", async () => {
46+
const stoppedReaper = reaperFixture(
47+
"stopped",
48+
{
49+
[LABEL_TESTCONTAINERS_LANG]: "node",
50+
[LABEL_TESTCONTAINERS_SESSION_ID]: "0123456789ab",
51+
},
52+
"exited"
53+
);
54+
const testReaper = reaperFixture("test", {
55+
[LABEL_TESTCONTAINERS_LANG]: "node",
56+
[LABEL_TESTCONTAINERS_SESSION_ID]: "0123456789ab",
57+
TESTCONTAINERS_RYUK_TEST_LABEL: "true",
58+
});
59+
60+
const result = await findReaperContainers(stubClient([stoppedReaper, testReaper]));
61+
62+
expect(result).toEqual([]);
63+
});
64+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ContainerInfo } from "dockerode";
2+
3+
import { LABEL_TESTCONTAINERS_LANG, LABEL_TESTCONTAINERS_RYUK, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
4+
5+
const LABEL_TESTCONTAINERS_RYUK_TEST_LABEL = "TESTCONTAINERS_RYUK_TEST_LABEL";
6+
7+
/**
8+
* Decides whether a running Ryuk container can be adopted by this binding's
9+
* session. A reaper is only adoptable when this binding can actually own the
10+
* session it watches:
11+
*
12+
* - it must have been started by this library, which labels everything it
13+
* creates with `org.testcontainers.lang: "node"` (see `createLabels()`), and
14+
* - it must carry its session id in a durable label. Ryuk containers started
15+
* by other language bindings carry neither, and adopting them used to mint
16+
* a fresh session id per worker that no reaper ever owned — leaking every
17+
* container created under it (see issue #1442).
18+
*/
19+
export function isAdoptableReaperContainer(container: ContainerInfo): boolean {
20+
return (
21+
container.State === "running" &&
22+
container.Labels[LABEL_TESTCONTAINERS_RYUK] === "true" &&
23+
container.Labels[LABEL_TESTCONTAINERS_RYUK_TEST_LABEL] !== "true" &&
24+
container.Labels[LABEL_TESTCONTAINERS_LANG] === "node" &&
25+
typeof container.Labels[LABEL_TESTCONTAINERS_SESSION_ID] === "string"
26+
);
27+
}

packages/testcontainers/src/reaper/reaper.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { userInfo } from "os";
44
import { IntervalRetry, log, RandomUuid, withFileLock } from "../common";
55
import { ContainerRuntimeClient, ImageName } from "../container-runtime";
66
import { GenericContainer } from "../generic-container/generic-container";
7-
import { LABEL_TESTCONTAINERS_RYUK, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
7+
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
88
import { Wait } from "../wait-strategies/wait";
9+
import { isAdoptableReaperContainer } from "./reaper-discovery";
910

1011
/**
1112
* Resolve the Ryuk reaper image name. Read lazily so that callers (and tests)
@@ -68,16 +69,9 @@ export async function getReaper(client: ContainerRuntimeClient): Promise<Reaper>
6869
return reaper;
6970
}
7071

71-
async function findReaperContainers(client: ContainerRuntimeClient): Promise<ContainerInfo[]> {
72+
export async function findReaperContainers(client: ContainerRuntimeClient): Promise<ContainerInfo[]> {
7273
const containers = await client.container.list();
73-
return containers
74-
.filter(
75-
(container) =>
76-
container.State === "running" &&
77-
container.Labels[LABEL_TESTCONTAINERS_RYUK] === "true" &&
78-
container.Labels["TESTCONTAINERS_RYUK_TEST_LABEL"] !== "true"
79-
)
80-
.sort((a, b) => b.Created - a.Created);
74+
return containers.filter(isAdoptableReaperContainer).sort((a, b) => b.Created - a.Created);
8175
}
8276

8377
async function useExistingReaper(reaperContainer: ContainerInfo, sessionId: string, host: string): Promise<Reaper> {

0 commit comments

Comments
 (0)