@aws-blocks/core 0.1.3 → 0.1.7

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 (66) 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/constants.d.ts +12 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +12 -0
  10. package/dist/errors.d.ts +28 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +27 -1
  13. package/dist/errors.test.d.ts +2 -0
  14. package/dist/errors.test.d.ts.map +1 -0
  15. package/dist/errors.test.js +47 -0
  16. package/dist/hosting.d.ts +22 -1
  17. package/dist/hosting.d.ts.map +1 -1
  18. package/dist/index.cdk.d.ts +1 -1
  19. package/dist/index.cdk.d.ts.map +1 -1
  20. package/dist/index.cdk.js +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/scripts/dev-server-config.test.d.ts +2 -0
  25. package/dist/scripts/dev-server-config.test.d.ts.map +1 -0
  26. package/dist/scripts/dev-server-config.test.js +37 -0
  27. package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
  28. package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
  29. package/dist/scripts/dev-server-supervisor.test.js +551 -0
  30. package/dist/scripts/dev-server.d.ts +92 -0
  31. package/dist/scripts/dev-server.d.ts.map +1 -1
  32. package/dist/scripts/dev-server.js +317 -34
  33. package/dist/scripts/index.d.ts +1 -0
  34. package/dist/scripts/index.d.ts.map +1 -1
  35. package/dist/scripts/index.js +1 -0
  36. package/dist/scripts/process-tree.d.ts +126 -0
  37. package/dist/scripts/process-tree.d.ts.map +1 -0
  38. package/dist/scripts/process-tree.js +198 -0
  39. package/dist/scripts/sandbox.d.ts.map +1 -1
  40. package/dist/scripts/sandbox.js +41 -3
  41. package/dist/scripts/stack-id.d.ts +12 -0
  42. package/dist/scripts/stack-id.d.ts.map +1 -0
  43. package/dist/scripts/stack-id.js +54 -0
  44. package/dist/scripts/stack-id.test.d.ts +2 -0
  45. package/dist/scripts/stack-id.test.d.ts.map +1 -0
  46. package/dist/scripts/stack-id.test.js +54 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
  50. package/src/cdk/index.ts +1 -1
  51. package/src/client/index.ts +1 -1
  52. package/src/constants.ts +13 -0
  53. package/src/errors.test.ts +55 -0
  54. package/src/errors.ts +32 -1
  55. package/src/hosting.ts +22 -1
  56. package/src/index.cdk.ts +1 -1
  57. package/src/index.ts +1 -1
  58. package/src/scripts/dev-server-config.test.ts +43 -0
  59. package/src/scripts/dev-server-supervisor.test.ts +621 -0
  60. package/src/scripts/dev-server.ts +364 -33
  61. package/src/scripts/index.ts +1 -0
  62. package/src/scripts/process-tree.ts +245 -0
  63. package/src/scripts/sandbox.ts +40 -3
  64. package/src/scripts/stack-id.test.ts +63 -0
  65. package/src/scripts/stack-id.ts +61 -0
  66. package/src/version.ts +1 -1
