Skip to content

Commit 7d953c4

Browse files
ensure that container builds don't disrupt dev hotkey handling
1 parent 3bdec6b commit 7d953c4

File tree

19 files changed

+402
-81
lines changed

19 files changed

+402
-81
lines changed

.changeset/fine-seals-mate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/containers-shared": minor
3+
---
4+
5+
extend `prepareContainerImagesForDev` to allow aborting a container's build process

.changeset/sweet-turtles-stay.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
fix: ensure that container builds don't disrupt dev hotkey handling
6+
7+
currently container builds run during local development (via `wrangler dev` or `startWorker`) prevent the standard hotkeys not to be recognized (most noticeably `ctrl+c`, preventing developers from existing the process), the changes here ensure that hotkeys are instead correctly handled as expected

fixtures/container-app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"container:build": "wrangler containers build ./ -t container-fixture",
77
"deploy": "wrangler deploy",
88
"dev": "wrangler dev",
9-
"start": "wrangler dev"
9+
"dev:registry": "wrangler dev -c ./wrangler.registry.jsonc",
10+
"start": "wrangler dev",
11+
"start:registry": "wrangler dev -c ./wrangler.registry.jsonc"
1012
},
1113
"devDependencies": {
1214
"@cloudflare/workers-tsconfig": "workspace:*",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /usr/src/app
4+
5+
RUN echo 'This (no-op) build takes forever...'
6+
RUN sleep 50000
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "ESNext",
5+
"lib": ["ES2020"],
6+
"types": ["@cloudflare/workers-types"],
7+
"moduleResolution": "node",
8+
"noEmit": true,
9+
"skipLibCheck": true
10+
},
11+
"include": ["**/*.ts"]
12+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
declare namespace Cloudflare {
2+
interface Env {
3+
CONTAINER: DurableObjectNamespace<import("./index").FixtureTestContainer>;
4+
}
5+
}
6+
interface Env extends Cloudflare.Env {}

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55
import path from "node:path";
66
import rl from "node:readline";
77
import stream from "node:stream";
8+
import { setTimeout } from "node:timers/promises";
89
import stripAnsi from "strip-ansi";
910
import { fetch } from "undici";
1011
import {
@@ -435,6 +436,131 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
435436
}
436437
);
437438

