Skip to content

Commit c8fabcd

Browse files
Fix containers left running after wait strategies fail (#142)
1 parent 3191c93 commit c8fabcd

File tree

3 files changed

+102
-5
lines changed

3 files changed

+102
-5
lines changed

src/generic-container.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { GenericContainer } from "./generic-container";
66
import { AlwaysPullPolicy } from "./pull-policy";
77
import { Wait } from "./wait";
88
import { Readable } from "stream";
9+
import { RandomUuid } from "./uuid";
910

1011
describe("GenericContainer", () => {
1112
jest.setTimeout(180_000);
@@ -245,6 +246,69 @@ describe("GenericContainer", () => {
245246
await container.stop();
246247
});
247248

249+
it("should stop the container when the host port check wait strategy times out", async () => {
250+
const containerName = `container-${new RandomUuid().nextUuid()}`;
251+
252+
await expect(
253+
new GenericContainer("cristianrgreco/testcontainer", "1.1.12")
254+
.withName(containerName)
255+
.withExposedPorts(8081)
256+
.withStartupTimeout(new Duration(0, TemporalUnit.MILLISECONDS))
257+
.start()
258+
).rejects.toThrowError("Port 8081 not bound after 0ms");
259+
260+
expect(await getRunningContainerNames()).not.toContain(containerName);
261+
});
262+
263+
it("should stop the container when the log message wait strategy times out", async () => {
264+
const containerName = `container-${new RandomUuid().nextUuid()}`;
265+
266+
await expect(
267+
new GenericContainer("cristianrgreco/testcontainer", "1.1.12")
268+
.withName(containerName)
269+
.withExposedPorts(8080)
270+
.withWaitStrategy(Wait.forLogMessage("unexpected"))
271+
.withStartupTimeout(new Duration(0, TemporalUnit.MILLISECONDS))
272+
.start()
273+
).rejects.toThrowError(`Log message "unexpected" not received after 0ms`);
274+
275+
expect(await getRunningContainerNames()).not.toContain(containerName);
276+
});
277+
278+
it("should stop the container when the health check wait strategy times out", async () => {
279+
const containerName = `container-${new RandomUuid().nextUuid()}`;
280+
281+
const context = path.resolve(fixtures, "docker-with-health-check");
282+
const customGenericContainer = await GenericContainer.fromDockerfile(context).build();
283+
await expect(
284+
customGenericContainer
285+
.withName(containerName)
286+
.withExposedPorts(8080)
287+
.withWaitStrategy(Wait.forHealthCheck())
288+
.withStartupTimeout(new Duration(0, TemporalUnit.MILLISECONDS))
289+
.start()
290+
).rejects.toThrowError("Health check not healthy after 0ms");
291+
292+
expect(await getRunningContainerNames()).not.toContain(containerName);
293+
});
294+
295+
it("should stop the container when the health check fails", async () => {
296+
const containerName = `container-${new RandomUuid().nextUuid()}`;
297+
298+
const context = path.resolve(fixtures, "docker-with-health-check");
299+
const customGenericContainer = await GenericContainer.fromDockerfile(context).build();
300+
await expect(
301+
customGenericContainer
302+
.withName(containerName)
303+
.withExposedPorts(8080)
304+
.withHealthCheck({ test: "exit 1" })
305+
.withWaitStrategy(Wait.forHealthCheck())
306+
.start()
307+
).rejects.toThrowError("Health check failed");
308+
309+
expect(await getRunningContainerNames()).not.toContain(containerName);
310+
});
311+
248312
it("should honour .dockerignore file", async () => {
249313
const context = path.resolve(fixtures, "docker-with-dockerignore");
250314
const container = await GenericContainer.fromDockerfile(context).build();
@@ -312,4 +376,12 @@ describe("GenericContainer", () => {
312376
await startedContainer.stop();
313377
});
314378
});
379+
380+
const getRunningContainerNames = async (): Promise<string[]> => {
381+
const containers = await dockerodeClient.listContainers();
382+
return containers
383+
.map((container) => container.Names)
384+
.reduce((result, containerNames) => [...result, ...containerNames], [])
385+
.map((containerName) => containerName.replace("/", ""));
386+
};
315387
});

src/generic-container.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
TmpFs,
2222
} from "./docker-client";
2323
import { DockerClientFactory, Host } from "./docker-client-factory";
24-
import { log, containerLog } from "./logger";
24+
import { containerLog, log } from "./logger";
2525
import { Port } from "./port";
2626
import { PortBinder } from "./port-binder";
2727
import { HostPortCheck, InternalPortCheck } from "./port-check";
@@ -235,8 +235,20 @@ export class GenericContainer implements TestContainer {
235235
): Promise<void> {
236236
log.debug(`Waiting for container to be ready: ${container.getId()}`);
237237
const waitStrategy = this.getWaitStrategy(dockerClient, container);
238-
await waitStrategy.withStartupTimeout(this.startupTimeout).waitUntilReady(container, containerState, boundPorts);
239-
log.info("Container is ready");
238+
239+
try {
240+
await waitStrategy.withStartupTimeout(this.startupTimeout).waitUntilReady(container, containerState, boundPorts);
241+
log.info("Container is ready");
242+
} catch (err) {
243+
log.error("Container failed to be ready");
244+
try {
245+
await container.stop({ timeout: new Duration(0, TemporalUnit.MILLISECONDS) });
246+
await container.remove({ removeVolumes: true });
247+
} catch (stopErr) {
248+
log.error("Failed to stop container after it failed to be ready");
249+
}
250+
throw err;
251+
}
240252
}
241253

242254
private getWaitStrategy(dockerClient: DockerClient, container: Container): WaitStrategy {

src/wait-strategy.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,21 +89,30 @@ export class LogWaitStrategy extends AbstractWaitStrategy {
8989
const stream = await container.logs();
9090

9191
return new Promise((resolve, reject) => {
92+
const startupTimeout = this.startupTimeout.get(TemporalUnit.MILLISECONDS);
93+
const timeout = setTimeout(
94+
() => reject(new Error(`Log message "${this.message}" not received after ${startupTimeout}ms`)),
95+
startupTimeout
96+
);
97+
9298
byline(stream)
9399
.on("data", (line) => {
94100
if (line.includes(this.message)) {
95101
stream.destroy();
102+
clearTimeout(timeout);
96103
resolve();
97104
}
98105
})
99106
.on("err", (line) => {
100107
if (line.includes(this.message)) {
101108
stream.destroy();
109+
clearTimeout(timeout);
102110
resolve();
103111
}
104112
})
105113
.on("end", () => {
106114
stream.destroy();
115+
clearTimeout(timeout);
107116
reject();
108117
});
109118
});
@@ -118,14 +127,18 @@ export class HealthCheckWaitStrategy extends AbstractWaitStrategy {
118127
new Duration(100, TemporalUnit.MILLISECONDS)
119128
);
120129

121-
await retryStrategy.retryUntil(
130+
const status = await retryStrategy.retryUntil(
122131
async () => (await container.inspect()).healthCheckStatus,
123-
(status) => status === "healthy",
132+
(status) => status === "healthy" || status === "unhealthy",
124133
() => {
125134
const timeout = this.startupTimeout.get(TemporalUnit.MILLISECONDS);
126135
throw new Error(`Health check not healthy after ${timeout}ms`);
127136
},
128137
this.startupTimeout
129138
);
139+
140+
if (status !== "healthy") {
141+
throw new Error(`Health check failed`);
142+
}
130143
}
131144
}

0 commit comments

Comments
 (0)