@@ -0,0 +1,551 @@
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 { evaluateFrontendRespawn, DEFAULT_FRONTEND_RESPAWN_POLICY, waitForPortFree, shouldCreditFrontendReady, } from './dev-server.js';
11
+ import { killFrontendTree, windowsTreeKill, terminateProcessTree, isProcessGroupAlive, KILL_GRACE_MS, } from './process-tree.js';
12
+ const isWindows = process.platform === 'win32';
13
+ // The real-process integration test spawns OS processes and depends on
14
+ // wall-clock timing; gate it behind RUN_SLOW_TESTS so the default test run
15
+ // stays deterministic and fast. The pure unit tests below always run.
16
+ const runSlowTests = !!process.env.RUN_SLOW_TESTS;
17
+ function getFreePort() {
18
+ return new Promise((resolve, reject) => {
19
+ const srv = createServer();
20
+ srv.on('error', reject);
21
+ srv.listen(0, '127.0.0.1', () => {
22
+ const addr = srv.address();
23
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
24
+ srv.close(() => resolve(port));
25
+ });
26
+ });
27
+ }
28
+ function isPortOpen(port) {
29
+ return new Promise((resolve) => {
30
+ const sock = createConnection({ port, host: '127.0.0.1' }, () => {
31
+ sock.destroy();
32
+ resolve(true);
33
+ });
34
+ sock.on('error', () => { sock.destroy(); resolve(false); });
35
+ sock.setTimeout(300, () => { sock.destroy(); resolve(false); });
36
+ });
37
+ }
38
+ async function waitFor(predicate, timeoutMs) {
39
+ const deadline = Date.now() + timeoutMs;
40
+ while (Date.now() < deadline) {
41
+ if (await predicate())
42
+ return true;
43
+ await new Promise((r) => setTimeout(r, 50));
44
+ }
45
+ return predicate();
46
+ }
47
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
48
+ // Poll across a bounded window, returning false the instant the port closes.
49
+ // More robust than a single post-`delay` snapshot on a busy/slow host: it
50
+ // asserts the port stays bound for the whole window rather than at one moment.
51
+ async function staysOpen(port, windowMs) {
52
+ const deadline = Date.now() + windowMs;
53
+ while (Date.now() < deadline) {
54
+ if (!(await isPortOpen(port)))
55
+ return false;
56
+ await delay(50);
57
+ }
58
+ return true;
59
+ }
60
+ // ── evaluateFrontendRespawn ────────────────────────────────────────────────
61
+ describe('evaluateFrontendRespawn — bounded auto-respawn policy', () => {
62
+ it('allows the first restart with the base backoff', () => {
63
+ const now = 1_000_000;
64
+ const d = evaluateFrontendRespawn([], now);
65
+ assert.strictEqual(d.restart, true);
66
+ assert.strictEqual(d.delayMs, DEFAULT_FRONTEND_RESPAWN_POLICY.backoffMs);
67
+ assert.deepStrictEqual(d.recent, [now]);
68
+ });
69
+ it('backs off exponentially with the number of recent restarts', () => {
70
+ const now = 1_000_000;
71
+ // one recent restart already → 500 * 2^1
72
+ assert.strictEqual(evaluateFrontendRespawn([now - 100], now).delayMs, 1000);
73
+ // three recent → 500 * 2^3
74
+ assert.strictEqual(evaluateFrontendRespawn([now - 30, now - 20, now - 10], now).delayMs, 4000);
75
+ });
76
+ it('caps the backoff at maxBackoffMs', () => {
77
+ const now = 1_000_000;
78
+ // four recent → 500 * 2^4 = 8000, capped to 5000
79
+ const d = evaluateFrontendRespawn([now - 4, now - 3, now - 2, now - 1], now);
80
+ assert.strictEqual(d.restart, true);
81
+ assert.strictEqual(d.delayMs, DEFAULT_FRONTEND_RESPAWN_POLICY.maxBackoffMs);
82
+ });
83
+ it('stops restarting once the budget within the window is exhausted', () => {
84
+ const now = 1_000_000;
85
+ const recent = [now - 5, now - 4, now - 3, now - 2, now - 1]; // 5 == maxRestarts
86
+ const d = evaluateFrontendRespawn(recent, now);
87
+ assert.strictEqual(d.restart, false);
88
+ assert.strictEqual(d.delayMs, 0);
89
+ assert.strictEqual(d.recent.length, DEFAULT_FRONTEND_RESPAWN_POLICY.maxRestarts);
90
+ });
91
+ it('forgets restarts that fall outside the sliding window', () => {
92
+ const now = 1_000_000;
93
+ const { windowMs } = DEFAULT_FRONTEND_RESPAWN_POLICY;
94
+ const recent = [
95
+ now - windowMs - 1, // stale
96
+ now - windowMs - 2, // stale
97
+ now - windowMs - 3, // stale
98
+ now - windowMs - 4, // stale
99
+ now - 100, // in-window
100
+ ];
101
+ const d = evaluateFrontendRespawn(recent, now);
102
+ assert.strictEqual(d.restart, true);
103
+ // only the one in-window timestamp survives → backoff 500 * 2^1
104
+ assert.strictEqual(d.delayMs, 1000);
105
+ assert.deepStrictEqual(d.recent, [now - 100, now]);
106
+ });
107
+ it('honors a custom policy', () => {
108
+ const now = 0;
109
+ const policy = { maxRestarts: 1, windowMs: 1000, backoffMs: 100, maxBackoffMs: 200 };
110
+ assert.strictEqual(evaluateFrontendRespawn([], now, policy).delayMs, 100);
111
+ assert.strictEqual(evaluateFrontendRespawn([now], now, policy).restart, false);
112
+ });
113
+ });
114
+ // ── killFrontendTree (unit, injected spies) ─────────────────────────────────
115
+ describe('killFrontendTree — signal routing', () => {
116
+ function makeChild(pid) {
117
+ const calls = [];
118
+ const child = {
119
+ pid,
120
+ kill(signal) { calls.push(signal); return true; },
121
+ };
122
+ return { child, calls };
123
+ }
124
+ it('signals the whole process group on POSIX (negative pid)', () => {
125
+ const { child, calls } = makeChild(4242);
126
+ const groupCalls = [];
127
+ killFrontendTree(child, 'SIGTERM', 'linux', (pid, sig) => { groupCalls.push([pid, sig]); });
128
+ assert.deepStrictEqual(groupCalls, [[-4242, 'SIGTERM']]);
129
+ assert.deepStrictEqual(calls, []); // direct child.kill not used on POSIX
130
+ });
131
+ it('reaps the tree via taskkill on Windows (no POSIX group, no direct kill)', () => {
132
+ const { child, calls } = makeChild(4242);
133
+ let groupCalled = false;
134
+ const winPids = [];
135
+ killFrontendTree(child, 'SIGTERM', 'win32', () => { groupCalled = true; }, (pid) => { winPids.push(pid); return true; });
136
+ assert.strictEqual(groupCalled, false); // POSIX group kill not used on Windows
137
+ assert.deepStrictEqual(winPids, [4242]); // taskkill tree-kill invoked with the pid
138
+ assert.deepStrictEqual(calls, []); // no direct child.kill once taskkill succeeded
139
+ });
140
+ it('falls back to a direct child kill on Windows when taskkill cannot run', () => {
141
+ const { child, calls } = makeChild(4242);
142
+ killFrontendTree(child, 'SIGTERM', 'win32', () => { }, () => false); // taskkill unavailable → must degrade to child.kill
143
+ assert.deepStrictEqual(calls, ['SIGTERM']);
144
+ });
145
+ it('falls back to a direct child kill when the group signal throws', () => {
146
+ const { child, calls } = makeChild(4242);
147
+ killFrontendTree(child, 'SIGKILL', 'linux', () => {
148
+ throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' });
149
+ });
150
+ assert.deepStrictEqual(calls, ['SIGKILL']);
151
+ });
152
+ it('uses a direct child kill when there is no pid', () => {
153
+ const { child, calls } = makeChild(undefined);
154
+ let groupCalled = false;
155
+ killFrontendTree(child, 'SIGTERM', 'linux', () => { groupCalled = true; });
156
+ assert.strictEqual(groupCalled, false);
157
+ assert.deepStrictEqual(calls, ['SIGTERM']);
158
+ });
159
+ it('never group-signals pid <= 1 (defensive)', () => {
160
+ const { child, calls } = makeChild(1);
161
+ let groupCalled = false;
162
+ killFrontendTree(child, 'SIGTERM', 'linux', () => { groupCalled = true; });
163
+ assert.strictEqual(groupCalled, false);
164
+ assert.deepStrictEqual(calls, ['SIGTERM']);
165
+ });
166
+ });
167
+ // ── windowsTreeKill (unit, injected runner) ─────────────────────────────────
168
+ describe('windowsTreeKill — taskkill tree-kill command', () => {
169
+ it('invokes `taskkill /T /F /PID <pid>` and reports success', () => {
170
+ const runs = [];
171
+ const ok = windowsTreeKill(4242, (cmd, args) => { runs.push([cmd, args]); return { status: 0 }; });
172
+ assert.strictEqual(ok, true);
173
+ assert.deepStrictEqual(runs, [['taskkill', ['/T', '/F', '/PID', '4242']]]);
174
+ });
175
+ it('treats an already-gone tree (non-zero exit, no spawn error) as handled', () => {
176
+ const ok = windowsTreeKill(4242, () => ({ status: 128 })); // 128 = "process not found"
177
+ assert.strictEqual(ok, true);
178
+ });
179
+ it('reports failure when taskkill cannot be spawned (caller then falls back)', () => {
180
+ const ok = windowsTreeKill(4242, () => ({ status: null, error: new Error('ENOENT') }));
181
+ assert.strictEqual(ok, false);
182
+ });
183
+ it('reports failure when taskkill runs but returns a non-zero, non-128 status', () => {
184
+ // taskkill spawned fine (no error) but FAILED to reap the tree — e.g. exit 1
185
+ // = access denied. It must NOT be treated as handled, or the caller skips
186
+ // its child.kill fallback and silently leaks the tree.
187
+ assert.strictEqual(windowsTreeKill(4242, () => ({ status: 1 })), false);
188
+ // A taskkill killed by a signal (status null, no spawn error) is likewise
189
+ // not a successful reap.
190
+ assert.strictEqual(windowsTreeKill(4242, () => ({ status: null })), false);
191
+ });
192
+ it('never throws even if the runner throws', () => {
193
+ const ok = windowsTreeKill(4242, () => { throw new Error('boom'); });
194
+ assert.strictEqual(ok, false);
195
+ });
196
+ });
197
+ // ── isProcessGroupAlive (unit, injected kill + platform) ────────────────────
198
+ // Scopes the post-exit group SIGKILL: a `-pid` signal is only PID-reuse-safe
199
+ // while a group member is still alive, so terminateProcessTree probes here and
200
+ // skips the reap once the group has drained.
201
+ describe('isProcessGroupAlive — group-liveness probe (signal 0)', () => {
202
+ const esrch = () => { throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); };
203
+ const eperm = () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); };
204
+ it('probes the GROUP with signal 0 (negative pid, no real signal) and reports alive on success', () => {
205
+ const calls = [];
206
+ const alive = isProcessGroupAlive(4242, 'linux', (pid, sig) => { calls.push([pid, sig]); });
207
+ assert.strictEqual(alive, true);
208
+ assert.deepStrictEqual(calls, [[-4242, 0]]); // group probe, signal 0 = existence check only
209
+ });
210
+ it('reports drained (false) when the group is gone (ESRCH)', () => {
211
+ assert.strictEqual(isProcessGroupAlive(4242, 'linux', esrch), false);
212
+ });
213
+ it('reports alive (true) when the group exists but is not ours to signal (EPERM)', () => {
214
+ assert.strictEqual(isProcessGroupAlive(4242, 'linux', eperm), true);
215
+ });
216
+ it('always reports alive on Windows (no process groups; taskkill is PID-tree-scoped)', () => {
217
+ let probed = false;
218
+ const alive = isProcessGroupAlive(4242, 'win32', () => { probed = true; });
219
+ assert.strictEqual(alive, true);
220
+ assert.strictEqual(probed, false); // never probes on Windows
221
+ });
222
+ });
223
+ // ── killFrontendTree (integration, real shell + grandchild) ─────────────────
224
+ // Opt-in: spawns real OS processes and relies on wall-clock timing, so it is
225
+ // gated behind RUN_SLOW_TESTS to keep the default `npm test` deterministic.
226
+ // POSIX-only because it asserts process-group reaping.
227
+ const integrationSkip = !runSlowTests
228
+ ? 'set RUN_SLOW_TESTS=1 to run (spawns real processes)'
229
+ : isWindows
230
+ ? 'POSIX-only (relies on process groups)'
231
+ : false;
232
+ describe('killFrontendTree — reaps a real detached shell tree', { skip: integrationSkip }, () => {
233
+ it('frees a port held by a grandchild that survives a direct child kill', { timeout: 30000 }, async () => {
234
+ const port = await getFreePort();
235
+ // getFreePort() closes its probe listener before this grandchild binds,
236
+ // so on a busy host another process can grab the port in that gap. Retry
237
+ // briefly on EADDRINUSE (creating a fresh server each attempt) instead of
238
+ // exiting on the first error, so the shell→node→port topology under test
239
+ // is reliably established; bail only on a non-transient error or once the
240
+ // retry budget (~5s) is exhausted.
241
+ const inner = `const net=require('net');` +
242
+ `const port=${port};` +
243
+ `let tries=0;` +
244
+ `(function bind(){` +
245
+ `const s=net.createServer(c=>c.destroy());` +
246
+ `s.on('error',e=>{` +
247
+ `if(e.code==='EADDRINUSE'&&tries++<50){setTimeout(bind,100);return;}` +
248
+ `process.exit(1);` +
249
+ `});` +
250
+ `s.listen(port,'127.0.0.1');` +
251
+ `})();` +
252
+ `setInterval(()=>{},1e9);`;
253
+ // Run node as a backgrounded child of the shell (then `wait`): this gives
254
+ // the shell→node parent/grandchild topology of `shell: true` without any
255
+ // exec-optimization. SIGTERM to the shell does NOT propagate to the
256
+ // backgrounded node, so the node is orphaned and keeps the port — exactly
257
+ // the leak the fix must reap via a process-group kill.
258
+ const cmd = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(inner)} & wait`;
259
+ const child = spawn(cmd, { shell: true, detached: true, stdio: 'ignore' });
260
+ try {
261
+ assert.ok(await waitFor(() => isPortOpen(port), 10000), 'frontend grandchild should bind the port');
262
+ // Direct kill of only the shell parent (what the old cleanup did): the
263
+ // backgrounded node grandchild is orphaned, survives, and keeps the
264
+ // port bound — this is exactly the :3100 502 leak.
265
+ child.kill('SIGTERM');
266
+ // Poll across a bounded window instead of a single post-`delay`
267
+ // snapshot so a slow/busy host can't race the assertion.
268
+ assert.ok(await staysOpen(port, 600), 'direct child kill must NOT free the port (demonstrates the orphan bug)');
269
+ // The shell parent is now gone, so the group kill below is exercised as
270
+ // a POST-EXIT reap — the exact case the reconciled kill policy relies on:
271
+ // the orphaned grandchild keeps the process group alive (pgid === the
272
+ // exited shell's pid), so `process.kill(-pid)` still targets our group.
273
+ assert.ok(await waitFor(async () => child.exitCode !== null || child.signalCode !== null, 5000), 'shell parent should have exited after SIGTERM (sets up the post-exit reap)');
274
+ // Group kill reaps the entire tree and frees the port — the fix. It
275
+ // works even though the shell parent has already exited (asserted above).
276
+ killFrontendTree(child, 'SIGKILL');
277
+ assert.ok(await waitFor(async () => !(await isPortOpen(port)), 10000), 'group kill must free the port (the fix)');
278
+ }
279
+ finally {
280
+ // Belt-and-suspenders: never leak the node grandchild if an assert throws.
281
+ if (child.pid) {
282
+ try {
283
+ process.kill(-child.pid, 'SIGKILL');
284
+ }
285
+ catch { /* gone */ }
286
+ }
287
+ }
288
+ });
289
+ });
290
+ // ── terminateProcessTree (integration, detached NON-shell tree) ─────────────
291
+ // Mirrors the sandbox `cdk watch` topology (npx → cdk → node, no shell) that
292
+ // finding #1 switched from a bare `cdkWatch.kill()` to `terminateProcessTree`.
293
+ // Opt-in + POSIX-only for the same reasons as the shell-tree test above.
294
+ describe('terminateProcessTree — reaps a detached non-shell tree (cdk-watch shape)', { skip: integrationSkip }, () => {
295
+ it('group-reaps an npx-style parent whose grandchild holds a port, even post-exit', { timeout: 30000 }, async () => {
296
+ const port = await getFreePort();
297
+ // Grandchild (stands in for cdk-watch's node): binds the port and idles.
298
+ // Retry on EADDRINUSE like the shell-tree test, since getFreePort()
299
+ // releases its probe listener before this grandchild can rebind.
300
+ const inner = `const net=require('net');` +
301
+ `const port=${port};` +
302
+ `let tries=0;` +
303
+ `(function bind(){` +
304
+ `const s=net.createServer(c=>c.destroy());` +
305
+ `s.on('error',e=>{` +
306
+ `if(e.code==='EADDRINUSE'&&tries++<50){setTimeout(bind,100);return;}` +
307
+ `process.exit(1);` +
308
+ `});` +
309
+ `s.listen(port,'127.0.0.1');` +
310
+ `})();` +
311
+ `setInterval(()=>{},1e9);`;
312
+ // Parent (stands in for `npx`): spawns the port-binding grandchild as a
313
+ // normal (NON-detached) child, then idles. No shell anywhere — the
314
+ // cdk-watch shape. Spawning the PARENT `detached` makes it a process-group
315
+ // leader (pgid === parent.pid) that the grandchild inherits, so a single
316
+ // `process.kill(-pid)` reaches both.
317
+ const parent = `const cp=require('child_process');` +
318
+ `cp.spawn(process.execPath,['-e',${JSON.stringify(inner)}],{stdio:'ignore'});` +
319
+ `setInterval(()=>{},1e9);`;
320
+ const child = spawn(process.execPath, ['-e', parent], { detached: true, stdio: 'ignore' });
321
+ try {
322
+ assert.ok(await waitFor(() => isPortOpen(port), 10000), 'grandchild should bind the port');
323
+ // A direct kill of ONLY the parent (what the old bare `cdkWatch.kill()`
324
+ // did) orphans the grandchild, which survives still holding the port —
325
+ // the exact shell/parent-only-kill leak finding #1 eliminates.
326
+ child.kill('SIGTERM');
327
+ assert.ok(await staysOpen(port, 600), 'direct parent kill must NOT free the port (demonstrates the orphan)');
328
+ assert.ok(await waitFor(async () => child.exitCode !== null || child.signalCode !== null, 5000), 'parent should have exited after SIGTERM (sets up the post-exit reap)');
329
+ // terminateProcessTree now takes the post-exit branch (parent already
330
+ // gone) and issues a group SIGKILL via the shared killFrontendTree,
331
+ // reaping the orphaned grandchild and freeing the port — what the new
332
+ // `terminateProcessTree(cdkWatch, …)` call guarantees in sandbox.ts.
333
+ const reaped = await terminateProcessTree(child, 2000);
334
+ assert.strictEqual(reaped, true, 'post-exit terminateProcessTree resolves true');
335
+ assert.ok(await waitFor(async () => !(await isPortOpen(port)), 10000), 'group kill must free the port held by the grandchild (the fix)');
336
+ }
337
+ finally {
338
+ // Belt-and-suspenders: never leak the grandchild if an assert throws.
339
+ if (child.pid) {
340
+ try {
341
+ process.kill(-child.pid, 'SIGKILL');
342
+ }
343
+ catch { /* gone */ }
344
+ }
345
+ }
346
+ });
347
+ });
348
+ // ── group SIGTERM → nested node handler (sandbox dev-server teardown) ────────
349
+ // Verifies the load-bearing claim in sandbox.ts: a *group* SIGTERM (what
350
+ // killFrontendTree issues on POSIX, via terminateProcessTree) actually reaches
351
+ // the nested node dev server so its OWN SIGTERM handler (the :3100 drain) runs —
352
+ // not merely that the tree gets reaped. Opt-in + POSIX-only like the reaping
353
+ // tests above.
354
+ describe('group SIGTERM reaches a nested node child and runs its SIGTERM handler', { skip: integrationSkip }, () => {
355
+ it('delivers a group SIGTERM to a detached shell→node child whose node handler observes it', { timeout: 30000 }, async () => {
356
+ const port = await getFreePort();
357
+ const marker = join(tmpdir(), `blocks-sigterm-${process.pid}-${Date.now()}.flag`);
358
+ // node grandchild: install a SIGTERM handler that RECORDS it observed the
359
+ // signal (writes the marker) before exiting, THEN bind the port to
360
+ // announce readiness. Installing the handler before binding guarantees the
361
+ // test never sends SIGTERM before the handler exists (which would
362
+ // default-terminate node and flake the assertion). Retry on EADDRINUSE
363
+ // like the reaping tests, since getFreePort() releases its probe listener.
364
+ const inner = `const net=require('net'),fs=require('fs');` +
365
+ `process.on('SIGTERM',()=>{try{fs.writeFileSync(${JSON.stringify(marker)},'sigterm');}catch{}process.exit(0);});` +
366
+ `let tries=0;` +
367
+ `(function bind(){` +
368
+ `const s=net.createServer(c=>c.destroy());` +
369
+ `s.on('error',e=>{` +
370
+ `if(e.code==='EADDRINUSE'&&tries++<50){setTimeout(bind,100);return;}` +
371
+ `process.exit(1);` +
372
+ `});` +
373
+ `s.listen(${port},'127.0.0.1');` +
374
+ `})();` +
375
+ `setInterval(()=>{},1e9);`;
376
+ // shell → node grandchild, backgrounded with `& wait` so the shell stays
377
+ // the live group leader — the exact `shell: true` + detached topology the
378
+ // dev server uses, where a group signal must fan out to the nested node.
379
+ const cmd = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(inner)} & wait`;
380
+ const child = spawn(cmd, { shell: true, detached: true, stdio: 'ignore' });
381
+ try {
382
+ assert.ok(await waitFor(() => isPortOpen(port), 10000), 'nested node should install its SIGTERM handler then bind the port (ready)');
383
+ assert.ok(child.pid && child.pid > 1, 'child must have a real pid to group-signal');
384
+ // The group SIGTERM under test: exactly what killFrontendTree does on
385
+ // POSIX (`process.kill(-pid, 'SIGTERM')`) and what the sandbox dev-server
386
+ // teardown relies on to trigger the node's own terminateFrontend handler.
387
+ // The `if (child.pid)` guard (asserted above) narrows the type without a
388
+ // non-null assertion — matching the finally-block pattern used in this file.
389
+ if (child.pid)
390
+ process.kill(-child.pid, 'SIGTERM');
391
+ // Assert the node handler OBSERVED the signal (wrote the marker) — i.e.
392
+ // the group SIGTERM reached the *nested* node, not just the shell.
393
+ const observed = await waitFor(async () => {
394
+ try {
395
+ return readFileSync(marker, 'utf8') === 'sigterm';
396
+ }
397
+ catch {
398
+ return false;
399
+ }
400
+ }, 10000);
401
+ assert.ok(observed, 'the nested node SIGTERM handler must run on a group SIGTERM');
402
+ }
403
+ finally {
404
+ if (child.pid) {
405
+ try {
406
+ process.kill(-child.pid, 'SIGKILL');
407
+ }
408
+ catch { /* gone */ }
409
+ }
410
+ try {
411
+ unlinkSync(marker);
412
+ }
413
+ catch { /* never created */ }
414
+ }
415
+ });
416
+ });
417
+ // ── shouldCreditFrontendReady (unit) ────────────────────────────────────────
418
+ // Guards the restart-budget reset: a liveness-only port probe must not credit a
419
+ // foreign listener, or the maxRestarts cap is neutralized and Vite hot-loops.
420
+ describe('shouldCreditFrontendReady — credit only our own live child', () => {
421
+ const liveChild = () => ({ exitCode: null, signalCode: null });
422
+ it('credits the probe when our spawned child is still the live frontend', () => {
423
+ const child = liveChild();
424
+ assert.strictEqual(shouldCreditFrontendReady(child, child), true);
425
+ });
426
+ it('does NOT credit a foreign listener (a different or absent current child)', () => {
427
+ const child = liveChild();
428
+ assert.strictEqual(shouldCreditFrontendReady(child, liveChild()), false);
429
+ assert.strictEqual(shouldCreditFrontendReady(child, null), false);
430
+ });
431
+ it('does NOT credit a child that already exited (lost the --strictPort bind race)', () => {
432
+ const exited = { exitCode: 1, signalCode: null };
433
+ assert.strictEqual(shouldCreditFrontendReady(exited, exited), false);
434
+ const killed = { exitCode: null, signalCode: 'SIGKILL' };
435
+ assert.strictEqual(shouldCreditFrontendReady(killed, killed), false);
436
+ });
437
+ it('does NOT credit when there is no child', () => {
438
+ assert.strictEqual(shouldCreditFrontendReady(null, null), false);
439
+ });
440
+ });
441
+ // ── terminateProcessTree (unit, injected killTree + sleep) ──────────────────
442
+ // The shared SIGTERM->SIGKILL escalation used by both the dev server and the
443
+ // sandbox entrypoint. Dependencies are injected so the policy is exercised
444
+ // without spawning real processes or real timers.
445
+ describe('terminateProcessTree — escalation + post-exit reap', () => {
446
+ const immediate = (_ms) => Promise.resolve();
447
+ const never = (_ms) => new Promise(() => { });
448
+ function makeAwaitableChild(initial = {}) {
449
+ let exitListener = null;
450
+ const child = {
451
+ pid: 4242,
452
+ exitCode: initial.exitCode ?? null,
453
+ signalCode: initial.signalCode ?? null,
454
+ kill() { return true; },
455
+ once(_event, listener) { exitListener = listener; return child; },
456
+ };
457
+ const fireExit = (code = 0, signal = null) => {
458
+ child.exitCode = code;
459
+ child.signalCode = signal;
460
+ exitListener?.();
461
+ };
462
+ return { child, fireExit };
463
+ }
464
+ it('issues a single best-effort group SIGKILL when the child already exited and its group is still alive', async () => {
465
+ const { child } = makeAwaitableChild({ exitCode: 0 });
466
+ const sent = [];
467
+ // Group still has a live member (an orphaned grandchild) → reap it.
468
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, never, () => true);
469
+ assert.strictEqual(ok, true);
470
+ assert.deepStrictEqual(sent, ['SIGKILL']); // post-exit reap: no SIGTERM, no await
471
+ });
472
+ it('skips the post-exit group SIGKILL when the whole group has already drained', async () => {
473
+ const { child } = makeAwaitableChild({ exitCode: 0 });
474
+ const sent = [];
475
+ // The group has fully drained: a `-pid` SIGKILL risks signalling a recycled,
476
+ // unrelated group, and there is nothing of ours left to reap — so skip it.
477
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, never, () => false);
478
+ assert.strictEqual(ok, true); // still resolves true (the child has exited)
479
+ assert.deepStrictEqual(sent, []); // no signal sent into the drained group
480
+ });
481
+ it('SIGTERMs the tree and resolves without escalating when it exits in time', async () => {
482
+ const { child, fireExit } = makeAwaitableChild();
483
+ const sent = [];
484
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => {
485
+ sent.push(s);
486
+ if (s === 'SIGTERM')
487
+ fireExit(0, null); // clean exit wins the grace race
488
+ }, never);
489
+ assert.strictEqual(ok, true);
490
+ assert.deepStrictEqual(sent, ['SIGTERM']);
491
+ });
492
+ it('escalates to a tree SIGKILL when the child lingers past the grace window', async () => {
493
+ const { child, fireExit } = makeAwaitableChild();
494
+ const sent = [];
495
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => {
496
+ sent.push(s);
497
+ if (s === 'SIGKILL')
498
+ fireExit(null, 'SIGKILL'); // dies only on SIGKILL
499
+ }, immediate);
500
+ assert.deepStrictEqual(sent, ['SIGTERM', 'SIGKILL']);
501
+ assert.strictEqual(ok, true);
502
+ });
503
+ it('reports false when the child is still alive after the SIGKILL grace', async () => {
504
+ const { child } = makeAwaitableChild();
505
+ const sent = [];
506
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, immediate);
507
+ assert.deepStrictEqual(sent, ['SIGTERM', 'SIGKILL']);
508
+ assert.strictEqual(ok, false);
509
+ });
510
+ it('waits graceMs for the SIGTERM grace and the shorter KILL_GRACE_MS after SIGKILL', async () => {
511
+ const { child } = makeAwaitableChild();
512
+ const sent = [];
513
+ const slept = [];
514
+ // Resolve every sleep immediately (so both grace races resolve) but record
515
+ // the requested durations to prove which grace each escalation step uses.
516
+ const recordingSleep = (ms) => { slept.push(ms); return Promise.resolve(); };
517
+ const ok = await terminateProcessTree(child, 1500, (_c, s) => { sent.push(s); }, recordingSleep);
518
+ assert.deepStrictEqual(sent, ['SIGTERM', 'SIGKILL']);
519
+ // First grace == the injectable SIGTERM graceMs; second == the fixed,
520
+ // deliberately shorter post-SIGKILL grace constant.
521
+ assert.deepStrictEqual(slept, [1500, KILL_GRACE_MS]);
522
+ assert.ok(KILL_GRACE_MS < 1500, 'the post-SIGKILL grace must be shorter than the SIGTERM grace');
523
+ assert.strictEqual(ok, false); // child never fired exit
524
+ });
525
+ });
526
+ // ── waitForPortFree (real sockets, fast + deterministic) ────────────────────
527
+ // Asserts the "wait for the port to free before exiting" invariant that the
528
+ // post-exit terminate path used to drop.
529
+ describe('waitForPortFree — waits for the listener to release the port', () => {
530
+ it('returns promptly when nothing holds the port', async () => {
531
+ const port = await getFreePort();
532
+ const t0 = Date.now();
533
+ await waitForPortFree(port, 2000);
534
+ assert.ok(Date.now() - t0 < 1000, 'should return quickly when the port is already free');
535
+ });
536
+ it('keeps polling while the port is held, then resolves once it closes', async () => {
537
+ const port = await getFreePort();
538
+ const srv = createServer((c) => c.destroy());
539
+ await new Promise((res) => srv.listen(port, '127.0.0.1', () => res()));
540
+ // A short bounded wait must consume ~its whole budget while the port is held;
541
+ // it can only return early if it (wrongly) sees the held port as free.
542
+ const t0 = Date.now();
543
+ await waitForPortFree(port, 300);
544
+ assert.ok(Date.now() - t0 >= 250, 'must keep polling while the port is held');
545
+ await new Promise((res) => srv.close(() => res()));
546
+ // Once free, a generous-timeout call returns promptly.
547
+ const t1 = Date.now();
548
+ await waitForPortFree(port, 3000);
549
+ assert.ok(Date.now() - t1 < 1500, 'should resolve soon after the port frees');
550
+ });
551
+ });
@@ -4,6 +4,25 @@ export declare const LOCALHOST_PATTERN: RegExp;
4
4
  * Reflects back origins matching localhost/127.0.0.1; otherwise returns the fallback.
