Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit b929f02

Browse files
authored
Reuse Docker container between reloads, closes #369 (#376)
* Reuse Docker container between reloads, closes #369 On my machine, this change reduces reload time from ~1.5s to ~400ms * Remove redundant `await`s * Add return type annotations to `Runtime#dispose()` implementations
1 parent ddc45f3 commit b929f02

File tree

6 files changed

+157
-32
lines changed

6 files changed

+157
-32
lines changed

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/tre/lib/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
*
22
!.gitignore
3+
!restart.sh

packages/tre/lib/restart.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
# Restarts a process on receiving SIGUSR1.
3+
# Usage: ./restart.sh <command> <...args>
4+
5+
# Start process and record its PID
6+
"$@" &
7+
PID=$!
8+
echo "[*] Started $PID"
9+
10+
# Trap SIGUSR1 to set $RECEIVED_USR1 to 1, then terminate $PID.
11+
# Setting $RECEIVED_USR1 will cause the process to be restarted.
12+
RECEIVED_USR1=0
13+
trap 'RECEIVED_USR1=1 && kill -TERM $PID' USR1
14+
15+
# Trap SIGINT and SIGTERM to also terminate $PID for cleanup.
16+
# By not setting $RECEIVED_USR1, we ensure this script exits
17+
# when $PID exits.
18+
trap 'kill -TERM $PID' INT TERM
19+
20+
while true
21+
do
22+
# Wait for the started process to exit
23+
wait $PID
24+
EXIT_CODE=$?
25+
26+
# If the process exited for any reason other than this script
27+
# receiving SIGUSR1, exit the script with the same exit code.
28+
if [ $RECEIVED_USR1 -eq 0 ]
29+
then
30+
echo "[*] Exited with status $EXIT_CODE"
31+
exit $EXIT_CODE
32+
fi
33+
34+
# Otherwise, if this script received SIGUSR1, reset the flag,
35+
# restart the process, and record its new PID.
36+
RECEIVED_USR1=0
37+
"$@" &
38+
PID=$!
39+
echo "[*] Restarted $PID"
40+
done

packages/tre/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"acorn": "^8.8.0",
4040
"acorn-walk": "^8.2.0",
4141
"capnp-ts": "^0.7.0",
42+
"exit-hook": "^2.2.1",
4243
"get-port": "^5.1.1",
4344
"kleur": "^4.1.5",
4445
"stoppable": "^1.1.0",

packages/tre/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import assert from "assert";
22
import http from "http";
33
import path from "path";
4+
import exitHook from "exit-hook";
45
import getPort from "get-port";
56
import { bold, green, grey } from "kleur/colors";
67
import stoppable from "stoppable";
@@ -99,6 +100,7 @@ type PluginRouters = {
99100
[Key in keyof Plugins]: OptionalInstanceType<Plugins[Key]["router"]>;
100101
};
101102

103+
// `__dirname` relative to bundled output `dist/src/index.js`
102104
const RUNTIME_PATH = path.resolve(__dirname, "..", "..", "lib", "cfwrkr");
103105

