@aws-blocks/core 0.1.4 → 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 (58) 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/errors.d.ts +28 -0
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +27 -1
  10. package/dist/errors.test.d.ts +2 -0
  11. package/dist/errors.test.d.ts.map +1 -0
  12. package/dist/errors.test.js +47 -0
  13. package/dist/hosting.d.ts +22 -1
  14. package/dist/hosting.d.ts.map +1 -1
  15. package/dist/index.cdk.d.ts +1 -1
  16. package/dist/index.cdk.d.ts.map +1 -1
  17. package/dist/index.cdk.js +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
  22. package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
  23. package/dist/scripts/dev-server-supervisor.test.js +551 -0
  24. package/dist/scripts/dev-server.d.ts +73 -0
  25. package/dist/scripts/dev-server.d.ts.map +1 -1
  26. package/dist/scripts/dev-server.js +279 -29
  27. package/dist/scripts/index.d.ts +1 -0
  28. package/dist/scripts/index.d.ts.map +1 -1
  29. package/dist/scripts/index.js +1 -0
  30. package/dist/scripts/process-tree.d.ts +126 -0
  31. package/dist/scripts/process-tree.d.ts.map +1 -0
  32. package/dist/scripts/process-tree.js +198 -0
  33. package/dist/scripts/sandbox.d.ts.map +1 -1
  34. package/dist/scripts/sandbox.js +41 -3
  35. package/dist/scripts/stack-id.d.ts +12 -0
  36. package/dist/scripts/stack-id.d.ts.map +1 -0
  37. package/dist/scripts/stack-id.js +54 -0
  38. package/dist/scripts/stack-id.test.d.ts +2 -0
  39. package/dist/scripts/stack-id.test.d.ts.map +1 -0
  40. package/dist/scripts/stack-id.test.js +54 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
  44. package/src/cdk/index.ts +1 -1
  45. package/src/client/index.ts +1 -1
  46. package/src/errors.test.ts +55 -0
  47. package/src/errors.ts +32 -1
  48. package/src/hosting.ts +22 -1
  49. package/src/index.cdk.ts +1 -1
  50. package/src/index.ts +1 -1
  51. package/src/scripts/dev-server-supervisor.test.ts +621 -0
  52. package/src/scripts/dev-server.ts +316 -27
  53. package/src/scripts/index.ts +1 -0
  54. package/src/scripts/process-tree.ts +245 -0
  55. package/src/scripts/sandbox.ts +40 -3
  56. package/src/scripts/stack-id.test.ts +63 -0
  57. package/src/scripts/stack-id.ts +61 -0
  58. package/src/version.ts +1 -1