5
5
  */
6
6
  export declare function resolveDevCorsOrigin(origin: string): string;
7
+ /** Shape of the client runtime config the browser fetches to discover the API URL. */
8
+ export interface BlocksRuntimeConfig {
9
+ apiUrl: string;
10
+ environment: 'local' | 'sandbox';
11
+ }
12
+ /**
13
+ * Build the runtime config the browser fetches at `${BLOCKS_SANDBOX_PREFIX}/config.json`.
14
+ * In sandbox mode the browser still targets the localhost front door (the dev
15
+ * server proxies `/aws-blocks/api` to the deployed API), so the shape is the
16
+ * same in both modes — only `environment` differs.
17
+ */
18
+ export declare function buildBlocksConfig(port: number, isSandbox: boolean): BlocksRuntimeConfig;
19
+ /**
20
+ * True for the reserved runtime-config request the dev server answers itself
21
+ * (mirroring production, where CloudFront serves `${BLOCKS_SANDBOX_PREFIX}/*`
22
+ * statically) instead of proxying it to the framework dev server — which only
23
+ * serves its own static dir (Next.js `public/`, etc.) and would 404.
24
+ */
25
+ export declare function isBlocksConfigRequest(method: string, pathname: string): boolean;
7
26
  export interface DevServerOptions {
8
27
  /** Customer-facing port. Default: 3000. */
9
28
  port?: number;
@@ -17,5 +36,78 @@ export interface DevServerOptions {
17
36
  /** Port the frontend dev server listens on. Default: 3100. */
18
37
  frontendPort?: number;
19
38
  }
39
+ /** Bounded auto-respawn policy for the frontend dev server. */
40
+ export interface FrontendRespawnPolicy {
41
+ /** Max restarts allowed within `windowMs` before giving up (prevents hot loops). */
42
+ maxRestarts: number;
43
+ /** Sliding window (ms) over which restarts are counted. */
44
+ windowMs: number;
45
+ /** Base backoff (ms); doubles for each restart already in the window. */
46
+ backoffMs: number;
47
+ /** Upper bound (ms) on any single backoff delay. */
48
+ maxBackoffMs: number;
49
+ }
50
+ /** Default frontend respawn budget: 5 restarts / 10s, 500ms→5s exponential backoff. */
51
+ export declare const DEFAULT_FRONTEND_RESPAWN_POLICY: FrontendRespawnPolicy;
52
+ /** Outcome of {@link evaluateFrontendRespawn}. */
53
+ export interface RespawnDecision {
54
+ /** Whether the frontend should be respawned now. */
55
+ restart: boolean;
56
+ /** Delay (ms) to wait before respawning when `restart` is true. */
57
+ delayMs: number;
58
+ /**
59
+ * Restart timestamps still inside the window — plus the new attempt when
60
+ * restarting. The caller persists this for the next decision.
61
+ */
62
+ recent: number[];
63
+ }
64
+ /**
65
+ * Decide whether to auto-respawn the frontend dev server after an unexpected
66
+ * exit, given the timestamps of restarts not yet "forgiven".
67
+ *
68
+ * Semantics — the budget counts only *failing* restarts:
69
+ * - Timestamps older than `windowMs` are dropped from the sliding window.
70
+ * - If `maxRestarts` are still within the window, the budget is exhausted and
71
+ * the frontend is left down (no hot restart loop) — `restart: false`.
72
+ * - Otherwise `restart: true` with an exponential backoff (`backoffMs` doubled
73
+ * per in-window restart, capped at `maxBackoffMs`) and the new attempt
74
+ * appended to `recent`.
75
+ *
76
+ * This function is pure; the *meaning* of the budget is enforced by the caller,
77
+ * which **resets `recentRestarts` to `[]` once a respawn demonstrably succeeds**
78
+ * (the frontend port becomes bound — see `announceFrontendReady`). As a result
79
+ * only *consecutive failing* restarts accumulate toward `maxRestarts`: a
80
+ * frontend that legitimately restarts many times in a burst (e.g.
81
+ * editor-triggered Vite full reloads) refreshes its budget on each healthy bind
82
+ * and is never permanently left down — only a genuine crash loop that never
83
+ * rebinds the port trips the limit.
84
+ */
85
+ export declare function evaluateFrontendRespawn(recentRestarts: number[], now: number, policy?: FrontendRespawnPolicy): RespawnDecision;
86
+ /**
87
+ * Wait (bounded) for a TCP port to STOP accepting connections, i.e. for the
88
+ * listener to actually release the socket. Used after killing the frontend so a
89
+ * `tsx watch` relaunch can rebind `:3100` cleanly instead of racing the kernel's
90
+ * socket teardown and hitting `--strictPort` `EADDRINUSE`. Resolves as soon as
91
+ * the port is free, or once `timeoutMs` elapses (never rejects).
92
+ */
93
+ export declare function waitForPortFree(port: number, timeoutMs?: number): Promise<void>;
94
+ /**
95
+ * Decide whether a "frontend is listening" probe should be *credited* as a
96
+ * successful (re)spawn — and thus reset the restart budget.
97
+ *
98
+ * `waitForPort` only proves *something* is listening on `:3100`; it cannot tell
99
+ * our Vite apart from a foreign listener (a leftover Vite, or a second dev
100
+ * server). Crediting any listener would let a foreign process on `:3100` make
101
+ * every `--strictPort`-failing respawn look successful, neutralizing the
102
+ * `maxRestarts` cap and hot-looping forever. So we credit the probe only when
103
+ * **our** spawned child is still the live frontend process — same identity and
104
+ * not yet exited. A child that already exited (e.g. it lost the `--strictPort`
105
+ * bind race to the foreign listener) is no longer `current`, so it is not
106
+ * credited and its failed attempt still counts toward the budget.
107
+ */
108
+ export declare function shouldCreditFrontendReady(child: {
109
+ exitCode: number | null;
110
+ signalCode: NodeJS.Signals | null;
111
+ } | null, current: unknown): boolean;
20
112
  export declare function startDevServer(options: DevServerOptions): Promise<void>;
21
113
  //# sourceMappingURL=dev-server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"AAmCA,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAEhF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAmCD,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,iBAiP7D"}
1
+ {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"AAoCA,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAEhF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,sFAAsF;AACtF,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,OAAO,GAAG,SAAS,CAAC;CAClC;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,mBAAmB,CAKvF;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE/E;AAED,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAmCD,+DAA+D;AAC/D,MAAM,WAAW,qBAAqB;IACpC,oFAAoF;IACpF,WAAW,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,uFAAuF;AACvF,eAAO,MAAM,+BAA+B,EAAE,qBAK7C,CAAC;AAEF,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,OAAO,EAAE,OAAO,CAAC;IACjB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,CACrC,cAAc,EAAE,MAAM,EAAE,EACxB,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,qBAAuD,GAC9D,eAAe,CAOjB;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAenF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,EAC5E,OAAO,EAAE,OAAO,GACf,OAAO,CAOT;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,iBAya7D"}