Skip to content

Commit 6eb731a

Browse files
authored
fix(dev-server): auto-respawn frontend and kill process group on restart (#80)
1 parent 607fe57 commit 6eb731a

5 files changed

Lines changed: 1302 additions & 29 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
"@aws-blocks/core": patch
3+
---
4+
5+
fix(dev-server): auto-respawn frontend and kill the whole process group on restart
6+
7+
The dev server spawns the frontend (Vite) with `shell: true`, making the real
8+
Vite process a **grandchild** (shell → npx → node vite). On a `tsx watch`
9+
restart, cleanup sent `SIGTERM` to only the shell parent, orphaning the Vite
10+
grandchild — it survived still bound to `:3100`. The freshly launched Vite then
11+
hit `--strictPort`, failed to bind, and exited; the `exit` handler only logged,
12+
so `/` served a permanent `502 Frontend server unavailable` with no recovery.
13+
14+
Fixes:
15+
16+
- **Process-group kill** — the frontend is spawned `detached` on POSIX (its own
17+
process group) and cleanup/restart now signal the entire group via
18+
`process.kill(-pid, …)`, reaping the Vite grandchild and freeing `:3100`.
19+
Windows (no POSIX groups) reaps the tree with `taskkill /T /F /PID <pid>`,
20+
which walks the child tree by PID so the Vite grandchild is killed too; it
21+
degrades to a direct child kill only if `taskkill` cannot be spawned.
22+
- **Bounded auto-respawn** — an unexpected frontend exit now respawns Vite with
23+
exponential backoff, capped at 5 restarts / 10s to avoid hot loops, and is
24+
suppressed during intentional shutdown via an `isShuttingDown` guard. The
25+
budget counts only *consecutive failing* restarts: it resets only when **our
26+
own** freshly spawned child is the process now bound to the port. A liveness
27+
probe alone cannot tell our Vite from a foreign listener (a leftover Vite or a
28+
second dev server), and crediting a foreign one would make every
29+
`--strictPort`-failing respawn look successful, neutralizing the cap and
30+
hot-looping forever. A frontend that legitimately restarts many times (e.g.
31+
editor-triggered full reloads) is still never permanently left down. Before
32+
each relaunch the supervisor now also waits (bounded) for `:3100` to be
33+
released — the same port-free drain the graceful shutdown path uses — so a slow
34+
socket teardown can't hand the relaunched `--strictPort` Vite an `EADDRINUSE`
35+
and burn a restart-budget slot; that wait re-checks the `isShuttingDown` guard,
36+
so a shutdown arriving mid-wait still cancels the relaunch (and the budget is
37+
debited once, at exit time, so the wait never double-counts a restart).
38+
- **Robust shutdown** — cleanup is idempotent, wired to `SIGINT`/`SIGTERM`/
39+
`SIGHUP`, removes its own listeners, and waits (bounded) for the group to die
40+
**and for `:3100` to actually be released** before exiting: SIGTERM→SIGKILL
41+
escalation, then a port-free poll that runs on *both* the live and the
42+
already-exited paths (the post-exit path previously skipped it, so a relaunch
43+
could race the kernel's socket teardown into `--strictPort` `EADDRINUSE`). A
44+
synchronous `process.on('exit')` safety net remains for paths that bypass
45+
cleanup — now routed through the shared tree-kill so it reaps on Windows
46+
(`taskkill`) too instead of early-returning and leaking the Vite tree.
47+
- **Consistent post-exit reaping** — the failure being fixed is the *shell
48+
exiting while the detached grandchild survives*, so every post-exit path
49+
(the respawn handler, graceful shutdown, and the `exit` safety net) now
50+
issues one best-effort process-group kill even after the shell has gone,
51+
rather than skipping it. A surviving grandchild keeps the group's id reserved
52+
on POSIX, so `process.kill(-pid)` still targets our own group; the kills are
53+
issued synchronously on observing the exit to keep the PID-reuse window
54+
minimal. The single rationale lives next to the supervisor as the
55+
"POST-EXIT GROUP-KILL POLICY" so all three sites stay in agreement.
56+
- **Sandbox entrypoint parity**`sandbox.ts` (the sibling dev entrypoint) now
57+
spawns **both** long-running children — the dev server *and* `cdk watch` — in
58+
their own process groups and `await`s a bounded group teardown for each
59+
(run concurrently) on `SIGINT`/`SIGTERM`, replacing the synchronous
60+
`cdkWatch.kill()` + single dev-server `kill()` + `process.exit(0)` that
61+
signalled only the npx/shell parents and exited immediately. A bare
62+
`cdkWatch.kill()` could orphan the real `cdk watch` node process
63+
(npx → cdk → node) — the same shell-only-kill leak this PR fixes for the dev
64+
server — so it now routes through the shared `terminateProcessTree` too. Only
65+
the dev-server drain (the longer 6s budget) owns the `:3100` port-free wait, via
66+
its own SIGTERM handler, so the next `npm run sandbox` no longer races a
67+
survivor on `:3100`.
68+
- **Single tree-kill primitive** — the POSIX group-kill, the Windows `taskkill`
69+
tree-kill, and the bounded SIGTERM→SIGKILL teardown now live in one shared
70+
`process-tree.ts` module used by every entrypoint (dev server, respawn
71+
handler, `exit` net, and sandbox), so the reaping behavior can no longer drift
72+
between hand-rolled copies. Its bounded teardown documents that its boolean
73+
reflects only the **direct child's** exit (not whole-group teardown or port
74+
release — callers needing a freed port must follow with `waitForPortFree`), and
75+
its post-SIGKILL grace is a named `KILL_GRACE_MS` constant kept deliberately
76+
shorter than the injectable SIGTERM grace (SIGKILL is uncatchable, so only a
77+
brief beat is needed to observe the exit).
78+
79+
`--strictPort` is intentionally retained: the proxy target is hardcoded to
80+
`:3100`, so the port is reliably freed rather than letting Vite drift to another
81+
port the proxy wouldn't follow.

0 commit comments

Comments
 (0)