439+
baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
440+
"container dev where image build takes a long time",
441+
{ retry: 0, timeout: 90000 },
442+
() => {
443+
let tmpDir: string;
444+
beforeAll(async () => {
445+
tmpDir = fs.mkdtempSync(path.join(tmpdir(), "wrangler-container-sleep-"));
446+
fs.cpSync(
447+
path.resolve(__dirname, "../", "container-app"),
448+
path.join(tmpDir),
449+
{
450+
recursive: true,
451+
}
452+
);
453+
const tmpDockerFilePath = path.join(tmpDir, "Dockerfile");
454+
fs.rmSync(tmpDockerFilePath);
455+
fs.renameSync(
456+
path.join(tmpDir, "DockerfileWithLongSleep"),
457+
tmpDockerFilePath
458+
);
459+
460+
const ids = getContainerIds();
461+
if (ids.length > 0) {
462+
execSync("docker rm -f " + ids.join(" "), {
463+
encoding: "utf8",
464+
});
465+
}
466+
});
467+
468+
afterEach(async () => {
469+
const ids = getContainerIds();
470+
if (ids.length > 0) {
471+
execSync("docker rm -f " + ids.join(" "), {
472+
encoding: "utf8",
473+
});
474+
}
475+
});
476+
477+
afterAll(async () => {
478+
try {
479+
fs.rmSync(tmpDir, { recursive: true, force: true });
480+
} catch (e) {
481+
// It seems that Windows doesn't let us delete this, with errors like:
482+
//
483+
// Error: EBUSY: resource busy or locked, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\wrangler-modules-pKJ7OQ'
484+
// ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
485+
// Serialized Error: {
486+
// "code": "EBUSY",
487+
// "errno": -4082,
488+
// "path": "C:\Users\RUNNER~1\AppData\Local\Temp\wrangler-modules-pKJ7OQ",
489+
// "syscall": "rmdir",
490+
// }
491+
console.error(e);
492+
}
493+
});
494+
495+
it("should allow quitting while the image is building", async () => {
496+
const wrangler = await startWranglerDev([
497+
"dev",
498+
"-c",
499+
path.join(tmpDir, "wrangler.jsonc"),
500+
]);
501+
502+
const waitForOptions = {
503+
timeout: 10_000,
504+
interval: WAITFOR_OPTIONS.interval,
505+
};
506+
507+
// wait for long sleep instruction to start
508+
await vi.waitFor(async () => {
509+
expect(wrangler.stdout).toContain("RUN sleep 50000");
510+
}, waitForOptions);
511+
512+
wrangler.pty.write("q");
513+
514+
await vi.waitFor(async () => {
515+
expect(wrangler.stdout).toMatch(/CANCELED \[.*?\] RUN sleep 50000/);
516+
}, waitForOptions);
517+
});
518+
519+
it("should rebuilding while the image is building", async () => {
520+
const wrangler = await startWranglerDev([
521+
"dev",
522+
"-c",
523+
path.join(tmpDir, "wrangler.jsonc"),
524+
]);
525+
526+
const waitForOptions = {
527+
timeout: 15_000,
528+
interval: 1_500,
529+
};
530+
531+
// wait for long sleep instruction to start
532+
await vi.waitFor(async () => {
533+
expect(wrangler.stdout).toContain("RUN sleep 50000");
534+
}, waitForOptions);
535+
536+
let logOccurrencesBefore =
537+
wrangler.stdout.match(/This \(no-op\) build takes forever.../g)
538+
?.length ?? 0;
539+
540+
wrangler.pty.write("r");
541+
542+
await vi.waitFor(async () => {
543+
const logOccurrences =
544+
wrangler.stdout.match(/This \(no-op\) build takes forever.../g)
545+
?.length ?? 0;
546+
expect(logOccurrences).toBeGreaterThan(1);
547+
logOccurrencesBefore = logOccurrences;
548+
}, waitForOptions);
549+
550+
await vi.waitFor(async () => {
551+
await setTimeout(700);
552+
wrangler.pty.write("r");
553+
const logOccurrences =
554+
wrangler.stdout.match(/This \(no-op\) build takes forever.../g)
555+
?.length ?? 0;
556+
expect(logOccurrences).toBeGreaterThan(logOccurrencesBefore);
557+
}, waitForOptions);
558+
559+
wrangler.pty.kill();
560+
});
561+
}
562+
);
563+
438564
/** gets any containers that were created by running this fixture */
439565
const getContainerIds = () => {
440566
// note the -a to include stopped containers

packages/containers-shared/src/build.ts

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,32 +43,51 @@ export function dockerBuild(
4343
buildCmd: string[];
4444
dockerfile: string;
4545
}
46-
): Promise<void> {
46+
): { abort: () => void; ready: Promise<void> } {
4747
let errorHandled = false;
48-
return new Promise((resolve, reject) => {
49-
const child = spawn(dockerPath, options.buildCmd, {
50-
stdio: ["pipe", "inherit", "inherit"],
51-
});
52-
if (child.stdin !== null) {
53-
child.stdin.write(options.dockerfile);
54-
child.stdin.end();
55-
}
48+
let resolve: () => void;
49+
let reject: (err: unknown) => void;
50+
const ready = new Promise<void>((res, rej) => {
51+
resolve = res;
52+
reject = rej;
53+
});
5654

57-
child.on("exit", (code) => {
58-
if (code === 0) {
59-
resolve();
60-
} else if (!errorHandled) {
61-
errorHandled = true;
62-
reject(new Error(`Build exited with code: ${code}`));
63-
}
64-
});
65-
child.on("error", (err) => {
66-
if (!errorHandled) {
67-
errorHandled = true;
68-
reject(err);
69-
}
70-
});
55+
const child = spawn(dockerPath, options.buildCmd, {
56+
stdio: ["pipe", "inherit", "inherit"],
57+
// We need to set detached to true so that the child process
58+
// will control all of its child processed and we can kill
59+
// all of them in case we need to abort the build process
60+
detached: true,
61+
});
62+
if (child.stdin !== null) {
63+
child.stdin.write(options.dockerfile);
64+
child.stdin.end();
65+
}
66+
67+
child.on("exit", (code) => {
68+
if (code === 0) {
69+
resolve();
70+
} else if (!errorHandled) {
71+
errorHandled = true;
72+
reject(new Error(`Build exited with code: ${code}`));
73+
}
74+
});
75+
child.on("error", (err) => {
76+
if (!errorHandled) {
77+
errorHandled = true;
78+
reject(err);
79+
}
7180
});
81+
return {
82+
abort: () => {
83+
child.unref();
84+
if (child.pid !== undefined) {
85+
// kill run on the negative PID kills the whole group controlled by the child process
86+
process.kill(-child.pid);
87+
}
88+
},
89+
ready,
90+
};
7291
}
7392

7493
export async function buildImage(
@@ -88,5 +107,5 @@ export async function buildImage(
88107
configPath
89108
);
90109

91-
await dockerBuild(dockerPath, { buildCmd, dockerfile });
110+
return dockerBuild(dockerPath, { buildCmd, dockerfile });
92111
}

packages/containers-shared/src/images.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,28 @@ import {
1212
export async function pullImage(
1313
dockerPath: string,
1414
options: ContainerDevOptions
15-
) {
15+
): Promise<{ abort: () => void; ready: Promise<void> }> {
1616
await dockerLoginManagedRegistry(dockerPath);
17-
await runDockerCmd(dockerPath, [
17+
const pull = runDockerCmd(dockerPath, [
1818
"pull",
1919
options.image,
2020
// All containers running on our platform need to be built for amd64 architecture, but by default docker pull seems to look for an image matching the host system, so we need to specify this here
2121
"--platform",
2222
"linux/amd64",
2323
]);
24-
// re-tag image with the expected dev-formatted image tag for consistency
25-
await runDockerCmd(dockerPath, ["tag", options.image, options.imageTag]);
24+
const ready = pull.ready.then(async ({ aborted }: { aborted: boolean }) => {
25+
if (!aborted) {
26+
// re-tag image with the expected dev-formatted image tag for consistency
27+
await runDockerCmd(dockerPath, ["tag", options.image, options.imageTag]);
28+
}
29+
});
30+
31+
return {
32+
abort: () => {
33+
pull.abort();
34+
},
35+
ready,
36+
};
2637
}
2738

2839
/**
@@ -38,8 +49,16 @@ export async function pullImage(
3849
export async function prepareContainerImagesForDev(
3950
dockerPath: string,
4051
containerOptions: ContainerDevOptions[],
41-
configPath: string | undefined
52+
configPath: string | undefined,
53+
onContainerImagePreparationStart: (args: {
54+
containerOptions: ContainerDevOptions;
55+
abort: () => void;
56+
}) => void,
57+
onContainerImagePreparationEnd: (args: {
58+
containerOptions: ContainerDevOptions;
59+
}) => void
4260
) {
61+
let aborted = false;
4362
if (process.platform === "win32") {
4463
throw new Error(
4564
"Local development with containers is currently not supported on Windows. You should use WSL instead. You can also set `enable_containers` to false if you do not need to develop the container part of your application."
@@ -48,16 +67,40 @@ export async function prepareContainerImagesForDev(
4867
await verifyDockerInstalled(dockerPath);
4968
for (const options of containerOptions) {
5069
if (isDockerfile(options.image, configPath)) {
51-
await buildImage(dockerPath, options, configPath);
70+
const build = await buildImage(dockerPath, options, configPath);
71+
onContainerImagePreparationStart({
72+
containerOptions: options,
73+
abort: () => {
74+
aborted = true;
75+
build.abort();
76+
},
77+
});
78+
await build.ready;
79+
onContainerImagePreparationEnd({
80+
containerOptions: options,
81+
});
5282
} else {
5383
if (!isCloudflareRegistryLink(options.image)) {
5484
throw new Error(
5585
`Image "${options.image}" is a registry link but does not point to the Cloudflare container registry.\n` +
5686
`To use an existing image from another repository, see https://developers.cloudflare.com/containers/image-management/#using-existing-images`
5787
);
5888
}
59-
await pullImage(dockerPath, options);
89+
const pull = await pullImage(dockerPath, options);
90+
onContainerImagePreparationStart({
91+
containerOptions: options,
92+
abort: () => {
93+
aborted = true;
94+
pull.abort();
95+
},
96+
});
97+
await pull.ready;
98+
onContainerImagePreparationEnd({
99+
containerOptions: options,
100+
});
101+
}
102+
if (!aborted) {
103+
await checkExposedPorts(dockerPath, options);
60104
}
61-
await checkExposedPorts(dockerPath, options);
62105
}
63106
}

0 commit comments

Comments
 (0)