Skip to content

Commit 3743896

Browse files
ensure that container builds don't disrupt dev hotkey handling (#9833)
1 parent ce2a054 commit 3743896

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
@@ -15,17 +15,28 @@ import {
1515
export async function pullImage(
1616
dockerPath: string,
1717
options: ContainerDevOptions
18-
) {
18+
): Promise<{ abort: () => void; ready: Promise<void> }> {
1919
await dockerLoginManagedRegistry(dockerPath);
20-
await runDockerCmd(dockerPath, [
20+
const pull = runDockerCmd(dockerPath, [
2121
"pull",
2222
options.image,
2323
// 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
2424
"--platform",
2525
"linux/amd64",
2626
]);
27-
// re-tag image with the expected dev-formatted image tag for consistency
28-
await runDockerCmd(dockerPath, ["tag", options.image, options.imageTag]);
27+
const ready = pull.ready.then(async ({ aborted }: { aborted: boolean }) => {
28+
if (!aborted) {
29+
// re-tag image with the expected dev-formatted image tag for consistency
30+
await runDockerCmd(dockerPath, ["tag", options.image, options.imageTag]);
31+
}
32+
});
33+
34+
return {
35+
abort: () => {
36+
pull.abort();
37+
},
38+
ready,
39+
};
2940
}
3041

3142
/**
@@ -41,8 +52,16 @@ export async function pullImage(
4152
export async function prepareContainerImagesForDev(
4253
dockerPath: string,
4354
containerOptions: ContainerDevOptions[],
44-
configPath: string | undefined
55+
configPath: string | undefined,
56+
onContainerImagePreparationStart: (args: {
57+
containerOptions: ContainerDevOptions;
58+
abort: () => void;
59+
}) => void,
60+
onContainerImagePreparationEnd: (args: {
61+
containerOptions: ContainerDevOptions;
62+
}) => void
4563
) {
64+
let aborted = false;
4665
if (process.platform === "win32") {
4766
throw new Error(
4867
"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."
@@ -51,17 +70,41 @@ export async function prepareContainerImagesForDev(
5170
await verifyDockerInstalled(dockerPath);
5271
for (const options of containerOptions) {
5372
if (isDockerfile(options.image, configPath)) {
54-
await buildImage(dockerPath, options, configPath);
73+
const build = await buildImage(dockerPath, options, configPath);
74+
onContainerImagePreparationStart({
75+
containerOptions: options,
76+
abort: () => {
77+
aborted = true;
78+
build.abort();
79+
},
80+
});
81+
await build.ready;
82+
onContainerImagePreparationEnd({
83+
containerOptions: options,
84+
});
5585
} else {
5686
if (!isCloudflareRegistryLink(options.image)) {
5787
throw new Error(
5888
`Image "${options.image}" is a registry link but does not point to the Cloudflare container registry.\n` +
5989
`To use an existing image from another repository, see https://developers.cloudflare.com/containers/image-management/#using-existing-images`
6090
);
6191
}
62-
await pullImage(dockerPath, options);
92+
const pull = await pullImage(dockerPath, options);
93+
onContainerImagePreparationStart({
94+
containerOptions: options,
95+
abort: () => {
96+
aborted = true;
97+
pull.abort();
98+
},
99+
});
100+
await pull.ready;
101+
onContainerImagePreparationEnd({
102+
containerOptions: options,
103+
});
104+
}
105+
if (!aborted) {
106+
await checkExposedPorts(dockerPath, options);
63107
}
64-
await checkExposedPorts(dockerPath, options);
65108
}
66109
}
67110

0 commit comments

Comments
 (0)