@aws-blocks/core 0.1.4 → 0.1.10

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.
Files changed (91) hide show
  1. package/dist/cdk/index.d.ts +1 -1
  2. package/dist/cdk/index.d.ts.map +1 -1
  3. package/dist/cdk/index.js +1 -1
  4. package/dist/client/index.d.ts +1 -1
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +1 -1
  7. package/dist/db-naming.d.ts +17 -5
  8. package/dist/db-naming.d.ts.map +1 -1
  9. package/dist/db-naming.js +18 -6
  10. package/dist/db-naming.test.js +44 -3
  11. package/dist/errors.d.ts +28 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +27 -1
  14. package/dist/errors.test.d.ts +2 -0
  15. package/dist/errors.test.d.ts.map +1 -0
  16. package/dist/errors.test.js +47 -0
  17. package/dist/hosting.d.ts +71 -1
  18. package/dist/hosting.d.ts.map +1 -1
  19. package/dist/hosting.js +47 -8
  20. package/dist/hosting.test.js +60 -0
  21. package/dist/index.cdk.d.ts +1 -1
  22. package/dist/index.cdk.d.ts.map +1 -1
  23. package/dist/index.cdk.js +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/scripts/deploy.d.ts.map +1 -1
  28. package/dist/scripts/deploy.js +4 -2
  29. package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
  30. package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
  31. package/dist/scripts/dev-server-supervisor.test.js +551 -0
  32. package/dist/scripts/dev-server.d.ts +73 -0
  33. package/dist/scripts/dev-server.d.ts.map +1 -1
  34. package/dist/scripts/dev-server.js +279 -29
  35. package/dist/scripts/ensure-secrets.d.ts +5 -2
  36. package/dist/scripts/ensure-secrets.d.ts.map +1 -1
  37. package/dist/scripts/ensure-secrets.js +14 -6
  38. package/dist/scripts/external-migrations-step.d.ts.map +1 -1
  39. package/dist/scripts/external-migrations-step.js +5 -1
  40. package/dist/scripts/index.d.ts +1 -0
  41. package/dist/scripts/index.d.ts.map +1 -1
  42. package/dist/scripts/index.js +1 -0
  43. package/dist/scripts/process-tree.d.ts +126 -0
  44. package/dist/scripts/process-tree.d.ts.map +1 -0
  45. package/dist/scripts/process-tree.js +198 -0
  46. package/dist/scripts/sandbox.d.ts.map +1 -1
  47. package/dist/scripts/sandbox.js +51 -4
  48. package/dist/scripts/stack-id.d.ts +37 -0
  49. package/dist/scripts/stack-id.d.ts.map +1 -0
  50. package/dist/scripts/stack-id.js +79 -0
  51. package/dist/scripts/stack-id.test.d.ts +2 -0
  52. package/dist/scripts/stack-id.test.d.ts.map +1 -0
  53. package/dist/scripts/stack-id.test.js +104 -0
  54. package/dist/telemetry/client.d.ts +3 -1
  55. package/dist/telemetry/client.d.ts.map +1 -1
  56. package/dist/telemetry/client.js +20 -24
  57. package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
  58. package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
  59. package/dist/telemetry/telemetry-send-worker.js +58 -0
  60. package/dist/telemetry/telemetry.test.js +77 -1
  61. package/dist/telemetry/trackCommand.d.ts +1 -1
  62. package/dist/telemetry/trackCommand.js +3 -3
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.d.ts.map +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +1 -1
  67. package/src/cdk/index.ts +1 -1
  68. package/src/client/index.ts +1 -1
  69. package/src/db-naming.test.ts +50 -5
  70. package/src/db-naming.ts +18 -6
  71. package/src/errors.test.ts +55 -0
  72. package/src/errors.ts +32 -1
  73. package/src/hosting.test.ts +79 -0
  74. package/src/hosting.ts +127 -13
  75. package/src/index.cdk.ts +1 -1
  76. package/src/index.ts +1 -1
  77. package/src/scripts/deploy.ts +4 -2
  78. package/src/scripts/dev-server-supervisor.test.ts +621 -0
  79. package/src/scripts/dev-server.ts +316 -27
  80. package/src/scripts/ensure-secrets.ts +17 -6
  81. package/src/scripts/external-migrations-step.ts +5 -1
  82. package/src/scripts/index.ts +1 -0
  83. package/src/scripts/process-tree.ts +245 -0
  84. package/src/scripts/sandbox.ts +50 -4
  85. package/src/scripts/stack-id.test.ts +123 -0
  86. package/src/scripts/stack-id.ts +87 -0
  87. package/src/telemetry/client.ts +22 -30
  88. package/src/telemetry/telemetry-send-worker.ts +60 -0
  89. package/src/telemetry/telemetry.test.ts +91 -1
  90. package/src/telemetry/trackCommand.ts +3 -3
  91. package/src/version.ts +1 -1