104106
type StoppableServer = http.Server & stoppable.WithStop;
@@ -114,6 +116,7 @@ export class Miniflare {
114116

115117
readonly #runtimeConstructor: RuntimeConstructor;
116118
#runtime?: Runtime;
119+
#removeRuntimeExitHook?: () => void;
117120
#runtimeEntryURL?: URL;
118121

119122
readonly #disposeController: AbortController;
@@ -181,6 +184,8 @@ export class Miniflare {
181184
entryPort,
182185
loopbackPort
183186
);
187+
this.#removeRuntimeExitHook = exitHook(() => void this.#runtime?.dispose());
188+
184189
this.#runtimeEntryURL = new URL(`http://127.0.0.1:${entryPort}`);
185190

186191
const config = await this.#initialConfigPromise;
@@ -410,6 +415,7 @@ export class Miniflare {
410415
this.#disposeController.abort();
411416
await this.#initPromise;
412417
await this.#updatePromise;
418+
this.#removeRuntimeExitHook?.();
413419
this.#runtime?.dispose();
414420
await this.#stopLoopbackServer();
415421
}

packages/tre/src/runtime/index.ts

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import childProcess from "child_process";
2-
import { Awaitable, MiniflareError } from "../helpers";
2+
import crypto from "crypto";
3+
import fs from "fs";
4+
import os from "os";
5+
import path from "path";
6+
import { Awaitable, MiniflareCoreError } from "../helpers";
37
import { SERVICE_LOOPBACK, SOCKET_ENTRY } from "../plugins";
48

5-
export interface Runtime {
6-
updateConfig(configBuffer: Buffer): Awaitable<void>;
7-
dispose(): Awaitable<void>;
9+
export abstract class Runtime {
10+
constructor(
11+
protected readonly runtimeBinaryPath: string,
12+
protected readonly entryPort: number,
13+
protected readonly loopbackPort: number
14+
) {}
15+
16+
abstract updateConfig(configBuffer: Buffer): Awaitable<void>;
17+
abstract dispose(): Awaitable<void>;
818
}
919

1020
export interface RuntimeConstructor {
@@ -21,14 +31,16 @@ export interface RuntimeConstructor {
2131
}
2232

2333
const COMMON_RUNTIME_ARGS = ["serve", "--binary", "--verbose"];
34+
// `__dirname` relative to bundled output `dist/src/index.js`
35+
const RESTART_PATH = path.resolve(__dirname, "..", "..", "lib", "restart.sh");
2436

25-
function waitForExit(process: childProcess.ChildProcess): Promise<number> {
37+
function waitForExit(process: childProcess.ChildProcess): Promise<void> {
2638
return new Promise((resolve) => {
27-
process.once("exit", (code) => resolve(code ?? -1));
39+
process.once("exit", () => resolve());
2840
});
2941
}
3042

31-
class NativeRuntime implements Runtime {
43+
class NativeRuntime extends Runtime {
3244
static isSupported() {
3345
return process.platform === "linux"; // TODO: and "darwin"?
3446
}
@@ -40,13 +52,14 @@ class NativeRuntime implements Runtime {
4052
readonly #args: string[];
4153

4254
#process?: childProcess.ChildProcess;
43-
#processExitPromise?: Promise<number>;
55+
#processExitPromise?: Promise<void>;
4456

4557
constructor(
46-
protected readonly runtimeBinaryPath: string,
47-
protected readonly entryPort: number,
48-
protected readonly loopbackPort: number
58+
runtimeBinaryPath: string,
59+
entryPort: number,
60+
loopbackPort: number
4961
) {
62+
super(runtimeBinaryPath, entryPort, loopbackPort);
5063
const [command, ...args] = this.getCommand();
5164
this.#command = command;
5265
this.#args = args;
@@ -71,7 +84,7 @@ class NativeRuntime implements Runtime {
7184
// TODO: what happens if runtime crashes?
7285

7386
// 2. Start new process
74-
const runtimeProcess = await childProcess.spawn(this.#command, this.#args, {
87+
const runtimeProcess = childProcess.spawn(this.#command, this.#args, {
7588
stdio: "pipe",
7689
shell: true,
7790
});
@@ -86,11 +99,12 @@ class NativeRuntime implements Runtime {
8699

87100
// 3. Write config
88101
runtimeProcess.stdin.write(configBuffer);
102+
runtimeProcess.stdin.end();
89103
}
90104

91-
async dispose() {
105+
dispose(): Awaitable<void> {
92106
this.#process?.kill();
93-
await this.#processExitPromise;
107+
return this.#processExitPromise;
94108
}
95109
}
96110

@@ -112,7 +126,7 @@ class WSLRuntime extends NativeRuntime {
112126
}
113127
}
114128

115-
class DockerRuntime extends NativeRuntime {
129+
class DockerRuntime extends Runtime {
116130
static isSupported() {
117131
const result = childProcess.spawnSync("docker", ["--version"]); // TODO: check daemon running too?
118132
return result.error === undefined;
@@ -123,23 +137,68 @@ class DockerRuntime extends NativeRuntime {
123137
static description = "using Docker 🐳";
124138
static distribution = `linux-${process.arch}`;
125139

126-
getCommand(): string[] {
127-
// TODO: consider reusing container, but just restarting process within
128-
return [
140+
#configPath = path.join(
141+
os.tmpdir(),
142+
`miniflare-config-${crypto.randomBytes(16).toString("hex")}.bin`
143+
);
144+
145+
#process?: childProcess.ChildProcess;
146+
#processExitPromise?: Promise<void>;
147+
148+
async updateConfig(configBuffer: Buffer) {
149+
// 1. Write config to file (this is much easier than trying to buffer STDIN
150+
// in the restart script)
151+
fs.writeFileSync(this.#configPath, configBuffer);
152+
153+
// 2. If process running, send SIGUSR1 to restart runtime with new config
154+
// (see `lib/restart.sh`)
155+
if (this.#process) {
156+
this.#process.kill("SIGUSR1");
157+
return;
158+
}
159+
160+
// 3. Otherwise, start new process
161+
const runtimeProcess = childProcess.spawn(
129162
"docker",
130-
"run",
131-
"--platform=linux/amd64",
132-
"--interactive",
133-
"--rm",
134-
`--volume=${this.runtimeBinaryPath}:/runtime`,
135-
`--publish=127.0.0.1:${this.entryPort}:8787`,
136-
"debian:bullseye-slim",
137-
"/runtime",
138-
...COMMON_RUNTIME_ARGS,
139-
`--socket-addr=${SOCKET_ENTRY}=*:8787`,
140-
`--external-addr=${SERVICE_LOOPBACK}=host.docker.internal:${this.loopbackPort}`,
141-
"-",
142-
];
163+
[
164+
"run",
165+
"--platform=linux/amd64",
166+
"--interactive",
167+
"--rm",
168+
`--volume=${RESTART_PATH}:/restart.sh`,
169+
`--volume=${this.runtimeBinaryPath}:/runtime`,
170+
`--volume=${this.#configPath}:/miniflare-config.bin`,
171+
`--publish=127.0.0.1:${this.entryPort}:8787`,
172+
"debian:bullseye-slim",
173+
"/restart.sh",
174+
"/runtime",
175+
...COMMON_RUNTIME_ARGS,
176+
`--socket-addr=${SOCKET_ENTRY}=*:8787`,
177+
`--external-addr=${SERVICE_LOOPBACK}=host.docker.internal:${this.loopbackPort}`,
178+
"/miniflare-config.bin",
179+
],
180+
{
181+
stdio: "pipe",
182+
shell: true,
183+
}
184+
);
185+
this.#process = runtimeProcess;
186+
this.#processExitPromise = waitForExit(runtimeProcess);
187+
188+
// TODO: may want to proxy these and prettify ✨
189+
runtimeProcess.stdout.pipe(process.stdout);
190+
runtimeProcess.stderr.pipe(process.stderr);
191+
}
192+
193+
dispose(): Awaitable<void> {
194+
this.#process?.kill();
195+
try {
196+
fs.unlinkSync(this.#configPath);
197+
} catch (e: any) {
198+
// Ignore not found errors if we called dispose() without updateConfig()
199+
if (e.code !== "ENOENT") throw e;
200+
}
201+
return this.#processExitPromise;
143202
}
144203
}
145204

@@ -160,7 +219,7 @@ export function getSupportedRuntime(): RuntimeConstructor {
160219
const suggestions = RUNTIMES.map(
161220
({ supportSuggestion }) => `- ${supportSuggestion}`
162221
);
163-
throw new MiniflareError(
222+
throw new MiniflareCoreError(
164223
"ERR_RUNTIME_UNSUPPORTED",
165224
`The 🦄 Cloudflare Workers Runtime 🦄 does not support your system (${
166225
process.platform

0 commit comments

Comments
 (0)