@@ -22,6 +22,7 @@ import {
22
22
  import { redactToJson } from '../redact.js';
23
23
  import { buildAndSendEvent } from '../telemetry/client.js';
24
24
  import { applyDevMigrations } from './external-migrations-step.js';
25
+ import { killFrontendTree, terminateProcessTree } from './process-tree.js';
25
26
 
26
27
  function toBodyStream(text: string): ReadableStream<Uint8Array> | null {
27
28
  if (!text) return null;
@@ -119,6 +120,123 @@ async function waitForPort(port: number, maxAttempts = 60): Promise<void> {
119
120
  throw new Error(`Frontend server on port ${port} did not start within ${maxAttempts * 500}ms`);
120
121
  }
121
122
 
123
+ /** Bounded auto-respawn policy for the frontend dev server. */
124
+ export interface FrontendRespawnPolicy {
125
+ /** Max restarts allowed within `windowMs` before giving up (prevents hot loops). */
126
+ maxRestarts: number;
127
+ /** Sliding window (ms) over which restarts are counted. */
128
+ windowMs: number;
129
+ /** Base backoff (ms); doubles for each restart already in the window. */
130
+ backoffMs: number;
131
+ /** Upper bound (ms) on any single backoff delay. */
132
+ maxBackoffMs: number;
133
+ }
134
+
135
+ /** Default frontend respawn budget: 5 restarts / 10s, 500ms→5s exponential backoff. */
136
+ export const DEFAULT_FRONTEND_RESPAWN_POLICY: FrontendRespawnPolicy = {
137
+ maxRestarts: 5,
138
+ windowMs: 10_000,
139
+ backoffMs: 500,
140
+ maxBackoffMs: 5_000,
141
+ };
142
+
143
+ /** Outcome of {@link evaluateFrontendRespawn}. */
144
+ export interface RespawnDecision {
145
+ /** Whether the frontend should be respawned now. */
146
+ restart: boolean;
147
+ /** Delay (ms) to wait before respawning when `restart` is true. */
148
+ delayMs: number;
149
+ /**
150
+ * Restart timestamps still inside the window — plus the new attempt when
151
+ * restarting. The caller persists this for the next decision.
152
+ */
153
+ recent: number[];
154
+ }
155
+
156
+ /**
157
+ * Decide whether to auto-respawn the frontend dev server after an unexpected
158
+ * exit, given the timestamps of restarts not yet "forgiven".
159
+ *
160
+ * Semantics — the budget counts only *failing* restarts:
161
+ * - Timestamps older than `windowMs` are dropped from the sliding window.
162
+ * - If `maxRestarts` are still within the window, the budget is exhausted and
163
+ * the frontend is left down (no hot restart loop) — `restart: false`.
164
+ * - Otherwise `restart: true` with an exponential backoff (`backoffMs` doubled
165
+ * per in-window restart, capped at `maxBackoffMs`) and the new attempt
166
+ * appended to `recent`.
167
+ *
168
+ * This function is pure; the *meaning* of the budget is enforced by the caller,
169
+ * which **resets `recentRestarts` to `[]` once a respawn demonstrably succeeds**
170
+ * (the frontend port becomes bound — see `announceFrontendReady`). As a result
171
+ * only *consecutive failing* restarts accumulate toward `maxRestarts`: a
172
+ * frontend that legitimately restarts many times in a burst (e.g.
173
+ * editor-triggered Vite full reloads) refreshes its budget on each healthy bind
174
+ * and is never permanently left down — only a genuine crash loop that never
175
+ * rebinds the port trips the limit.
176
+ */
177
+ export function evaluateFrontendRespawn(
178
+ recentRestarts: number[],
179
+ now: number,
180
+ policy: FrontendRespawnPolicy = DEFAULT_FRONTEND_RESPAWN_POLICY,
181
+ ): RespawnDecision {
182
+ const recent = recentRestarts.filter((t) => now - t < policy.windowMs);
183
+ if (recent.length >= policy.maxRestarts) {
184
+ return { restart: false, delayMs: 0, recent };
185
+ }
186
+ const delayMs = Math.min(policy.backoffMs * 2 ** recent.length, policy.maxBackoffMs);
187
+ return { restart: true, delayMs, recent: [...recent, now] };
188
+ }
189
+
190
+ /**
191
+ * Wait (bounded) for a TCP port to STOP accepting connections, i.e. for the
192
+ * listener to actually release the socket. Used after killing the frontend so a
193
+ * `tsx watch` relaunch can rebind `:3100` cleanly instead of racing the kernel's
194
+ * socket teardown and hitting `--strictPort` `EADDRINUSE`. Resolves as soon as
195
+ * the port is free, or once `timeoutMs` elapses (never rejects).
196
+ */
197
+ export async function waitForPortFree(port: number, timeoutMs = 2000): Promise<void> {
198
+ const { setTimeout: sleep } = await import('node:timers/promises');
199
+ const deadline = Date.now() + timeoutMs;
200
+ while (Date.now() < deadline) {
201
+ const open = await new Promise<boolean>((resolve) => {
202
+ const socket = createConnection({ port, host: 'localhost' }, () => {
203
+ socket.destroy();
204
+ resolve(true);
205
+ });
206
+ socket.on('error', () => { socket.destroy(); resolve(false); });
207
+ socket.setTimeout(200, () => { socket.destroy(); resolve(false); });
208
+ });
209
+ if (!open) return;
210
+ await sleep(100);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Decide whether a "frontend is listening" probe should be *credited* as a
216
+ * successful (re)spawn — and thus reset the restart budget.
217
+ *
218
+ * `waitForPort` only proves *something* is listening on `:3100`; it cannot tell
219
+ * our Vite apart from a foreign listener (a leftover Vite, or a second dev
220
+ * server). Crediting any listener would let a foreign process on `:3100` make
221
+ * every `--strictPort`-failing respawn look successful, neutralizing the
222
+ * `maxRestarts` cap and hot-looping forever. So we credit the probe only when
223
+ * **our** spawned child is still the live frontend process — same identity and
224
+ * not yet exited. A child that already exited (e.g. it lost the `--strictPort`
225
+ * bind race to the foreign listener) is no longer `current`, so it is not
226
+ * credited and its failed attempt still counts toward the budget.
227
+ */
228
+ export function shouldCreditFrontendReady(
229
+ child: { exitCode: number | null; signalCode: NodeJS.Signals | null } | null,
230
+ current: unknown,
231
+ ): boolean {
232
+ return (
233
+ !!child &&
234
+ child === current &&
235
+ child.exitCode === null &&
236
+ child.signalCode === null
237
+ );
238
+ }
239
+
122
240
  export async function startDevServer(options: DevServerOptions) {
123
241
  const {
124
242
  port = 3000,
@@ -200,6 +318,168 @@ export async function startDevServer(options: DevServerOptions) {
200
318
  (res as ServerResponse).end('Frontend server unavailable');
201
319
  });
202
320
 
321
+ // ── Frontend supervisor ─────────────────────────────────────────────────
322
+ // The frontend runs under `shell: true`, so the real dev server (Vite) is a
323
+ // grandchild of this process. We spawn it `detached` (its own process group)
324
+ // on POSIX so cleanup/restart can signal the *whole* tree and free the port;
325
+ // otherwise the orphaned grandchild keeps `:3100` and every `/` request 502s
326
+ // forever (the proxy target is hardcoded to `frontendPort`). We also bound-
327
+ // respawn it on unexpected death and suppress all of this during shutdown.
328
+ //
329
+ // ── POST-EXIT GROUP-KILL POLICY ─────────────────────────────────────────
330
+ // The exact bug this supervisor fixes is the shell *exiting* while the
331
+ // detached grandchild survives, orphaned, still holding `:3100`. Reaping that
332
+ // orphan REQUIRES a group kill (`process.kill(-pid, …)`) issued *after* the
333
+ // shell has already exited — so all three post-exit kill sites below agree:
334
+ // the respawn path, `terminateFrontend`, and the `process.on('exit')` net all
335
+ // group-kill rather than skip when the shell is already gone.
336
+ //
337
+ // Why this is safe against the classic `-pid` PID-reuse hazard:
338
+ // 1. A surviving grandchild keeps the process group non-empty, so POSIX
339
+ // keeps `pid` reserved as the group id — it cannot be recycled as a new
340
+ // process id while it is still a live group's id. Hence `-pid` is
341
+ // guaranteed to target *our* group precisely when it matters (an orphan
342
+ // is still alive in it).
343
+ // 2. We only ever issue the kill synchronously, the instant we observe the
344
+ // shell's exit — there is no intervening `await` that could let the group
345
+ // drain and the pid be recycled — so the residual window is minimal.
346
+ // Residual accepted risk: if the ENTIRE group is already gone *and* `pid` has
347
+ // since been recycled into a brand-new group leader, `-pid` could signal an
348
+ // unrelated group. This is an accepted best-effort trade-off — there is then
349
+ // nothing of ours left to reap, whereas skipping the kill would otherwise
350
+ // leave `:3100` wedged, which is the failure this PR exists to prevent.
351
+ //
352
+ // Where each post-exit kill site lands on this trade-off: the two sites *in
353
+ // this file* — the respawn reap (in the child's `exit` handler) and the
354
+ // `process.on('exit')` net — fire synchronously the instant we observe the
355
+ // exit, so they lean on point (2) above and stay unconditional. The third
356
+ // path, `terminateFrontend` → `terminateProcessTree` (process-tree.ts), can
357
+ // run outside that minimal synchronous window, so it additionally PROBES group
358
+ // liveness (POSIX signal 0) and skips the reap once the group has fully
359
+ // drained — see its "POST-EXIT GROUP-KILL (scoped)" comment.
360
+ const usePosixProcessGroups = process.platform !== 'win32';
361
+ let isShuttingDown = false;
362
+ let frontendRestarts: number[] = [];
363
+ let respawnTimer: ReturnType<typeof setTimeout> | null = null;
364
+
365
+ const announceFrontendReady = async (child: ChildProcess | null, suffix = ''): Promise<void> => {
366
+ try {
367
+ await waitForPort(frontendPort);
368
+ // Reset the restart budget only when OUR child is the one now bound to
369
+ // `:3100`. `waitForPort` is a liveness-only probe — it cannot tell our
370
+ // Vite from a foreign listener (a leftover Vite or a second dev server),
371
+ // and crediting a foreign listener would make every `--strictPort`-failing
372
+ // respawn look successful, neutralizing the `maxRestarts` cap and
373
+ // hot-looping forever (see {@link shouldCreditFrontendReady}). Only
374
+ // *consecutive failing* restarts should count toward the give-up
375
+ // threshold, so a frontend that legitimately restarts many times (e.g.
376
+ // editor-triggered Vite full reloads) still never gets left down.
377
+ if (shouldCreditFrontendReady(child, frontendProcess)) {
378
+ frontendRestarts = [];
379
+ }
380
+ console.log(`\n ➜ http://localhost:${port}/${suffix}\n`);
381
+ } catch (e) {
382
+ console.error(`⚠️ Frontend did not start: ${(e as Error).message}`);
383
+ console.log(`\n ➜ http://localhost:${port}/ (API only — frontend unavailable)\n`);
384
+ }
385
+ };
386
+
387
+ const spawnFrontend = (command: string): ChildProcess => {
388
+ const child = spawn(command, {
389
+ shell: true,
390
+ // Own process group on POSIX so we can reap the Vite grandchild too.
391
+ detached: usePosixProcessGroups,
392
+ stdio: ['ignore', 'pipe', 'pipe'],
393
+ env: { ...process.env, NODE_OPTIONS: '' },
394
+ });
395
+ frontendProcess = child;
396
+
397
+ // Suppress frontend output — only show errors.
398
+ child.stderr?.on('data', (d: Buffer) => {
399
+ const msg = d.toString();
400
+ if (!msg.includes('DeprecationWarning')) process.stderr.write(msg);
401
+ });
402
+
403
+ child.on('exit', (code, signal) => {
404
+ // Ignore exits from a process we've already replaced or torn down.
405
+ if (child !== frontendProcess) return;
406
+ frontendProcess = null;
407
+ if (isShuttingDown) return;
408
+ // Reap any orphaned grandchild left in this child's group so `:3100` is
409
+ // free before we respawn — otherwise `--strictPort` makes the new Vite
410
+ // exit on bind and we'd spin until the restart budget is gone. The shell
411
+ // has already exited here (we are inside its `exit` handler), so this is a
412
+ // post-exit group kill; it is issued synchronously in this handler and is
413
+ // safe against PID reuse — see POST-EXIT GROUP-KILL POLICY above.
414
+ killFrontendTree(child, 'SIGKILL');
415
+
416
+ const decision = evaluateFrontendRespawn(frontendRestarts, Date.now());
417
+ frontendRestarts = decision.recent;
418
+ const why = `code=${code ?? 'null'}, signal=${signal ?? 'null'}`;
419
+ if (!decision.restart) {
420
+ console.error(
421
+ `⚠️ Frontend dev server exited (${why}) and exceeded ` +
422
+ `${DEFAULT_FRONTEND_RESPAWN_POLICY.maxRestarts} restarts within ` +
423
+ `${DEFAULT_FRONTEND_RESPAWN_POLICY.windowMs / 1000}s — leaving it down. ` +
424
+ `Fix the error above, then restart \`npm run dev\`.`,
425
+ );
426
+ return;
427
+ }
428
+ console.error(`⚠️ Frontend dev server exited (${why}); restarting in ${decision.delayMs}ms…`);
429
+ respawnTimer = setTimeout(() => {
430
+ respawnTimer = null;
431
+ if (isShuttingDown) return;
432
+ // Before relaunching, wait (bounded) for `:3100` to actually free —
433
+ // mirroring the graceful `terminateFrontend` path. The synchronous
434
+ // post-exit SIGKILL above only *initiates* teardown of the orphaned
435
+ // group; the kernel can still hold the listening socket for a beat, and a
436
+ // relaunched `--strictPort` Vite would then hit `EADDRINUSE` and burn a
437
+ // restart-budget slot on a race that isn't a real crash. The budget was
438
+ // already debited above, so this never double-counts a restart; re-check
439
+ // `isShuttingDown` after the await, since a shutdown signal can land while
440
+ // we wait (`waitForPortFree` is bounded, so it can't deadlock shutdown).
441
+ void (async () => {
442
+ await waitForPortFree(frontendPort);
443
+ if (isShuttingDown) return;
444
+ const next = spawnFrontend(command);
445
+ await announceFrontendReady(next, ' (frontend restarted)');
446
+ })();
447
+ }, decision.delayMs);
448
+ // INTENTIONAL unref: the listening HTTP `server` (created below) owns this
449
+ // process's lifetime — the backoff timer must NOT, by itself, keep the
450
+ // event loop alive. Without unref a pending respawn timer would hold the
451
+ // process up during shutdown (or after the server has closed), delaying or
452
+ // blocking a clean exit. This never drops a legitimately-needed respawn:
453
+ // `cleanup` explicitly clears this timer, and both the timer body and the
454
+ // awaited relaunch re-check `isShuttingDown`. Do NOT remove the unref to
455
+ // "fix" a perceived missed restart — it would reintroduce that shutdown hang.
456
+ respawnTimer.unref?.();
457
+ });
458
+
459
+ return child;
460
+ };
461
+
462
+ /**
463
+ * Gracefully terminate the frontend tree and wait (bounded) for the port to
464
+ * actually free before this process exits, so a `tsx watch` relaunch can
465
+ * rebind `:3100` cleanly. SIGTERM the group, escalate to SIGKILL if it lingers
466
+ * (via the shared {@link terminateProcessTree}), then poll until `:3100` is
467
+ * released. tsx-watch gives us ~5s before it force-kills us, so this budget is
468
+ * safe. Crucially the port-free wait runs on *both* paths — including when the
469
+ * shell has already exited — so the post-exit branch no longer drops the
470
+ * "wait for the port to free" guarantee.
471
+ */
472
+ const terminateFrontend = async (child: ChildProcess | null): Promise<void> => {
473
+ if (!child) return;
474
+ // SIGTERM→SIGKILL the whole tree, reaping the detached Vite grandchild even
475
+ // when the shell has already exited (post-exit group kill — see policy).
476
+ await terminateProcessTree(child, 1500);
477
+ // Then wait (bounded) for `:3100` to be released. The old post-exit branch
478
+ // returned right after SIGKILL with no port poll, so a relaunch could race
479
+ // the kernel's socket teardown and hit `--strictPort` `EADDRINUSE`.
480
+ await waitForPortFree(frontendPort);
481
+ };
482
+
203
483
  // ── API Gateway proxy (sandbox mode) ───────────────────────────────────
204
484
  // `changeOrigin: true` rewrites the outgoing `Host` to the execute-api target
205
485
  // (required for API Gateway's TLS SNI / host-based routing). That would make
@@ -319,35 +599,14 @@ export async function startDevServer(options: DevServerOptions) {
319
599
  }
320
600
 
321
601
  // ── Start listening ────────────────────────────────────────────────────
322
- server.listen(port, '127.0.0.1', async () => {
602
+ server.listen(port, async () => {
323
603
  console.log(`AWS Blocks local server running on http://localhost:${port}`);
324
604
  buildAndSendEvent({ command: 'dev', state: 'SUCCESS', duration: Date.now() - devStartTime });
325
605
 
326
606
  // Spawn frontend dev server after Blocks server is ready
327
607
  if (frontendCommand) {
328
- frontendProcess = spawn(frontendCommand, {
329
- shell: true,
330
- stdio: ['ignore', 'pipe', 'pipe'],
331
- env: { ...process.env, NODE_OPTIONS: '' },
332
- });
333
- // Suppress frontend output — only show errors
334
- frontendProcess.stderr?.on('data', (d: Buffer) => {
335
- const msg = d.toString();
336
- if (!msg.includes('DeprecationWarning')) process.stderr.write(msg);
337
- });
338
- frontendProcess.on('exit', (code) => {
339
- if (code !== 0 && code !== null) {
340
- console.error(`⚠️ Frontend process exited with code ${code}`);
341
- }
342
- });
343
-
344
- try {
345
- await waitForPort(frontendPort);
346
- console.log(`\n ➜ http://localhost:${port}/\n`);
347
- } catch (e) {
348
- console.error(`⚠️ Frontend did not start: ${(e as Error).message}`);
349
- console.log(`\n ➜ http://localhost:${port}/ (API only — frontend unavailable)\n`);
350
- }
608
+ const child = spawnFrontend(frontendCommand);
609
+ await announceFrontendReady(child);
351
610
  } else {
352
611
  console.log(`\n ➜ http://localhost:${port}/\n`);
353
612
  }
@@ -359,9 +618,24 @@ export async function startDevServer(options: DevServerOptions) {
359
618
  });
360
619
 
361
620
  // ── Cleanup ────────────────────────────────────────────────────────────
621
+ const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
622
+ let cleaningUp = false;
362
623
  const cleanup = async () => {
624
+ if (cleaningUp) return; // idempotent — a second signal must not re-enter
625
+ cleaningUp = true;
626
+ isShuttingDown = true; // stop the supervisor from respawning the frontend
363
627
  console.log('\nShutting down...');
364
- if (frontendProcess) frontendProcess.kill('SIGTERM');
628
+
629
+ if (respawnTimer) { clearTimeout(respawnTimer); respawnTimer = null; }
630
+ // Detach our own listeners so repeated signals can't pile up handlers.
631
+ for (const sig of signals) process.removeListener(sig, cleanup);
632
+
633
+ // Kill the frontend process *group* and wait for the port to free before
634
+ // we exit, so a tsx-watch restart can rebind `:3100` cleanly.
635
+ const child = frontendProcess;
636
+ frontendProcess = null;
637
+ await terminateFrontend(child);
638
+
365
639
  if (typeof backend.__cleanup === 'function') {
366
640
  try { await backend.__cleanup(); } catch {}
367
641
  }
@@ -371,8 +645,23 @@ export async function startDevServer(options: DevServerOptions) {
371
645
  setTimeout(() => process.exit(0), 2000).unref();
372
646
  };
373
647
 
374
- process.on('SIGINT', cleanup);
375
- process.on('SIGTERM', cleanup);
648
+ for (const sig of signals) process.on(sig, cleanup);
649
+
650
+ // Last-resort safety net for paths that bypass `cleanup` (e.g. an uncaught
651
+ // exception terminating the process): synchronously reap the frontend tree so
652
+ // a `detached` Vite is never left orphaned on `:3100`. Reuses
653
+ // `killFrontendTree`, so unlike the old hand-rolled `process.kill(-pid)` it
654
+ // also reaps on Windows (via `taskkill`) instead of early-returning and
655
+ // leaking the Vite tree, and stays in lockstep with the other kill sites. Both
656
+ // the POSIX group kill and the Windows `taskkill` are synchronous, so this is
657
+ // legal in an `exit` handler; it reaps even when the shell has already exited
658
+ // (a surviving grandchild keeps the group alive) — see POST-EXIT GROUP-KILL
659
+ // POLICY above.
660
+ process.once('exit', () => {
661
+ const child = frontendProcess;
662
+ if (!child) return;
663
+ killFrontendTree(child, 'SIGKILL');
664
+ });
376
665
  }
377
666
 
378
667
  // ── Local API handler ────────────────────────────────────────────────────────
@@ -19,3 +19,4 @@ export {
19
19
  type BuildAndSendEventOptions,
20
20
  } from '../telemetry/index.js';
21
21
  export { telemetry, type TelemetryOptions } from './telemetry.js';
22
+ export { getStackId, getSandboxId } from './stack-id.js';
@@ -0,0 +1,245 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ // Shared process-tree teardown primitives used by every dev-tooling entrypoint
7
+ // (the dev server and the sandbox). Both spawn a long-running command with
8
+ // `shell: true`, so the real process (Vite, or the `tsx watch` dev server) is a
9
+ // grandchild of the shell. Reaping it requires killing the whole tree, not just
10
+ // the shell parent — see the per-function docs. Keeping this in one module means
11
+ // the dev server, the sandbox, and the `process.on('exit')` safety net all reap
12
+ // identically instead of hand-rolling divergent copies.
13
+
14
+ /** Minimal child-process surface needed to terminate a frontend dev server. */
15
+ export interface KillableProcess {
16
+ pid?: number;
17
+ kill(signal?: NodeJS.Signals | number): boolean;
18
+ }
19
+
20
+ /** Subset of {@link import('node:child_process').SpawnSyncReturns} that {@link windowsTreeKill} inspects. */
21
+ interface TreeKillResult {
22
+ status: number | null;
23
+ error?: Error;
24
+ }
25
+
26
+ /**
27
+ * Force-kill an entire process tree on Windows via `taskkill /T /F /PID <pid>`.
28
+ *
29
+ * Windows has no POSIX process groups, so a bare `child.kill()` only signals the
30
+ * spawned shell and orphans the real dev server (the Vite grandchild), which
31
+ * keeps holding `:3100` — the very wedge the POSIX process-group kill fixes.
32
+ * `taskkill /T` walks the live child tree by PID and terminates every
33
+ * descendant; `/F` is required because Windows cannot deliver a graceful
34
+ * shutdown to a non-console subtree anyway (Node maps SIGTERM/SIGKILL to
35
+ * `TerminateProcess`).
36
+ *
37
+ * Returns `true` only when `taskkill` ran AND reported the tree handled — exit
38
+ * `0` (reaped the tree) or `128` (`"process not found"`, i.e. already gone).
39
+ * Returns `false` when the command could not be spawned at all (e.g. not on
40
+ * `PATH`) OR when it ran but returned any other status (e.g. `1` = access
41
+ * denied): such a run did NOT reap the tree, so the caller must fall back to a
42
+ * direct `child.kill` rather than treat the leak as handled. (`child.kill`
43
+ * cannot reap the orphaned grandchild either, but the fallback is cheap and
44
+ * strictly correct — we never silently swallow a failed tree-kill.) Never
45
+ * throws.
46
+ */
47
+ export function windowsTreeKill(
48
+ pid: number,
49
+ runner: (command: string, args: readonly string[]) => TreeKillResult = (command, args) =>
50
+ spawnSync(command, args as string[], { stdio: 'ignore', windowsHide: true }),
51
+ ): boolean {
52
+ try {
53
+ const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
54
+ // Couldn't even spawn taskkill (e.g. not on PATH) → not handled; fall back.
55
+ if (error) return false;
56
+ // taskkill ran: only exit 0 (reaped the tree) or 128 ("process not found",
57
+ // already gone) mean the tree is handled. Any other non-null status (e.g.
58
+ // 1 = access denied) means taskkill ran but did NOT reap the tree, so report
59
+ // not-handled and let the caller fall back to a direct child.kill.
60
+ return status === 0 || status === 128;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Terminate a process spawned with `shell: true`, including its descendants, on
68
+ * every platform.
69
+ *
70
+ * Under a shell the real dev server (e.g. Vite) is a **grandchild**: the direct
71
+ * child is the shell, so signalling only the shell (`child.kill`) orphans the
72
+ * grandchild, which keeps holding its port (`:3100`) and wedges the next
73
+ * restart.
74
+ *
75
+ * - **POSIX**: the process is spawned `detached` (its own process group,
76
+ * pgid === child.pid), so we signal the whole group with
77
+ * `process.kill(-pid, signal)` and every descendant dies, freeing the port.
78
+ * - **Windows**: there are no process groups, so we reap the tree with
79
+ * `taskkill /T /F /PID <pid>` (see {@link windowsTreeKill}), which walks the
80
+ * child tree by PID. A bare `child.kill` would leave the Vite grandchild
81
+ * bound to `:3100`, reproducing the POSIX wedge.
82
+ *
83
+ * Best-effort and never throws: a missing/invalid pid, an already-dead group
84
+ * (ESRCH), a failed group signal, or an unavailable `taskkill` all degrade to a
85
+ * direct `child.kill`.
86
+ */
87
+ export function killFrontendTree(
88
+ child: KillableProcess,
89
+ signal: NodeJS.Signals = 'SIGTERM',
90
+ platform: NodeJS.Platform = process.platform,
91
+ killFn: (pid: number, signal: NodeJS.Signals) => void = (p, s) => process.kill(p, s),
92
+ winTreeKill: (pid: number) => boolean = windowsTreeKill,
93
+ ): void {
94
+ const { pid } = child;
95
+ // pid > 1 guards against signalling the whole current group (-0) or init (-1).
96
+ if (pid && pid > 1) {
97
+ if (platform !== 'win32') {
98
+ try {
99
+ killFn(-pid, signal);
100
+ return;
101
+ } catch {
102
+ // Group already gone or signal failed — fall through to a direct kill.
103
+ }
104
+ } else if (winTreeKill(pid)) {
105
+ // taskkill walked the PID tree and reaped the Vite grandchild.
106
+ return;
107
+ }
108
+ }
109
+ try {
110
+ child.kill(signal);
111
+ } catch {
112
+ // Process already exited; nothing to do.
113
+ }
114
+ }
115
+
116
+ /** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
117
+ export interface AwaitableChild extends KillableProcess {
118
+ exitCode: number | null;
119
+ signalCode: NodeJS.Signals | null;
120
+ once(event: 'exit', listener: () => void): unknown;
121
+ }
122
+
123
+ const defaultSleep = (ms: number): Promise<void> =>
124
+ new Promise((res) => {
125
+ setTimeout(res, ms).unref?.();
126
+ });
127
+
128
+ /**
129
+ * Grace (ms) we wait for the child's `exit` event *after* SIGKILL before giving
130
+ * up and reporting its last-known exit state. Deliberately shorter than — and
131
+ * intentionally decoupled from — the injectable SIGTERM `graceMs`: SIGKILL
132
+ * cannot be caught, blocked, or handled, so the child is already being
133
+ * force-terminated; we only need a brief beat to observe the `exit` event, not a
134
+ * full, tunable shutdown window. Fixed (not a parameter) because no caller needs
135
+ * to tune it — the injected `sleep` is the test seam.
136
+ */
137
+ export const KILL_GRACE_MS = 500;
138
+
139
+ /**
140
+ * Probe whether a detached process *group* still has at least one live member,
141
+ * **without signalling it**. Used to scope the post-exit group SIGKILL in
142
+ * {@link terminateProcessTree} to the only window where the `-pid` group signal
143
+ * is PID-reuse-safe.
144
+ *
145
+ * The hazard: {@link killFrontendTree}'s POSIX reap is `process.kill(-pid, …)`,
146
+ * which targets the process group whose gid is `pid`. That is safe only while a
147
+ * group member is still alive — a survivor keeps the kernel from recycling
148
+ * `pid` as a brand-new (unrelated) group leader. Once the whole group has
149
+ * drained, `pid` is eligible for reuse and a blind `-pid` kill could land on an
150
+ * unrelated group. So before a *post-exit* reap we probe here and skip when the
151
+ * group has already drained (there is then nothing of ours left to reap).
152
+ *
153
+ * - **POSIX**: `kill(-pid, 0)` sends no signal — it only checks the group
154
+ * exists and is signallable. Success or `EPERM` (exists but owned by another
155
+ * user) ⇒ alive. `ESRCH` (or anything else) ⇒ treat as drained.
156
+ * - **Windows**: there are no process groups and the reap path
157
+ * (`taskkill /T /F /PID`) walks the live PID tree, so there is no `-pid`
158
+ * recycle hazard — always allow the reap (`true`).
159
+ *
160
+ * Never throws. `platform`/`kill` are injected for tests.
161
+ */
162
+ export function isProcessGroupAlive(
163
+ pid: number,
164
+ platform: NodeJS.Platform = process.platform,
165
+ kill: (pid: number, signal: number) => void = (p, s) => process.kill(p, s),
166
+ ): boolean {
167
+ if (platform === 'win32') return true;
168
+ try {
169
+ kill(-pid, 0);
170
+ return true;
171
+ } catch (e) {
172
+ return (e as NodeJS.ErrnoException).code === 'EPERM';
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Terminate a child process *tree* and wait — bounded — for the child to exit,
178
+ * escalating SIGTERM → SIGKILL. Reuses {@link killFrontendTree} so every
179
+ * entrypoint reaps the same way (POSIX process-group kill / Windows `taskkill`)
180
+ * instead of hand-rolling its own group kill.
181
+ *
182
+ * Post-exit policy: if the child has *already* exited, a detached grandchild may
183
+ * still be orphaned (still holding a port), so we issue one best-effort group
184
+ * SIGKILL to reap it — but ONLY when the group still has a live member
185
+ * ({@link isProcessGroupAlive}). When the whole group has already drained (the
186
+ * common healthy shutdown — Vite was already gone), `pid` is eligible for
187
+ * recycling and a blind `-pid` signal could hit an unrelated, newly created
188
+ * group; since there is also nothing of ours left to reap, we skip the kill.
189
+ * See the dev server's "POST-EXIT GROUP-KILL POLICY" for the full rationale and
190
+ * the accepted residual (the synchronous probe→kill window). Otherwise we
191
+ * SIGTERM the tree, wait up to `graceMs` for a clean exit, then SIGKILL the tree
192
+ * and wait a short grace.
193
+ *
194
+ * Return value — IMPORTANT: the boolean reflects only the **direct child's**
195
+ * exit state (its `exitCode`/`signalCode`), NOT whole-group teardown or port
196
+ * release. On POSIX the SIGKILL is delivered to the whole group (`-pid`), but a
197
+ * surviving *detached grandchild* can outlive the awaited child and keep holding
198
+ * a port even after this resolves `true`. So `true` means only "the child we
199
+ * awaited has exited (or was already gone)" and `false` means "it was still
200
+ * alive when the budget elapsed" — neither guarantees the port is free. Callers
201
+ * that need a freed port MUST follow this with a bounded port-free wait (see
202
+ * `waitForPortFree` in dev-server.ts, which the dev-server child's own SIGTERM
203
+ * handler runs). Dependencies are injected for tests.
204
+ */
205
+ export async function terminateProcessTree(
206
+ child: AwaitableChild,
207
+ graceMs = 2000,
208
+ killTree: (c: KillableProcess, signal: NodeJS.Signals) => void = killFrontendTree,
209
+ sleep: (ms: number) => Promise<void> = defaultSleep,
210
+ isGroupAlive: (pid: number) => boolean = isProcessGroupAlive,
211
+ ): Promise<boolean> {
212
+ if (child.exitCode !== null || child.signalCode !== null) {
213
+ // ── POST-EXIT GROUP-KILL (scoped) ──────────────────────────────────────
214
+ // The direct child has already exited, but a detached *grandchild* (e.g. an
215
+ // orphaned Vite) may still be alive in its process group, still holding a
216
+ // port — reap it with one best-effort group SIGKILL.
217
+ //
218
+ // SCOPING: only reap when the group still has a live member. killFrontendTree's
219
+ // `-pid` group signal is PID-reuse-safe ONLY while a member keeps `pid`
220
+ // reserved as the group id; once the whole group has drained `pid` can be
221
+ // recycled and a blind `process.kill(-pid)` could hit an unrelated group. So
222
+ // we probe first (isProcessGroupAlive; POSIX signal 0) and skip when already
223
+ // drained — there is then nothing of ours to reap. The residual synchronous
224
+ // probe→kill window is the accepted trade-off documented in dev-server.ts
225
+ // "POST-EXIT GROUP-KILL POLICY", cross-referenced here so the risk is
226
+ // discoverable at this shared primitive.
227
+ const { pid } = child;
228
+ if (pid && pid > 1 && isGroupAlive(pid)) {
229
+ killTree(child, 'SIGKILL');
230
+ }
231
+ return true;
232
+ }
233
+ const exited = new Promise<void>((res) => child.once('exit', () => res()));
234
+ killTree(child, 'SIGTERM');
235
+ const exitedCleanly = await Promise.race([
236
+ exited.then(() => true),
237
+ sleep(graceMs).then(() => false),
238
+ ]);
239
+ if (exitedCleanly) return true;
240
+ killTree(child, 'SIGKILL');
241
+ // Shorter, fixed grace after SIGKILL (vs. the injectable SIGTERM graceMs):
242
+ // SIGKILL is uncatchable, so we only need a brief beat to observe `exit`.
243
+ await Promise.race([exited, sleep(KILL_GRACE_MS)]);
244
+ return child.exitCode !== null || child.signalCode !== null;
245
+ }