@@ -0,0 +1,621 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { spawn } from 'node:child_process';
6
+ import { createConnection, createServer } from 'node:net';
7
+ import { readFileSync, unlinkSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import {
11
+ evaluateFrontendRespawn,
12
+ DEFAULT_FRONTEND_RESPAWN_POLICY,
13
+ waitForPortFree,
14
+ shouldCreditFrontendReady,
15
+ } from './dev-server.js';
16
+ import {
17
+ killFrontendTree,
18
+ windowsTreeKill,
19
+ terminateProcessTree,
20
+ isProcessGroupAlive,
21
+ KILL_GRACE_MS,
22
+ type KillableProcess,
23
+ type AwaitableChild,
24
+ } from './process-tree.js';
25
+
26
+ const isWindows = process.platform === 'win32';
27
+ // The real-process integration test spawns OS processes and depends on
28
+ // wall-clock timing; gate it behind RUN_SLOW_TESTS so the default test run
29
+ // stays deterministic and fast. The pure unit tests below always run.
30
+ const runSlowTests = !!process.env.RUN_SLOW_TESTS;
31
+
32
+ function getFreePort(): Promise<number> {
33
+ return new Promise((resolve, reject) => {
34
+ const srv = createServer();
35
+ srv.on('error', reject);
36
+ srv.listen(0, '127.0.0.1', () => {
37
+ const addr = srv.address();
38
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
39
+ srv.close(() => resolve(port));
40
+ });
41
+ });
42
+ }
43
+
44
+ function isPortOpen(port: number): Promise<boolean> {
45
+ return new Promise((resolve) => {
46
+ const sock = createConnection({ port, host: '127.0.0.1' }, () => {
47
+ sock.destroy();
48
+ resolve(true);
49
+ });
50
+ sock.on('error', () => { sock.destroy(); resolve(false); });
51
+ sock.setTimeout(300, () => { sock.destroy(); resolve(false); });
52
+ });
53
+ }
54
+
55
+ async function waitFor(predicate: () => Promise<boolean>, timeoutMs: number): Promise<boolean> {
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (Date.now() < deadline) {
58
+ if (await predicate()) return true;
59
+ await new Promise((r) => setTimeout(r, 50));
60
+ }
61
+ return predicate();
62
+ }
63
+
64
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
65
+
66
+ // Poll across a bounded window, returning false the instant the port closes.
67
+ // More robust than a single post-`delay` snapshot on a busy/slow host: it
68
+ // asserts the port stays bound for the whole window rather than at one moment.
69
+ async function staysOpen(port: number, windowMs: number): Promise<boolean> {
70
+ const deadline = Date.now() + windowMs;
71
+ while (Date.now() < deadline) {
72
+ if (!(await isPortOpen(port))) return false;
73
+ await delay(50);
74
+ }
75
+ return true;
76
+ }
77
+
78
+ // ── evaluateFrontendRespawn ────────────────────────────────────────────────
79
+ describe('evaluateFrontendRespawn — bounded auto-respawn policy', () => {
80
+ it('allows the first restart with the base backoff', () => {
81
+ const now = 1_000_000;
82
+ const d = evaluateFrontendRespawn([], now);
83
+ assert.strictEqual(d.restart, true);
84
+ assert.strictEqual(d.delayMs, DEFAULT_FRONTEND_RESPAWN_POLICY.backoffMs);
85
+ assert.deepStrictEqual(d.recent, [now]);
86
+ });
87
+
88
+ it('backs off exponentially with the number of recent restarts', () => {
89
+ const now = 1_000_000;
90
+ // one recent restart already → 500 * 2^1
91
+ assert.strictEqual(evaluateFrontendRespawn([now - 100], now).delayMs, 1000);
92
+ // three recent → 500 * 2^3
93
+ assert.strictEqual(evaluateFrontendRespawn([now - 30, now - 20, now - 10], now).delayMs, 4000);
94
+ });
95
+
96
+ it('caps the backoff at maxBackoffMs', () => {
97
+ const now = 1_000_000;
98
+ // four recent → 500 * 2^4 = 8000, capped to 5000
99
+ const d = evaluateFrontendRespawn([now - 4, now - 3, now - 2, now - 1], now);
100
+ assert.strictEqual(d.restart, true);
101
+ assert.strictEqual(d.delayMs, DEFAULT_FRONTEND_RESPAWN_POLICY.maxBackoffMs);
102
+ });
103
+
104
+ it('stops restarting once the budget within the window is exhausted', () => {
105
+ const now = 1_000_000;
106
+ const recent = [now - 5, now - 4, now - 3, now - 2, now - 1]; // 5 == maxRestarts
107
+ const d = evaluateFrontendRespawn(recent, now);
108
+ assert.strictEqual(d.restart, false);
109
+ assert.strictEqual(d.delayMs, 0);
110
+ assert.strictEqual(d.recent.length, DEFAULT_FRONTEND_RESPAWN_POLICY.maxRestarts);
111
+ });
112
+
113
+ it('forgets restarts that fall outside the sliding window', () => {
114
+ const now = 1_000_000;
115
+ const { windowMs } = DEFAULT_FRONTEND_RESPAWN_POLICY;
116
+ const recent = [
117
+ now - windowMs - 1, // stale
118
+ now - windowMs - 2, // stale
119
+ now - windowMs - 3, // stale
120
+ now - windowMs - 4, // stale
121
+ now - 100, // in-window
122
+ ];
123
+ const d = evaluateFrontendRespawn(recent, now);
124
+ assert.strictEqual(d.restart, true);
125
+ // only the one in-window timestamp survives → backoff 500 * 2^1
126
+ assert.strictEqual(d.delayMs, 1000);
127
+ assert.deepStrictEqual(d.recent, [now - 100, now]);
128
+ });
129
+
130
+ it('honors a custom policy', () => {
131
+ const now = 0;
132
+ const policy = { maxRestarts: 1, windowMs: 1000, backoffMs: 100, maxBackoffMs: 200 };
133
+ assert.strictEqual(evaluateFrontendRespawn([], now, policy).delayMs, 100);
134
+ assert.strictEqual(evaluateFrontendRespawn([now], now, policy).restart, false);
135
+ });
136
+ });
137
+
138
+ // ── killFrontendTree (unit, injected spies) ─────────────────────────────────
139
+ describe('killFrontendTree — signal routing', () => {
140
+ function makeChild(pid: number | undefined) {
141
+ const calls: Array<NodeJS.Signals | number | undefined> = [];
142
+ const child: KillableProcess = {
143
+ pid,
144
+ kill(signal) { calls.push(signal); return true; },
145
+ };
146
+ return { child, calls };
147
+ }
148
+
149
+ it('signals the whole process group on POSIX (negative pid)', () => {
150
+ const { child, calls } = makeChild(4242);
151
+ const groupCalls: Array<[number, NodeJS.Signals]> = [];
152
+ killFrontendTree(child, 'SIGTERM', 'linux', (pid, sig) => { groupCalls.push([pid, sig]); });
153
+ assert.deepStrictEqual(groupCalls, [[-4242, 'SIGTERM']]);
154
+ assert.deepStrictEqual(calls, []); // direct child.kill not used on POSIX
155
+ });
156
+
157
+ it('reaps the tree via taskkill on Windows (no POSIX group, no direct kill)', () => {
158
+ const { child, calls } = makeChild(4242);
159
+ let groupCalled = false;
160
+ const winPids: number[] = [];
161
+ killFrontendTree(child, 'SIGTERM', 'win32',
162
+ () => { groupCalled = true; },
163
+ (pid) => { winPids.push(pid); return true; });
164
+ assert.strictEqual(groupCalled, false); // POSIX group kill not used on Windows
165
+ assert.deepStrictEqual(winPids, [4242]); // taskkill tree-kill invoked with the pid
166
+ assert.deepStrictEqual(calls, []); // no direct child.kill once taskkill succeeded
167
+ });
168
+
169
+ it('falls back to a direct child kill on Windows when taskkill cannot run', () => {
170
+ const { child, calls } = makeChild(4242);
171
+ killFrontendTree(child, 'SIGTERM', 'win32',
172
+ () => {},
173
+ () => false); // taskkill unavailable → must degrade to child.kill
174
+ assert.deepStrictEqual(calls, ['SIGTERM']);
175
+ });
176
+
177
+ it('falls back to a direct child kill when the group signal throws', () => {
178
+ const { child, calls } = makeChild(4242);
179
+ killFrontendTree(child, 'SIGKILL', 'linux', () => {
180
+ throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' });
181
+ });
182
+ assert.deepStrictEqual(calls, ['SIGKILL']);
183
+ });
184
+
185
+ it('uses a direct child kill when there is no pid', () => {
186
+ const { child, calls } = makeChild(undefined);
187
+ let groupCalled = false;
188
+ killFrontendTree(child, 'SIGTERM', 'linux', () => { groupCalled = true; });
189
+ assert.strictEqual(groupCalled, false);
190
+ assert.deepStrictEqual(calls, ['SIGTERM']);
191
+ });
192
+
193
+ it('never group-signals pid <= 1 (defensive)', () => {
194
+ const { child, calls } = makeChild(1);
195
+ let groupCalled = false;
196
+ killFrontendTree(child, 'SIGTERM', 'linux', () => { groupCalled = true; });
197
+ assert.strictEqual(groupCalled, false);
198
+ assert.deepStrictEqual(calls, ['SIGTERM']);
199
+ });
200
+ });
201
+
202
+ // ── windowsTreeKill (unit, injected runner) ─────────────────────────────────
203
+ describe('windowsTreeKill — taskkill tree-kill command', () => {
204
+ it('invokes `taskkill /T /F /PID <pid>` and reports success', () => {
205
+ const runs: Array<[string, readonly string[]]> = [];
206
+ const ok = windowsTreeKill(4242, (cmd, args) => { runs.push([cmd, args]); return { status: 0 }; });
207
+ assert.strictEqual(ok, true);
208
+ assert.deepStrictEqual(runs, [['taskkill', ['/T', '/F', '/PID', '4242']]]);
209
+ });
210
+
211
+ it('treats an already-gone tree (non-zero exit, no spawn error) as handled', () => {
212
+ const ok = windowsTreeKill(4242, () => ({ status: 128 })); // 128 = "process not found"
213
+ assert.strictEqual(ok, true);
214
+ });
215
+
216
+ it('reports failure when taskkill cannot be spawned (caller then falls back)', () => {
217
+ const ok = windowsTreeKill(4242, () => ({ status: null, error: new Error('ENOENT') }));
218
+ assert.strictEqual(ok, false);
219
+ });
220
+
221
+ it('reports failure when taskkill runs but returns a non-zero, non-128 status', () => {
222
+ // taskkill spawned fine (no error) but FAILED to reap the tree — e.g. exit 1
223
+ // = access denied. It must NOT be treated as handled, or the caller skips
224
+ // its child.kill fallback and silently leaks the tree.
225
+ assert.strictEqual(windowsTreeKill(4242, () => ({ status: 1 })), false);
226
+ // A taskkill killed by a signal (status null, no spawn error) is likewise
227
+ // not a successful reap.
228
+ assert.strictEqual(windowsTreeKill(4242, () => ({ status: null })), false);
229
+ });
230
+
231
+ it('never throws even if the runner throws', () => {
232
+ const ok = windowsTreeKill(4242, () => { throw new Error('boom'); });
233
+ assert.strictEqual(ok, false);
234
+ });
235
+ });
236
+
237
+ // ── isProcessGroupAlive (unit, injected kill + platform) ────────────────────
238
+ // Scopes the post-exit group SIGKILL: a `-pid` signal is only PID-reuse-safe
239
+ // while a group member is still alive, so terminateProcessTree probes here and
240
+ // skips the reap once the group has drained.
241
+ describe('isProcessGroupAlive — group-liveness probe (signal 0)', () => {
242
+ const esrch = () => { throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); };
243
+ const eperm = () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); };
244
+
245
+ it('probes the GROUP with signal 0 (negative pid, no real signal) and reports alive on success', () => {
246
+ const calls: Array<[number, number]> = [];
247
+ const alive = isProcessGroupAlive(4242, 'linux', (pid, sig) => { calls.push([pid, sig]); });
248
+ assert.strictEqual(alive, true);
249
+ assert.deepStrictEqual(calls, [[-4242, 0]]); // group probe, signal 0 = existence check only
250
+ });
251
+
252
+ it('reports drained (false) when the group is gone (ESRCH)', () => {
253
+ assert.strictEqual(isProcessGroupAlive(4242, 'linux', esrch), false);
254
+ });
255
+
256
+ it('reports alive (true) when the group exists but is not ours to signal (EPERM)', () => {
257
+ assert.strictEqual(isProcessGroupAlive(4242, 'linux', eperm), true);
258
+ });
259
+
260
+ it('always reports alive on Windows (no process groups; taskkill is PID-tree-scoped)', () => {
261
+ let probed = false;
262
+ const alive = isProcessGroupAlive(4242, 'win32', () => { probed = true; });
263
+ assert.strictEqual(alive, true);
264
+ assert.strictEqual(probed, false); // never probes on Windows
265
+ });
266
+ });
267
+
268
+ // ── killFrontendTree (integration, real shell + grandchild) ─────────────────
269
+ // Opt-in: spawns real OS processes and relies on wall-clock timing, so it is
270
+ // gated behind RUN_SLOW_TESTS to keep the default `npm test` deterministic.
271
+ // POSIX-only because it asserts process-group reaping.
272
+ const integrationSkip = !runSlowTests
273
+ ? 'set RUN_SLOW_TESTS=1 to run (spawns real processes)'
274
+ : isWindows
275
+ ? 'POSIX-only (relies on process groups)'
276
+ : false;
277
+
278
+ describe('killFrontendTree — reaps a real detached shell tree', { skip: integrationSkip }, () => {
279
+ it('frees a port held by a grandchild that survives a direct child kill',
280
+ { timeout: 30000 },
281
+ async () => {
282
+ const port = await getFreePort();
283
+ // getFreePort() closes its probe listener before this grandchild binds,
284
+ // so on a busy host another process can grab the port in that gap. Retry
285
+ // briefly on EADDRINUSE (creating a fresh server each attempt) instead of
286
+ // exiting on the first error, so the shell→node→port topology under test
287
+ // is reliably established; bail only on a non-transient error or once the
288
+ // retry budget (~5s) is exhausted.
289
+ const inner =
290
+ `const net=require('net');` +
291
+ `const port=${port};` +
292
+ `let tries=0;` +
293
+ `(function bind(){` +
294
+ `const s=net.createServer(c=>c.destroy());` +
295
+ `s.on('error',e=>{` +
296
+ `if(e.code==='EADDRINUSE'&&tries++<50){setTimeout(bind,100);return;}` +
297
+ `process.exit(1);` +
298
+ `});` +
299
+ `s.listen(port,'127.0.0.1');` +
300
+ `})();` +
301
+ `setInterval(()=>{},1e9);`;
302
+ // Run node as a backgrounded child of the shell (then `wait`): this gives
303
+ // the shell→node parent/grandchild topology of `shell: true` without any
304
+ // exec-optimization. SIGTERM to the shell does NOT propagate to the
305
+ // backgrounded node, so the node is orphaned and keeps the port — exactly
306
+ // the leak the fix must reap via a process-group kill.
307
+ const cmd = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(inner)} & wait`;
308
+ const child = spawn(cmd, { shell: true, detached: true, stdio: 'ignore' });
309
+
310
+ try {
311
+ assert.ok(await waitFor(() => isPortOpen(port), 10000),
312
+ 'frontend grandchild should bind the port');
313
+
314
+ // Direct kill of only the shell parent (what the old cleanup did): the
315
+ // backgrounded node grandchild is orphaned, survives, and keeps the
316
+ // port bound — this is exactly the :3100 502 leak.
317
+ child.kill('SIGTERM');
318
+ // Poll across a bounded window instead of a single post-`delay`
319
+ // snapshot so a slow/busy host can't race the assertion.
320
+ assert.ok(await staysOpen(port, 600),
321
+ 'direct child kill must NOT free the port (demonstrates the orphan bug)');
322
+
323
+ // The shell parent is now gone, so the group kill below is exercised as
324
+ // a POST-EXIT reap — the exact case the reconciled kill policy relies on:
325
+ // the orphaned grandchild keeps the process group alive (pgid === the
326
+ // exited shell's pid), so `process.kill(-pid)` still targets our group.
327
+ assert.ok(
328
+ await waitFor(async () => child.exitCode !== null || child.signalCode !== null, 5000),
329
+ 'shell parent should have exited after SIGTERM (sets up the post-exit reap)');
330
+
331
+ // Group kill reaps the entire tree and frees the port — the fix. It
332
+ // works even though the shell parent has already exited (asserted above).
333
+ killFrontendTree(child, 'SIGKILL');
334
+ assert.ok(await waitFor(async () => !(await isPortOpen(port)), 10000),
335
+ 'group kill must free the port (the fix)');
336
+ } finally {
337
+ // Belt-and-suspenders: never leak the node grandchild if an assert throws.
338
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }
339
+ }
340
+ });
341
+ });
342
+
343
+ // ── terminateProcessTree (integration, detached NON-shell tree) ─────────────
344
+ // Mirrors the sandbox `cdk watch` topology (npx → cdk → node, no shell) that
345
+ // finding #1 switched from a bare `cdkWatch.kill()` to `terminateProcessTree`.
346
+ // Opt-in + POSIX-only for the same reasons as the shell-tree test above.
347
+ describe('terminateProcessTree — reaps a detached non-shell tree (cdk-watch shape)', { skip: integrationSkip }, () => {
348
+ it('group-reaps an npx-style parent whose grandchild holds a port, even post-exit',
349
+ { timeout: 30000 },
350
+ async () => {
351
+ const port = await getFreePort();
352
+ // Grandchild (stands in for cdk-watch's node): binds the port and idles.
353
+ // Retry on EADDRINUSE like the shell-tree test, since getFreePort()
354
+ // releases its probe listener before this grandchild can rebind.
355
+ const inner =
356
+ `const net=require('net');` +
357
+ `const port=${port};` +
358
+ `let tries=0;` +
359
+ `(function bind(){` +
360
+ `const s=net.createServer(c=>c.destroy());` +
361
+ `s.on('error',e=>{` +
362
+ `if(e.code==='EADDRINUSE'&&tries++<50){setTimeout(bind,100);return;}` +
363
+ `process.exit(1);` +
364
+ `});` +
365
+ `s.listen(port,'127.0.0.1');` +
366
+ `})();` +
367
+ `setInterval(()=>{},1e9);`;
368
+ // Parent (stands in for `npx`): spawns the port-binding grandchild as a
369
+ // normal (NON-detached) child, then idles. No shell anywhere — the
370
+ // cdk-watch shape. Spawning the PARENT `detached` makes it a process-group
371
+ // leader (pgid === parent.pid) that the grandchild inherits, so a single
372
+ // `process.kill(-pid)` reaches both.
373
+ const parent =
374
+ `const cp=require('child_process');` +
375
+ `cp.spawn(process.execPath,['-e',${JSON.stringify(inner)}],{stdio:'ignore'});` +
376
+ `setInterval(()=>{},1e9);`;
377
+ const child = spawn(process.execPath, ['-e', parent], { detached: true, stdio: 'ignore' });
378
+
379
+ try {
380
+ assert.ok(await waitFor(() => isPortOpen(port), 10000),
381
+ 'grandchild should bind the port');
382
+
383
+ // A direct kill of ONLY the parent (what the old bare `cdkWatch.kill()`
384
+ // did) orphans the grandchild, which survives still holding the port —
385
+ // the exact shell/parent-only-kill leak finding #1 eliminates.
386
+ child.kill('SIGTERM');
387
+ assert.ok(await staysOpen(port, 600),
388
+ 'direct parent kill must NOT free the port (demonstrates the orphan)');
389
+ assert.ok(
390
+ await waitFor(async () => child.exitCode !== null || child.signalCode !== null, 5000),
391
+ 'parent should have exited after SIGTERM (sets up the post-exit reap)');
392
+
393
+ // terminateProcessTree now takes the post-exit branch (parent already
394
+ // gone) and issues a group SIGKILL via the shared killFrontendTree,
395
+ // reaping the orphaned grandchild and freeing the port — what the new
396
+ // `terminateProcessTree(cdkWatch, …)` call guarantees in sandbox.ts.
397
+ const reaped = await terminateProcessTree(child, 2000);
398
+ assert.strictEqual(reaped, true, 'post-exit terminateProcessTree resolves true');
399
+ assert.ok(await waitFor(async () => !(await isPortOpen(port)), 10000),
400
+ 'group kill must free the port held by the grandchild (the fix)');
401
+ } finally {
402
+ // Belt-and-suspenders: never leak the grandchild if an assert throws.
403
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }
404
+ }
405
+ });
406
+ });
407
+
408
+ // ── group SIGTERM → nested node handler (sandbox dev-server teardown) ────────
409
+ // Verifies the load-bearing claim in sandbox.ts: a *group* SIGTERM (what
410
+ // killFrontendTree issues on POSIX, via terminateProcessTree) actually reaches
411
+ // the nested node dev server so its OWN SIGTERM handler (the :3100 drain) runs —
412
+ // not merely that the tree gets reaped. Opt-in + POSIX-only like the reaping
413
+ // tests above.
414
+ describe('group SIGTERM reaches a nested node child and runs its SIGTERM handler', { skip: integrationSkip }, () => {
415
+ it('delivers a group SIGTERM to a detached shell→node child whose node handler observes it',
416
+ { timeout: 30000 },
417
+ async () => {
418
+ const port = await getFreePort();
419
+ const marker = join(tmpdir(), `blocks-sigterm-${process.pid}-${Date.now()}.flag`);
420
+ // node grandchild: install a SIGTERM handler that RECORDS it observed the
421
+ // signal (writes the marker) before exiting, THEN bind the port to
422
+ // announce readiness. Installing the handler before binding guarantees the
423
+ // test never sends SIGTERM before the handler exists (which would
424
+ // default-terminate node and flake the assertion). Retry on EADDRINUSE
425
+ // like the reaping tests, since getFreePort() releases its probe listener.
426
+ const inner =
427
+ `const net=require('net'),fs=require('fs');` +
428
+ `process.on('SIGTERM',()=>{try{fs.writeFileSync(${JSON.stringify(marker)},'sigterm');}catch{}process.exit(0);});` +
429
+ `let tries=0;` +
430
+ `(function bind(){` +
431
+ `const s=net.createServer(c=>c.destroy());` +
432
+ `s.on('error',e=>{` +
433
+ `if(e.code==='EADDRINUSE'&&tries++<50){setTimeout(bind,100);return;}` +
434
+ `process.exit(1);` +
435
+ `});` +
436
+ `s.listen(${port},'127.0.0.1');` +
437
+ `})();` +
438
+ `setInterval(()=>{},1e9);`;
439
+ // shell → node grandchild, backgrounded with `& wait` so the shell stays
440
+ // the live group leader — the exact `shell: true` + detached topology the
441
+ // dev server uses, where a group signal must fan out to the nested node.
442
+ const cmd = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(inner)} & wait`;
443
+ const child = spawn(cmd, { shell: true, detached: true, stdio: 'ignore' });
444
+
445
+ try {
446
+ assert.ok(await waitFor(() => isPortOpen(port), 10000),
447
+ 'nested node should install its SIGTERM handler then bind the port (ready)');
448
+ assert.ok(child.pid && child.pid > 1, 'child must have a real pid to group-signal');
449
+
450
+ // The group SIGTERM under test: exactly what killFrontendTree does on
451
+ // POSIX (`process.kill(-pid, 'SIGTERM')`) and what the sandbox dev-server
452
+ // teardown relies on to trigger the node's own terminateFrontend handler.
453
+ // The `if (child.pid)` guard (asserted above) narrows the type without a
454
+ // non-null assertion — matching the finally-block pattern used in this file.
455
+ if (child.pid) process.kill(-child.pid, 'SIGTERM');
456
+
457
+ // Assert the node handler OBSERVED the signal (wrote the marker) — i.e.
458
+ // the group SIGTERM reached the *nested* node, not just the shell.
459
+ const observed = await waitFor(async () => {
460
+ try { return readFileSync(marker, 'utf8') === 'sigterm'; } catch { return false; }
461
+ }, 10000);
462
+ assert.ok(observed, 'the nested node SIGTERM handler must run on a group SIGTERM');
463
+ } finally {
464
+ if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }
465
+ try { unlinkSync(marker); } catch { /* never created */ }
466
+ }
467
+ });
468
+ });
469
+
470
+ // ── shouldCreditFrontendReady (unit) ────────────────────────────────────────
471
+ // Guards the restart-budget reset: a liveness-only port probe must not credit a
472
+ // foreign listener, or the maxRestarts cap is neutralized and Vite hot-loops.
473
+ describe('shouldCreditFrontendReady — credit only our own live child', () => {
474
+ const liveChild = () => ({ exitCode: null as number | null, signalCode: null as NodeJS.Signals | null });
475
+
476
+ it('credits the probe when our spawned child is still the live frontend', () => {
477
+ const child = liveChild();
478
+ assert.strictEqual(shouldCreditFrontendReady(child, child), true);
479
+ });
480
+
481
+ it('does NOT credit a foreign listener (a different or absent current child)', () => {
482
+ const child = liveChild();
483
+ assert.strictEqual(shouldCreditFrontendReady(child, liveChild()), false);
484
+ assert.strictEqual(shouldCreditFrontendReady(child, null), false);
485
+ });
486
+
487
+ it('does NOT credit a child that already exited (lost the --strictPort bind race)', () => {
488
+ const exited = { exitCode: 1 as number | null, signalCode: null as NodeJS.Signals | null };
489
+ assert.strictEqual(shouldCreditFrontendReady(exited, exited), false);
490
+ const killed = { exitCode: null as number | null, signalCode: 'SIGKILL' as NodeJS.Signals | null };
491
+ assert.strictEqual(shouldCreditFrontendReady(killed, killed), false);
492
+ });
493
+
494
+ it('does NOT credit when there is no child', () => {
495
+ assert.strictEqual(shouldCreditFrontendReady(null, null), false);
496
+ });
497
+ });
498
+
499
+ // ── terminateProcessTree (unit, injected killTree + sleep) ──────────────────
500
+ // The shared SIGTERM->SIGKILL escalation used by both the dev server and the
501
+ // sandbox entrypoint. Dependencies are injected so the policy is exercised
502
+ // without spawning real processes or real timers.
503
+ describe('terminateProcessTree — escalation + post-exit reap', () => {
504
+ const immediate = (_ms: number): Promise<void> => Promise.resolve();
505
+ const never = (_ms: number): Promise<void> => new Promise<void>(() => {});
506
+
507
+ function makeAwaitableChild(
508
+ initial: { exitCode?: number | null; signalCode?: NodeJS.Signals | null } = {},
509
+ ): { child: AwaitableChild; fireExit: (code?: number | null, signal?: NodeJS.Signals | null) => void } {
510
+ let exitListener: (() => void) | null = null;
511
+ const child: AwaitableChild = {
512
+ pid: 4242,
513
+ exitCode: initial.exitCode ?? null,
514
+ signalCode: initial.signalCode ?? null,
515
+ kill() { return true; },
516
+ once(_event: 'exit', listener: () => void) { exitListener = listener; return child; },
517
+ };
518
+ const fireExit = (code: number | null = 0, signal: NodeJS.Signals | null = null) => {
519
+ child.exitCode = code;
520
+ child.signalCode = signal;
521
+ exitListener?.();
522
+ };
523
+ return { child, fireExit };
524
+ }
525
+
526
+ it('issues a single best-effort group SIGKILL when the child already exited and its group is still alive', async () => {
527
+ const { child } = makeAwaitableChild({ exitCode: 0 });
528
+ const sent: NodeJS.Signals[] = [];
529
+ // Group still has a live member (an orphaned grandchild) → reap it.
530
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, never, () => true);
531
+ assert.strictEqual(ok, true);
532
+ assert.deepStrictEqual(sent, ['SIGKILL']); // post-exit reap: no SIGTERM, no await
533
+ });
534
+
535
+ it('skips the post-exit group SIGKILL when the whole group has already drained', async () => {
536
+ const { child } = makeAwaitableChild({ exitCode: 0 });
537
+ const sent: NodeJS.Signals[] = [];
538
+ // The group has fully drained: a `-pid` SIGKILL risks signalling a recycled,
539
+ // unrelated group, and there is nothing of ours left to reap — so skip it.
540
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, never, () => false);
541
+ assert.strictEqual(ok, true); // still resolves true (the child has exited)
542
+ assert.deepStrictEqual(sent, []); // no signal sent into the drained group
543
+ });
544
+
545
+ it('SIGTERMs the tree and resolves without escalating when it exits in time', async () => {
546
+ const { child, fireExit } = makeAwaitableChild();
547
+ const sent: NodeJS.Signals[] = [];
548
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => {
549
+ sent.push(s);
550
+ if (s === 'SIGTERM') fireExit(0, null); // clean exit wins the grace race
551
+ }, never);
552
+ assert.strictEqual(ok, true);
553
+ assert.deepStrictEqual(sent, ['SIGTERM']);
554
+ });
555
+
556
+ it('escalates to a tree SIGKILL when the child lingers past the grace window', async () => {
557
+ const { child, fireExit } = makeAwaitableChild();
558
+ const sent: NodeJS.Signals[] = [];
559
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => {
560
+ sent.push(s);
561
+ if (s === 'SIGKILL') fireExit(null, 'SIGKILL'); // dies only on SIGKILL
562
+ }, immediate);
563
+ assert.deepStrictEqual(sent, ['SIGTERM', 'SIGKILL']);
564
+ assert.strictEqual(ok, true);
565
+ });
566
+
567
+ it('reports false when the child is still alive after the SIGKILL grace', async () => {
568
+ const { child } = makeAwaitableChild();
569
+ const sent: NodeJS.Signals[] = [];
570
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, immediate);
571
+ assert.deepStrictEqual(sent, ['SIGTERM', 'SIGKILL']);
572
+ assert.strictEqual(ok, false);
573
+ });
574
+
575
+ it('waits graceMs for the SIGTERM grace and the shorter KILL_GRACE_MS after SIGKILL', async () => {
576
+ const { child } = makeAwaitableChild();
577
+ const sent: NodeJS.Signals[] = [];
578
+ const slept: number[] = [];
579
+ // Resolve every sleep immediately (so both grace races resolve) but record
580
+ // the requested durations to prove which grace each escalation step uses.
581
+ const recordingSleep = (ms: number): Promise<void> => { slept.push(ms); return Promise.resolve(); };
582
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, recordingSleep);
583
+ assert.deepStrictEqual(sent, ['SIGTERM', 'SIGKILL']);
584
+ // First grace == the injectable SIGTERM graceMs; second == the fixed,
585
+ // deliberately shorter post-SIGKILL grace constant.
586
+ assert.deepStrictEqual(slept, [1500, KILL_GRACE_MS]);
587
+ assert.ok(KILL_GRACE_MS < 1500, 'the post-SIGKILL grace must be shorter than the SIGTERM grace');
588
+ assert.strictEqual(ok, false); // child never fired exit
589
+ });
590
+ });
591
+
592
+ // ── waitForPortFree (real sockets, fast + deterministic) ────────────────────
593
+ // Asserts the "wait for the port to free before exiting" invariant that the
594
+ // post-exit terminate path used to drop.
595
+ describe('waitForPortFree — waits for the listener to release the port', () => {
596
+ it('returns promptly when nothing holds the port', async () => {
597
+ const port = await getFreePort();
598
+ const t0 = Date.now();
599
+ await waitForPortFree(port, 2000);
600
+ assert.ok(Date.now() - t0 < 1000, 'should return quickly when the port is already free');
601
+ });
602
+
603
+ it('keeps polling while the port is held, then resolves once it closes', async () => {
604
+ const port = await getFreePort();
605
+ const srv = createServer((c) => c.destroy());
606
+ await new Promise<void>((res) => srv.listen(port, '127.0.0.1', () => res()));
607
+
608
+ // A short bounded wait must consume ~its whole budget while the port is held;
609
+ // it can only return early if it (wrongly) sees the held port as free.
610
+ const t0 = Date.now();
611
+ await waitForPortFree(port, 300);
612
+ assert.ok(Date.now() - t0 >= 250, 'must keep polling while the port is held');
613
+
614
+ await new Promise<void>((res) => srv.close(() => res()));
615
+
616
+ // Once free, a generous-timeout call returns promptly.
617
+ const t1 = Date.now();
618
+ await waitForPortFree(port, 3000);
619
+ assert.ok(Date.now() - t1 < 1500, 'should resolve soon after the port frees');
620
+ });
621
+ });