@checkstack/dev-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +50 -0
- package/src/dev-deps-resolver.test.ts +411 -0
- package/src/dev-deps-resolver.ts +215 -0
- package/src/dev-frontend.test.ts +148 -0
- package/src/dev-frontend.ts +198 -0
- package/src/dev-internals.test.ts +506 -0
- package/src/dev-internals.ts +278 -0
- package/src/dev-lifecycle.test.ts +327 -0
- package/src/dev-lifecycle.ts +173 -0
- package/src/dev-server.ts +197 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers used by `dev-server.ts`. Extracted into their own module
|
|
3
|
+
* so they're trivially unit-testable without spawning real processes,
|
|
4
|
+
* watching real filesystems, or hitting Postgres.
|
|
5
|
+
*
|
|
6
|
+
* Anything in this file is deterministic given its inputs. Side-effecting
|
|
7
|
+
* code (process spawn, fs.watch, vite startup) lives in dev-server.ts
|
|
8
|
+
* proper.
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import {
|
|
14
|
+
installPackageMetadataSchema,
|
|
15
|
+
type InstallPackageMetadata,
|
|
16
|
+
} from "@checkstack/common";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Args
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface DevArgs {
|
|
23
|
+
cwd: string;
|
|
24
|
+
port: number;
|
|
25
|
+
frontendPort: number;
|
|
26
|
+
databaseUrl: string;
|
|
27
|
+
watch: boolean;
|
|
28
|
+
showHelp: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse the dev command's CLI arguments. Pure: any env-var fallbacks are
|
|
33
|
+
* passed in via `env` so tests can drive them without touching
|
|
34
|
+
* `process.env`.
|
|
35
|
+
*/
|
|
36
|
+
export function parseDevArgs({
|
|
37
|
+
raw,
|
|
38
|
+
env = process.env,
|
|
39
|
+
cwd = process.cwd(),
|
|
40
|
+
}: {
|
|
41
|
+
raw: string[];
|
|
42
|
+
env?: Record<string, string | undefined>;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
}): DevArgs {
|
|
45
|
+
const args: DevArgs = {
|
|
46
|
+
cwd,
|
|
47
|
+
port: env.PORT ? Number(env.PORT) : 3000,
|
|
48
|
+
frontendPort: env.FRONTEND_PORT ? Number(env.FRONTEND_PORT) : 5173,
|
|
49
|
+
databaseUrl:
|
|
50
|
+
env.DATABASE_URL ??
|
|
51
|
+
"postgresql://checkstack:checkstack@localhost:5432/checkstack",
|
|
52
|
+
watch: true,
|
|
53
|
+
showHelp: false,
|
|
54
|
+
};
|
|
55
|
+
for (let i = 0; i < raw.length; i++) {
|
|
56
|
+
const a = raw[i];
|
|
57
|
+
switch (a) {
|
|
58
|
+
case "--cwd": {
|
|
59
|
+
args.cwd = path.resolve(raw[++i]);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case "--port": {
|
|
63
|
+
args.port = Number(raw[++i]);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "--frontend-port": {
|
|
67
|
+
args.frontendPort = Number(raw[++i]);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "--db-url": {
|
|
71
|
+
args.databaseUrl = raw[++i];
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "--no-watch": {
|
|
75
|
+
args.watch = false;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "--help":
|
|
79
|
+
case "-h": {
|
|
80
|
+
args.showHelp = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return args;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Plugin metadata validation
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export type ValidationResult =
|
|
93
|
+
| { ok: true; metadata: InstallPackageMetadata }
|
|
94
|
+
| { ok: false; missingPackageJson: true }
|
|
95
|
+
| { ok: false; issues: string[] };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate the plugin author's `package.json` against the install-time
|
|
99
|
+
* schema. The dev server runs the same boot code path as production, so
|
|
100
|
+
* it must accept the same metadata that `plugin-pack` produces.
|
|
101
|
+
*
|
|
102
|
+
* The `readFile` injection makes this trivially unit-testable.
|
|
103
|
+
*/
|
|
104
|
+
export function validatePluginPackageJson({
|
|
105
|
+
cwd,
|
|
106
|
+
readFile = (p) => fs.readFileSync(p, "utf8"),
|
|
107
|
+
exists = (p) => fs.existsSync(p),
|
|
108
|
+
}: {
|
|
109
|
+
cwd: string;
|
|
110
|
+
readFile?: (p: string) => string;
|
|
111
|
+
exists?: (p: string) => boolean;
|
|
112
|
+
}): ValidationResult {
|
|
113
|
+
const pkgJsonPath = path.join(cwd, "package.json");
|
|
114
|
+
if (!exists(pkgJsonPath)) {
|
|
115
|
+
return { ok: false, missingPackageJson: true };
|
|
116
|
+
}
|
|
117
|
+
const raw = JSON.parse(readFile(pkgJsonPath)) as unknown;
|
|
118
|
+
const result = installPackageMetadataSchema.safeParse(raw);
|
|
119
|
+
if (!result.success) {
|
|
120
|
+
const issues = result.error.issues.map(
|
|
121
|
+
(issue) => `${issue.path.join(".")}: ${issue.message}`,
|
|
122
|
+
);
|
|
123
|
+
return { ok: false, issues };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, metadata: result.data };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Backend entry resolution
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Locate `@checkstack/backend`'s main entry from the plugin author's
|
|
134
|
+
* node_modules so `bun run <entry>` works whether they pulled in
|
|
135
|
+
* `@checkstack/scripts` (which transitively brings backend in) or
|
|
136
|
+
* `@checkstack/backend` directly.
|
|
137
|
+
*
|
|
138
|
+
* Returns `undefined` when the package can't be resolved at all
|
|
139
|
+
* (probably means `bun install` hasn't run yet).
|
|
140
|
+
*/
|
|
141
|
+
export function resolveBackendEntry({
|
|
142
|
+
fromCwd,
|
|
143
|
+
resolveFrom,
|
|
144
|
+
readFile = (p) => fs.readFileSync(p, "utf8"),
|
|
145
|
+
}: {
|
|
146
|
+
fromCwd: string;
|
|
147
|
+
/** Optional injection point for tests. Defaults to Node's `createRequire`. */
|
|
148
|
+
resolveFrom?: (from: string, request: string) => string | undefined;
|
|
149
|
+
readFile?: (p: string) => string;
|
|
150
|
+
}): string | undefined {
|
|
151
|
+
const resolver =
|
|
152
|
+
resolveFrom ??
|
|
153
|
+
((from: string, request: string): string | undefined => {
|
|
154
|
+
try {
|
|
155
|
+
return createRequire(from).resolve(request);
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
const pkgJsonPath = resolver(
|
|
161
|
+
path.join(fromCwd, "package.json"),
|
|
162
|
+
"@checkstack/backend/package.json",
|
|
163
|
+
);
|
|
164
|
+
if (!pkgJsonPath) return undefined;
|
|
165
|
+
let pkg: { main?: string };
|
|
166
|
+
try {
|
|
167
|
+
pkg = JSON.parse(readFile(pkgJsonPath)) as { main?: string };
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const main = pkg.main ?? "src/index.ts";
|
|
172
|
+
return path.join(path.dirname(pkgJsonPath), main);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Frontend Vite spawn decision
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Determine whether the dev command should also spawn a Vite frontend
|
|
181
|
+
* dev server. True when the plugin under dev is itself a `-frontend`,
|
|
182
|
+
* or when it's a bundle primary (e.g. `-backend`) that ships a
|
|
183
|
+
* `-frontend` sibling in `checkstack.bundle`.
|
|
184
|
+
*/
|
|
185
|
+
export function shouldSpawnFrontend(
|
|
186
|
+
metadata: Pick<InstallPackageMetadata, "checkstack">,
|
|
187
|
+
): boolean {
|
|
188
|
+
if (metadata.checkstack.type === "frontend") return true;
|
|
189
|
+
for (const sibling of metadata.checkstack.bundle ?? []) {
|
|
190
|
+
if (sibling.endsWith("-frontend")) return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
196
|
+
// Child-process env construction
|
|
197
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build the env block for the backend child process. Wraps the dev-mode
|
|
201
|
+
* env vars + sensible defaults for required production env (DB URL,
|
|
202
|
+
* AUTH_SECRET, …). Inputs are explicit so the test can verify the env
|
|
203
|
+
* var shape without spawning anything.
|
|
204
|
+
*/
|
|
205
|
+
export function buildBackendChildEnv({
|
|
206
|
+
args,
|
|
207
|
+
parentEnv,
|
|
208
|
+
extraPluginPaths,
|
|
209
|
+
}: {
|
|
210
|
+
args: DevArgs;
|
|
211
|
+
parentEnv: Record<string, string | undefined>;
|
|
212
|
+
extraPluginPaths: string[];
|
|
213
|
+
}): Record<string, string | undefined> {
|
|
214
|
+
return {
|
|
215
|
+
...parentEnv,
|
|
216
|
+
CHECKSTACK_DEV_PLUGIN_PATH: args.cwd,
|
|
217
|
+
CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS: JSON.stringify(extraPluginPaths),
|
|
218
|
+
CHECKSTACK_DEV_AUTH: "true",
|
|
219
|
+
PORT: String(args.port),
|
|
220
|
+
DATABASE_URL: args.databaseUrl,
|
|
221
|
+
BASE_URL: parentEnv.BASE_URL ?? `http://localhost:${args.port}`,
|
|
222
|
+
AUTH_SECRET: parentEnv.AUTH_SECRET ?? "checkstack-dev-secret",
|
|
223
|
+
NODE_ENV: parentEnv.NODE_ENV ?? "development",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
228
|
+
// Watcher debouncer
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Tiny event coalescer — multiple file events within the debounce window
|
|
233
|
+
* trigger a single `onTrigger` call. Editors fire several events per
|
|
234
|
+
* save (write a temp file, rename, touch metadata); we want one restart.
|
|
235
|
+
*
|
|
236
|
+
* Returns a function that consumers call once per filesystem event;
|
|
237
|
+
* it filters dotfiles and tilde-suffixed editor swap files so they don't
|
|
238
|
+
* cause spurious restarts.
|
|
239
|
+
*
|
|
240
|
+
* The `setTimer`/`clearTimer` pair is injectable so tests can drive the
|
|
241
|
+
* timer synchronously without sleeping.
|
|
242
|
+
*/
|
|
243
|
+
export interface DebouncedWatcher {
|
|
244
|
+
feed(filename: string | undefined): void;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function createDebouncedWatcher({
|
|
248
|
+
onTrigger,
|
|
249
|
+
delayMs = 150,
|
|
250
|
+
setTimer = (fn, ms) => setTimeout(fn, ms) as unknown as TimerHandle,
|
|
251
|
+
clearTimer = (h: TimerHandle) => clearTimeout(h as unknown as Parameters<typeof clearTimeout>[0]),
|
|
252
|
+
}: {
|
|
253
|
+
onTrigger: () => void;
|
|
254
|
+
delayMs?: number;
|
|
255
|
+
setTimer?: (fn: () => void, ms: number) => TimerHandle;
|
|
256
|
+
clearTimer?: (h: TimerHandle) => void;
|
|
257
|
+
}): DebouncedWatcher {
|
|
258
|
+
let pending: TimerHandle | undefined;
|
|
259
|
+
return {
|
|
260
|
+
feed(filename) {
|
|
261
|
+
if (!filename) return;
|
|
262
|
+
// Skip editor temp/swap files and dotfiles. These fire on every
|
|
263
|
+
// save in Vim, VS Code, and IntelliJ and would otherwise restart
|
|
264
|
+
// the backend twice per actual edit.
|
|
265
|
+
if (filename.endsWith("~") || filename.startsWith(".")) return;
|
|
266
|
+
if (pending !== undefined) clearTimer(pending);
|
|
267
|
+
pending = setTimer(() => {
|
|
268
|
+
pending = undefined;
|
|
269
|
+
onTrigger();
|
|
270
|
+
}, delayMs);
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Opaque timer handle — `setTimeout`/`clearTimeout` differ in shape
|
|
276
|
+
// between Node, Bun, and the browser. We don't care about the runtime
|
|
277
|
+
// type, only that it round-trips through `setTimer`/`clearTimer`.
|
|
278
|
+
export type TimerHandle = unknown;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createBackendLifecycle,
|
|
4
|
+
type ChildHandle,
|
|
5
|
+
type Spawner,
|
|
6
|
+
} from "./dev-lifecycle";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fake child + spawner. Records every spawn, every kill signal, and lets
|
|
10
|
+
* tests synthesize an exit by calling `simulateExit`. The lifecycle code
|
|
11
|
+
* does not care about real I/O — only about the lifecycle hooks
|
|
12
|
+
* (`kill` + `onExit`) — so this is a complete double for it.
|
|
13
|
+
*/
|
|
14
|
+
function makeFakeSpawner() {
|
|
15
|
+
interface FakeChild {
|
|
16
|
+
handle: ChildHandle;
|
|
17
|
+
spawnArgs: { command: string; args: string[]; env: Record<string, string | undefined> };
|
|
18
|
+
killSignals: NodeJS.Signals[];
|
|
19
|
+
exitHandlers: Array<
|
|
20
|
+
(code: number | null, signal: NodeJS.Signals | null) => void
|
|
21
|
+
>;
|
|
22
|
+
exited: boolean;
|
|
23
|
+
/** Trigger the registered onExit handlers. */
|
|
24
|
+
simulateExit: (code: number | null, signal?: NodeJS.Signals | null) => void;
|
|
25
|
+
}
|
|
26
|
+
const children: FakeChild[] = [];
|
|
27
|
+
|
|
28
|
+
const spawner: Spawner = {
|
|
29
|
+
spawn(spawnArgs) {
|
|
30
|
+
const fake: FakeChild = {
|
|
31
|
+
handle: undefined as never,
|
|
32
|
+
spawnArgs,
|
|
33
|
+
killSignals: [],
|
|
34
|
+
exitHandlers: [],
|
|
35
|
+
exited: false,
|
|
36
|
+
simulateExit: (code, signal = null) => {
|
|
37
|
+
if (fake.exited) return;
|
|
38
|
+
fake.exited = true;
|
|
39
|
+
for (const handler of fake.exitHandlers) handler(code, signal);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
fake.handle = {
|
|
43
|
+
kill: (signal: NodeJS.Signals) => {
|
|
44
|
+
fake.killSignals.push(signal);
|
|
45
|
+
},
|
|
46
|
+
onExit: (handler) => {
|
|
47
|
+
fake.exitHandlers.push(handler);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
children.push(fake);
|
|
51
|
+
return fake.handle;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return { spawner, children };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mini fake clock for the SIGKILL escalation timer. Tracks scheduled
|
|
59
|
+
* callbacks; tests advance time explicitly. We do not use a real
|
|
60
|
+
* setTimeout because we don't want to wait 5s in unit tests.
|
|
61
|
+
*/
|
|
62
|
+
function makeFakeClock() {
|
|
63
|
+
interface T {
|
|
64
|
+
handle: number;
|
|
65
|
+
fireAt: number;
|
|
66
|
+
fn: () => void;
|
|
67
|
+
}
|
|
68
|
+
const scheduled = new Map<number, T>();
|
|
69
|
+
let now = 0;
|
|
70
|
+
let nextHandle = 1;
|
|
71
|
+
return {
|
|
72
|
+
setTimer: (fn: () => void, ms: number) => {
|
|
73
|
+
const handle = nextHandle++;
|
|
74
|
+
scheduled.set(handle, { handle, fireAt: now + ms, fn });
|
|
75
|
+
return handle;
|
|
76
|
+
},
|
|
77
|
+
clearTimer: (h: unknown) => {
|
|
78
|
+
scheduled.delete(h as number);
|
|
79
|
+
},
|
|
80
|
+
advance: (ms: number) => {
|
|
81
|
+
now += ms;
|
|
82
|
+
const due = [...scheduled.values()]
|
|
83
|
+
.filter((t) => t.fireAt <= now)
|
|
84
|
+
.toSorted((a, b) => a.fireAt - b.fireAt);
|
|
85
|
+
for (const t of due) {
|
|
86
|
+
scheduled.delete(t.handle);
|
|
87
|
+
t.fn();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
pending: () => [...scheduled.values()],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SPAWN_ARGS = {
|
|
95
|
+
command: "bun",
|
|
96
|
+
args: ["run", "/path/to/backend/src/index.ts"],
|
|
97
|
+
env: { CHECKSTACK_DEV_AUTH: "true" },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
describe("createBackendLifecycle", () => {
|
|
101
|
+
it("start() spawns exactly one child with the given args", () => {
|
|
102
|
+
const { spawner, children } = makeFakeSpawner();
|
|
103
|
+
const lc = createBackendLifecycle({
|
|
104
|
+
spawnArgs: SPAWN_ARGS,
|
|
105
|
+
spawner,
|
|
106
|
+
hooks: { onNaturalExit: () => {} },
|
|
107
|
+
onLog: () => {},
|
|
108
|
+
});
|
|
109
|
+
lc.start();
|
|
110
|
+
expect(children).toHaveLength(1);
|
|
111
|
+
expect(children[0].spawnArgs).toEqual(SPAWN_ARGS);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("start() is idempotent — calling twice doesn't double-spawn", () => {
|
|
115
|
+
const { spawner, children } = makeFakeSpawner();
|
|
116
|
+
const lc = createBackendLifecycle({
|
|
117
|
+
spawnArgs: SPAWN_ARGS,
|
|
118
|
+
spawner,
|
|
119
|
+
hooks: { onNaturalExit: () => {} },
|
|
120
|
+
onLog: () => {},
|
|
121
|
+
});
|
|
122
|
+
lc.start();
|
|
123
|
+
lc.start();
|
|
124
|
+
expect(children).toHaveLength(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("natural exit invokes onNaturalExit hook with the exit code", () => {
|
|
128
|
+
const { spawner, children } = makeFakeSpawner();
|
|
129
|
+
let capturedCode: number | null | undefined;
|
|
130
|
+
const lc = createBackendLifecycle({
|
|
131
|
+
spawnArgs: SPAWN_ARGS,
|
|
132
|
+
spawner,
|
|
133
|
+
hooks: {
|
|
134
|
+
onNaturalExit: (code) => {
|
|
135
|
+
capturedCode = code;
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
onLog: () => {},
|
|
139
|
+
});
|
|
140
|
+
lc.start();
|
|
141
|
+
children[0].simulateExit(0);
|
|
142
|
+
expect(capturedCode).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("restart() sends SIGTERM to the running child and respawns after exit", () => {
|
|
146
|
+
const { spawner, children } = makeFakeSpawner();
|
|
147
|
+
const lc = createBackendLifecycle({
|
|
148
|
+
spawnArgs: SPAWN_ARGS,
|
|
149
|
+
spawner,
|
|
150
|
+
hooks: { onNaturalExit: () => {} },
|
|
151
|
+
onLog: () => {},
|
|
152
|
+
});
|
|
153
|
+
lc.start();
|
|
154
|
+
expect(children).toHaveLength(1);
|
|
155
|
+
|
|
156
|
+
lc.restart();
|
|
157
|
+
expect(children[0].killSignals).toEqual(["SIGTERM"]);
|
|
158
|
+
expect(children).toHaveLength(1); // not yet — child must exit first
|
|
159
|
+
|
|
160
|
+
// Simulate the child cleaning up and exiting after SIGTERM
|
|
161
|
+
children[0].simulateExit(null, "SIGTERM");
|
|
162
|
+
expect(children).toHaveLength(2);
|
|
163
|
+
expect(children[1].spawnArgs).toEqual(SPAWN_ARGS);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("restart() during an in-flight restart is a no-op (idempotent)", () => {
|
|
167
|
+
const { spawner, children } = makeFakeSpawner();
|
|
168
|
+
const lc = createBackendLifecycle({
|
|
169
|
+
spawnArgs: SPAWN_ARGS,
|
|
170
|
+
spawner,
|
|
171
|
+
hooks: { onNaturalExit: () => {} },
|
|
172
|
+
onLog: () => {},
|
|
173
|
+
});
|
|
174
|
+
lc.start();
|
|
175
|
+
lc.restart();
|
|
176
|
+
lc.restart(); // double-fire while still restarting
|
|
177
|
+
lc.restart();
|
|
178
|
+
expect(children[0].killSignals).toEqual(["SIGTERM"]); // still just one
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("multiple sequential restarts work — child cycles through", () => {
|
|
182
|
+
const { spawner, children } = makeFakeSpawner();
|
|
183
|
+
const lc = createBackendLifecycle({
|
|
184
|
+
spawnArgs: SPAWN_ARGS,
|
|
185
|
+
spawner,
|
|
186
|
+
hooks: { onNaturalExit: () => {} },
|
|
187
|
+
onLog: () => {},
|
|
188
|
+
});
|
|
189
|
+
lc.start();
|
|
190
|
+
// Restart 1
|
|
191
|
+
lc.restart();
|
|
192
|
+
children[0].simulateExit(null, "SIGTERM");
|
|
193
|
+
// Restart 2
|
|
194
|
+
lc.restart();
|
|
195
|
+
children[1].simulateExit(null, "SIGTERM");
|
|
196
|
+
expect(children).toHaveLength(3);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("exit during natural shutdown after restart() is treated as a restart", () => {
|
|
200
|
+
// i.e. the lifecycle did issue the SIGTERM, the child took time but
|
|
201
|
+
// exited cleanly with code 0 — we should still spawn the replacement
|
|
202
|
+
// because the user asked for a restart.
|
|
203
|
+
const { spawner, children } = makeFakeSpawner();
|
|
204
|
+
const naturalExits: Array<number | null> = [];
|
|
205
|
+
const lc = createBackendLifecycle({
|
|
206
|
+
spawnArgs: SPAWN_ARGS,
|
|
207
|
+
spawner,
|
|
208
|
+
hooks: { onNaturalExit: (code) => naturalExits.push(code) },
|
|
209
|
+
onLog: () => {},
|
|
210
|
+
});
|
|
211
|
+
lc.start();
|
|
212
|
+
lc.restart();
|
|
213
|
+
children[0].simulateExit(0);
|
|
214
|
+
expect(children).toHaveLength(2);
|
|
215
|
+
expect(naturalExits).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("hard-kill timer fires SIGKILL when SIGTERM doesn't bring the child down in time", () => {
|
|
219
|
+
const { spawner, children } = makeFakeSpawner();
|
|
220
|
+
const clock = makeFakeClock();
|
|
221
|
+
const lc = createBackendLifecycle({
|
|
222
|
+
spawnArgs: SPAWN_ARGS,
|
|
223
|
+
spawner,
|
|
224
|
+
hooks: { onNaturalExit: () => {} },
|
|
225
|
+
onLog: () => {},
|
|
226
|
+
setHardKillTimer: clock.setTimer,
|
|
227
|
+
clearHardKillTimer: clock.clearTimer,
|
|
228
|
+
hardKillDelayMs: 5000,
|
|
229
|
+
});
|
|
230
|
+
lc.start();
|
|
231
|
+
lc.restart();
|
|
232
|
+
expect(children[0].killSignals).toEqual(["SIGTERM"]);
|
|
233
|
+
expect(clock.pending()).toHaveLength(1);
|
|
234
|
+
|
|
235
|
+
// Advance past the hard-kill threshold without simulating exit.
|
|
236
|
+
clock.advance(5000);
|
|
237
|
+
expect(children[0].killSignals).toEqual(["SIGTERM", "SIGKILL"]);
|
|
238
|
+
|
|
239
|
+
// Eventually the OS does kill it. The replacement should still spawn.
|
|
240
|
+
children[0].simulateExit(137, "SIGKILL");
|
|
241
|
+
expect(children).toHaveLength(2);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("hard-kill timer is cleared if the child exits before the deadline", () => {
|
|
245
|
+
const { spawner, children } = makeFakeSpawner();
|
|
246
|
+
const clock = makeFakeClock();
|
|
247
|
+
const lc = createBackendLifecycle({
|
|
248
|
+
spawnArgs: SPAWN_ARGS,
|
|
249
|
+
spawner,
|
|
250
|
+
hooks: { onNaturalExit: () => {} },
|
|
251
|
+
onLog: () => {},
|
|
252
|
+
setHardKillTimer: clock.setTimer,
|
|
253
|
+
clearHardKillTimer: clock.clearTimer,
|
|
254
|
+
hardKillDelayMs: 5000,
|
|
255
|
+
});
|
|
256
|
+
lc.start();
|
|
257
|
+
lc.restart();
|
|
258
|
+
children[0].simulateExit(null, "SIGTERM");
|
|
259
|
+
// Replacement should be running with no leftover hard-kill timer
|
|
260
|
+
expect(clock.pending()).toHaveLength(0);
|
|
261
|
+
expect(children).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("shutdown(signal) forwards the signal and prevents the natural-exit hook from triggering a respawn loop", () => {
|
|
265
|
+
const { spawner, children } = makeFakeSpawner();
|
|
266
|
+
let naturalExits = 0;
|
|
267
|
+
const lc = createBackendLifecycle({
|
|
268
|
+
spawnArgs: SPAWN_ARGS,
|
|
269
|
+
spawner,
|
|
270
|
+
hooks: { onNaturalExit: () => naturalExits++ },
|
|
271
|
+
onLog: () => {},
|
|
272
|
+
});
|
|
273
|
+
lc.start();
|
|
274
|
+
lc.shutdown("SIGINT");
|
|
275
|
+
expect(children[0].killSignals).toEqual(["SIGINT"]);
|
|
276
|
+
children[0].simulateExit(0, "SIGINT");
|
|
277
|
+
// Natural exit hook NOT called during a shutdown — process.exit
|
|
278
|
+
// happens via the parent's signal handler instead.
|
|
279
|
+
expect(naturalExits).toBe(0);
|
|
280
|
+
expect(children).toHaveLength(1); // no respawn
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("shutdown() called before start() is a safe no-op", () => {
|
|
284
|
+
const { spawner, children } = makeFakeSpawner();
|
|
285
|
+
const lc = createBackendLifecycle({
|
|
286
|
+
spawnArgs: SPAWN_ARGS,
|
|
287
|
+
spawner,
|
|
288
|
+
hooks: { onNaturalExit: () => {} },
|
|
289
|
+
onLog: () => {},
|
|
290
|
+
});
|
|
291
|
+
lc.shutdown("SIGTERM");
|
|
292
|
+
expect(children).toHaveLength(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("restart() after shutdown() is ignored (we're already shutting down)", () => {
|
|
296
|
+
const { spawner, children } = makeFakeSpawner();
|
|
297
|
+
const lc = createBackendLifecycle({
|
|
298
|
+
spawnArgs: SPAWN_ARGS,
|
|
299
|
+
spawner,
|
|
300
|
+
hooks: { onNaturalExit: () => {} },
|
|
301
|
+
onLog: () => {},
|
|
302
|
+
});
|
|
303
|
+
lc.start();
|
|
304
|
+
lc.shutdown("SIGTERM");
|
|
305
|
+
lc.restart();
|
|
306
|
+
// Only the shutdown signal made it to the child
|
|
307
|
+
expect(children[0].killSignals).toEqual(["SIGTERM"]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("logs key lifecycle events through the injected onLog", () => {
|
|
311
|
+
const { spawner, children } = makeFakeSpawner();
|
|
312
|
+
const logs: string[] = [];
|
|
313
|
+
const lc = createBackendLifecycle({
|
|
314
|
+
spawnArgs: SPAWN_ARGS,
|
|
315
|
+
spawner,
|
|
316
|
+
hooks: { onNaturalExit: () => {} },
|
|
317
|
+
onLog: (line) => logs.push(line),
|
|
318
|
+
});
|
|
319
|
+
lc.start();
|
|
320
|
+
lc.restart();
|
|
321
|
+
children[0].simulateExit(null, "SIGTERM");
|
|
322
|
+
expect(logs.some((l) => l.includes("Starting backend"))).toBe(true);
|
|
323
|
+
expect(logs.some((l) => l.includes("Plugin source changed"))).toBe(true);
|
|
324
|
+
// After respawn, we log the start line again
|
|
325
|
+
expect(logs.filter((l) => l.includes("Starting backend"))).toHaveLength(2);
|
|
326
|
+
});
|
|
327
|
+
});
|