Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 360004d

Browse files
authored
Clean up containers started by local dev during process exit hook instead (#10099)
* make cleanup container calls sync and call in process.on("exit") * fix tests * changeset * PR feedback * cleanup
1 parent 5de87ee commit 360004d

File tree

9 files changed

+71
-155
lines changed

9 files changed

+71
-155
lines changed

.changeset/great-terms-roll.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudflare/vite-plugin": patch
3+
"wrangler": patch
4+
---
5+
6+
fix: move local dev container cleanup to process exit hook. This should ensure containers are cleaned up even when Wrangler is shut down programatically.

fixtures/interactive-dev-tests/tests/index.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,6 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
396396

397397
wrangler.pty.write("r");
398398

399-
// wait for build to finish
400399
await vi.waitFor(async () => {
401400
const status = await fetch(wrangler.url + "/status");
402401
expect(await status.json()).toBe(false);
@@ -435,7 +434,7 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
435434
await new Promise<void>((resolve) => {
436435
wrangler.pty.onExit(() => resolve());
437436
});
438-
vi.waitFor(() => {
437+
await vi.waitFor(() => {
439438
const remainingIds = getContainerIds();
440439
expect(remainingIds.length).toBe(0);
441440
});
@@ -719,7 +718,7 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
719718
await new Promise<void>((resolve) => {
720719
wrangler.pty.onExit(() => resolve());
721720
});
722-
vi.waitFor(() => {
721+
await vi.waitFor(() => {
723722
const remainingIds = getContainerIds();
724723
expect(remainingIds.length).toBe(0);
725724
});
@@ -779,7 +778,7 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
779778
}
780779
});
781780

782-
it("should be interact with both workers, rebuild the containers with the hotkey and all containers should be cleaned up at the end", async () => {
781+
it("should be able to interact with both workers, rebuild the containers with the hotkey and all containers should be cleaned up at the end", async () => {
783782
const wrangler = await startWranglerDev([
784783
"dev",
785784
"-c",

packages/containers-shared/src/utils.ts

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -141,33 +141,21 @@ export const isDockerfile = (
141141

142142
/**
143143
* Kills and removes any containers which come from the given image tag
144-
*
145-
* Please note that this function has an almost identical counterpart
146-
* in the `vite-plugin-cloudflare` package (see `removeContainersByIds`).
147-
* If you make any changes to this fn, please make sure you persist those
148-
* changes in `removeContainersByIds` if necessary.
149144
*/
150-
export const cleanupContainers = async (
145+
export const cleanupContainers = (
151146
dockerPath: string,
152147
imageTags: Set<string>
153148
) => {
154149
try {
155150
// Find all containers (stopped and running) for each built image
156-
const containerIds = await getContainerIdsByImageTags(
157-
dockerPath,
158-
imageTags
159-
);
151+
const containerIds = getContainerIdsByImageTags(dockerPath, imageTags);
160152

161153
if (containerIds.length === 0) {
162154
return true;
163155
}
164156

165157
// Workerd should have stopped all containers, but clean up any in case. Sends a sigkill.
166-
await runDockerCmd(
167-
dockerPath,
168-
["rm", "--force", ...containerIds],
169-
["inherit", "pipe", "pipe"]
170-
);
158+
runDockerCmdWithOutput(dockerPath, ["rm", "--force", ...containerIds]);
171159
return true;
172160
} catch {
173161
return false;
@@ -181,14 +169,14 @@ export const cleanupContainers = async (
181169
* @param imageTags A set of ancestor image tags
182170
* @returns The ids of all containers that share the given image tags as ancestors.
183171
*/
184-
export async function getContainerIdsByImageTags(
172+
export function getContainerIdsByImageTags(
185173
dockerPath: string,
186174
imageTags: Set<string>
187-
): Promise<Array<string>> {
175+
): string[] {
188176
const ids = new Set<string>();
189177

190178
for (const imageTag of imageTags) {
191-
const containerIdsFromImage = await getContainerIdsFromImage(
179+
const containerIdsFromImage = getContainerIdsFromImage(
192180
dockerPath,
193181
imageTag
194182
);
@@ -198,7 +186,7 @@ export async function getContainerIdsByImageTags(
198186
return Array.from(ids);
199187
}
200188

201-
export const getContainerIdsFromImage = async (
189+
export const getContainerIdsFromImage = (
202190
dockerPath: string,
203191
ancestorImage: string
204192
) => {

packages/vite-plugin-cloudflare/src/containers.ts

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ import assert from "node:assert";
22
import path from "node:path";
33
import { prepareContainerImagesForDev } from "@cloudflare/containers-shared/src/images";
44
import { getDevContainerImageName } from "@cloudflare/containers-shared/src/knobs";
5-
import {
6-
isDockerfile,
7-
runDockerCmd,
8-
} from "@cloudflare/containers-shared/src/utils";
5+
import { isDockerfile } from "@cloudflare/containers-shared/src/utils";
96
import type { WorkerConfig } from "./plugin-config";
107

118
/**
@@ -20,40 +17,6 @@ export function getDockerPath(): string {
2017
return process.env[dockerPathEnvVar] || defaultDockerPath;
2118
}
2219

23-
/**
24-
* Performs the forced removal of running Docker containers, given their ids.
25-
*
26-
* Please note that this function is almost identical to `cleanupContainers`
27-
* defined in `containers-shared`, with the exception that the current fn
28-
* expects the list of ids of all containers that are to be removed, as a
29-
* parameter. This is because extracting these ids is an async operation, and
30-
* we are currently restricted from performing async cleanup work upon
31-
* closing/exiting the vite dev process (see vite-plugin-cloudflare/src/index.ts
32-
* for more details).
33-
*
34-
* @param dockerPath The path to the Docker executable
35-
* @param containerIds The ids of the containers that should be removed
36-
*/
37-
export async function removeContainersByIds(
38-
dockerPath: string,
39-
containerIds: string[]
40-
) {
41-
try {
42-
if (containerIds.length === 0) {
43-
return;
44-
}
45-
46-
await runDockerCmd(
47-
dockerPath,
48-
["rm", "--force", ...containerIds],
49-
["inherit", "pipe", "pipe"]
50-
);
51-
} catch (error) {
52-
// fail silently
53-
return;
54-
}
55-
}
56-
5720
/**
5821
* @returns Container options suitable for building or pulling images,
5922
* with image tag set to well-known dev format, or undefined if

packages/vite-plugin-cloudflare/src/index.ts

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import * as fsp from "node:fs/promises";
33
import * as path from "node:path";
44
import * as util from "node:util";
55
import {
6+
cleanupContainers,
67
generateContainerBuildId,
7-
getContainerIdsByImageTags,
88
resolveDockerHost,
99
} from "@cloudflare/containers-shared/src/utils";
1010
import { generateStaticRoutingRuleMatcher } from "@cloudflare/workers-shared/asset-worker/src/utils/rules-engine";
@@ -29,11 +29,7 @@ import {
2929
kRequestType,
3030
ROUTER_WORKER_NAME,
3131
} from "./constants";
32-
import {
33-
getDockerPath,
34-
prepareContainerImages,
35-
removeContainersByIds,
36-
} from "./containers";
32+
import { getDockerPath, prepareContainerImages } from "./containers";
3733
import {
3834
addDebugToVitePrintUrls,
3935
getDebugPathHtml,
@@ -103,8 +99,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
10399

104100
const additionalModulePaths = new Set<string>();
105101
const nodeJsCompatWarningsMap = new Map<WorkerConfig, NodeJsCompatWarnings>();
106-
let containerImageTagsSeen: Set<string> | undefined;
107-
let runningContainerIds: Array<string>;
102+
let containerImageTagsSeen = new Set<string>();
108103

109104
/** Used to track whether hooks are being called because of a server restart or a server close event. */
110105
let restartingServer = false;
@@ -493,17 +488,6 @@ if (import.meta.hot) {
493488
)
494489
);
495490

496-
// poll Docker every two seconds and update the list of ids of all
497-
// running containers
498-
const dockerPollIntervalId = setInterval(async () => {
499-
if (containerImageTagsSeen?.size) {
500-
runningContainerIds = await getContainerIdsByImageTags(
501-
dockerPath,
502-
containerImageTagsSeen
503-
);
504-
}
505-
}, 2000);
506-
507491
/*
508492
* Upon exiting the dev process we should ensure we perform any
509493
* containers-specific cleanup work. Vite recommends using the
@@ -517,21 +501,11 @@ if (import.meta.hot) {
517501
* `process.exit()` imperatively, and therefore causes `beforeExit`
518502
* not to be emitted).
519503
*
520-
* Furthermore, since the `exit` event handler cannot perform async
521-
* ops as per spec (https://nodejs.org/api/process.html#event-exit),
522-
* we also need a mechanism to ensure that list of containers to be
523-
* cleaned up on exit is up to date. This is what the interval with id
524-
* `dockerPollIntervalId` is for.
525-
*
526-
* It is possible, though very unlikely, that in some rare cases,
527-
* we might be left with some orphaned containers, due to the fact
528-
* that at the point of exiting the dev process, our internal list
529-
* of container ids is out of date. We accept this caveat for now.
530-
*
531504
*/
532-
process.on("exit", () => {
533-
clearInterval(dockerPollIntervalId);
534-
removeContainersByIds(dockerPath, runningContainerIds);
505+
process.on("exit", async () => {
506+
if (containerImageTagsSeen.size) {
507+
cleanupContainers(dockerPath, containerImageTagsSeen);
508+
}
535509
});
536510
}
537511
}
@@ -629,18 +603,10 @@ if (import.meta.hot) {
629603
colors.dim(colors.yellow("\n⚡️ Containers successfully built.\n"))
630604
);
631605

632-
const dockerPollIntervalId = setInterval(async () => {
633-
if (containerImageTagsSeen?.size) {
634-
runningContainerIds = await getContainerIdsByImageTags(
635-
dockerPath,
636-
containerImageTagsSeen
637-
);
638-
}
639-
}, 2000);
640-
641606
process.on("exit", () => {
642-
clearInterval(dockerPollIntervalId);
643-
removeContainersByIds(dockerPath, runningContainerIds);
607+
if (containerImageTagsSeen.size) {
608+
cleanupContainers(dockerPath, containerImageTagsSeen);
609+
}
644610
});
645611
}
646612

@@ -665,14 +631,7 @@ if (import.meta.hot) {
665631
containerImageTagsSeen?.size
666632
) {
667633
const dockerPath = getDockerPath();
668-
runningContainerIds = await getContainerIdsByImageTags(
669-
dockerPath,
670-
containerImageTagsSeen
671-
);
672-
673-
await removeContainersByIds(dockerPath, runningContainerIds);
674-
containerImageTagsSeen.clear();
675-
runningContainerIds = [];
634+
cleanupContainers(dockerPath, containerImageTagsSeen);
676635
}
677636

678637
debuglog("buildEnd:", restartingServer ? "restarted" : "disposing");

packages/wrangler/e2e/containers.dev.test.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ for (const source of imageSource) {
2626
const isPullWithoutAccountId = source === "pull" && !CLOUDFLARE_ACCOUNT_ID;
2727

2828
describe.skipIf(isCINonLinux || isPullWithoutAccountId)(
29-
"containers local dev tests: %s",
29+
`containers local dev tests: ${source}`,
3030
{ timeout: 90_000 },
3131
() => {
3232
let helper: WranglerE2ETestHelper;
@@ -158,16 +158,23 @@ for (const source of imageSource) {
158158
await helper.seed({
159159
"wrangler.json": JSON.stringify(wranglerConfig),
160160
});
161-
// cleanup any running containers
161+
/// wait a bit in case the expected cleanup from shutting down wrangler dev is already happening
162+
await new Promise((resolve) => setTimeout(resolve, 500));
163+
// cleanup any running containers. this does happen automatically when we shut down wrangler,
164+
// but treekill is being uncooperative. this is also tested in interactive-dev-fixture
165+
// where it is working as expected
162166
const ids = getContainerIds("e2econtainer");
163167
if (ids.length > 0) {
164-
console.log(ids);
165168
execSync(`${getDockerPath()} rm -f ${ids.join(" ")}`, {
166169
encoding: "utf8",
167170
});
168171
}
169172
});
170173
afterAll(async () => {
174+
// wait a bit in case the expected cleanup from shutting down wrangler dev is already happening
175+
await new Promise((resolve) => setTimeout(resolve, 500));
176+
// again this should happen automatically when we shut down wrangler, but treekill is being uncooperative.
177+
// this is tested in interactive-dev-fixture where it is working as expected.
171178
const ids = getContainerIds("e2econtainer");
172179
if (ids.length > 0) {
173180
execSync(`${getDockerPath()} rm -f ${ids.join(" ")}`, {
@@ -209,21 +216,29 @@ for (const source of imageSource) {
209216
expect(response.status).toBe(200);
210217
expect(text).toBe("Container create request sent...");
211218

212-
// Wait a bit for container to start
213-
await new Promise((resolve) => setTimeout(resolve, 2_000));
219+
await vi.waitFor(async () => {
220+
response = await fetch(`${ready.url}/status`);
221+
expect(response.status).toBe(200);
222+
const status = await response.json();
223+
expect(status).toBe(true);
224+
});
214225

215-
response = await fetch(`${ready.url}/status`);
216-
const status = await response.json();
217-
expect(response.status).toBe(200);
218-
expect(status).toBe(true);
226+
await vi.waitFor(
227+
async () => {
228+
response = await fetch(`${ready.url}/fetch`, {
229+
signal: AbortSignal.timeout(3_000),
230+
headers: { "MF-Disable-Pretty-Error": "true" },
231+
});
232+
text = await response.text();
233+
expect(text).toBe("Hello World! Have an env var! I'm an env var!");
234+
},
235+
{ timeout: 5_000 }
236+
);
219237

220-
response = await fetch(`${ready.url}/fetch`);
221-
expect(response.status).toBe(200);
222-
text = await response.text();
223-
expect(text).toBe("Hello World! Have an env var! I'm an env var!");
224238
// Check that a container is running using `docker ps`
225239
const ids = getContainerIds("e2econtainer");
226240
expect(ids.length).toBe(1);
241+
await worker.stop();
227242
});
228243

229244
it("won't start the container service if no containers are present", async () => {

packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ export class LocalRuntimeController extends RuntimeController {
191191
};
192192

193193
onBundleStart(_: BundleStartEvent) {
194-
// Ignored in local runtime
194+
process.on("exit", () => {
195+
this.cleanupContainers();
196+
});
195197
}
196198

197199
async #onBundleComplete(data: BundleCompleteEvent, id: number) {
@@ -381,12 +383,16 @@ export class LocalRuntimeController extends RuntimeController {
381383
// Ignored in local runtime
382384
}
383385

384-
cleanupContainers = async () => {
386+
cleanupContainers = () => {
387+
if (!this.containerImageTagsSeen.size) {
388+
return;
389+
}
390+
385391
assert(
386392
this.dockerPath,
387393
"Docker path should have been set if containers are enabled"
388394
);
389-
await cleanupContainers(this.dockerPath, this.containerImageTagsSeen);
395+
cleanupContainers(this.dockerPath, this.containerImageTagsSeen);
390396
};
391397

392398
#teardown = async (): Promise<void> => {
@@ -399,15 +405,6 @@ export class LocalRuntimeController extends RuntimeController {
399405
await this.#mf?.dispose();
400406
this.#mf = undefined;
401407

402-
if (this.containerImageTagsSeen.size > 0) {
403-
try {
404-
await this.cleanupContainers();
405-
} catch {
406-
logger.warn(
407-
`Failed to clean up containers. You may have to stop and remove them up manually.`
408-
);
409-
}
410-
}
411408
if (this.#remoteProxySessionData) {
412409
logger.log(chalk.dim("⎔ Shutting down remote connection..."));
413410
}

0 commit comments

Comments
 (0)