@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.
- package/dist/cdk/index.d.ts +1 -1
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +27 -1
- package/dist/errors.test.d.ts +2 -0
- package/dist/errors.test.d.ts.map +1 -0
- package/dist/errors.test.js +47 -0
- package/dist/hosting.d.ts +22 -1
- package/dist/hosting.d.ts.map +1 -1
- package/dist/index.cdk.d.ts +1 -1
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
- package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
- package/dist/scripts/dev-server-supervisor.test.js +551 -0
- package/dist/scripts/dev-server.d.ts +73 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +279 -29
- package/dist/scripts/index.d.ts +1 -0
- package/dist/scripts/index.d.ts.map +1 -1
- package/dist/scripts/index.js +1 -0
- package/dist/scripts/process-tree.d.ts +126 -0
- package/dist/scripts/process-tree.d.ts.map +1 -0
- package/dist/scripts/process-tree.js +198 -0
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +41 -3
- package/dist/scripts/stack-id.d.ts +12 -0
- package/dist/scripts/stack-id.d.ts.map +1 -0
- package/dist/scripts/stack-id.js +54 -0
- package/dist/scripts/stack-id.test.d.ts +2 -0
- package/dist/scripts/stack-id.test.d.ts.map +1 -0
- package/dist/scripts/stack-id.test.js +54 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/cdk/index.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/errors.test.ts +55 -0
- package/src/errors.ts +32 -1
- package/src/hosting.ts +22 -1
- package/src/index.cdk.ts +1 -1
- package/src/index.ts +1 -1
- package/src/scripts/dev-server-supervisor.test.ts +621 -0
- package/src/scripts/dev-server.ts +316 -27
- package/src/scripts/index.ts +1 -0
- package/src/scripts/process-tree.ts +245 -0
- package/src/scripts/sandbox.ts +40 -3
- package/src/scripts/stack-id.test.ts +63 -0
- package/src/scripts/stack-id.ts +61 -0
- package/src/version.ts +1 -1
|
@@ -16,6 +16,7 @@ import { parseRpcRequest, successResponse, errorResponseFromCatch, methodNotFoun
|
|
|
16
16
|
import { redactToJson } from '../redact.js';
|
|
17
17
|
import { buildAndSendEvent } from '../telemetry/client.js';
|
|
18
18
|
import { applyDevMigrations } from './external-migrations-step.js';
|
|
19
|
+
import { killFrontendTree, terminateProcessTree } from './process-tree.js';
|
|
19
20
|
function toBodyStream(text) {
|
|
20
21
|
if (!text)
|
|
21
22
|
return null;
|
|
@@ -87,6 +88,86 @@ async function waitForPort(port, maxAttempts = 60) {
|
|
|
87
88
|
}
|
|
88
89
|
throw new Error(`Frontend server on port ${port} did not start within ${maxAttempts * 500}ms`);
|
|
89
90
|
}
|
|
91
|
+
/** Default frontend respawn budget: 5 restarts / 10s, 500ms→5s exponential backoff. */
|
|
92
|
+
export const DEFAULT_FRONTEND_RESPAWN_POLICY = {
|
|
93
|
+
maxRestarts: 5,
|
|
94
|
+
windowMs: 10_000,
|
|
95
|
+
backoffMs: 500,
|
|
96
|
+
maxBackoffMs: 5_000,
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Decide whether to auto-respawn the frontend dev server after an unexpected
|
|
100
|
+
* exit, given the timestamps of restarts not yet "forgiven".
|
|
101
|
+
*
|
|
102
|
+
* Semantics — the budget counts only *failing* restarts:
|
|
103
|
+
* - Timestamps older than `windowMs` are dropped from the sliding window.
|
|
104
|
+
* - If `maxRestarts` are still within the window, the budget is exhausted and
|
|
105
|
+
* the frontend is left down (no hot restart loop) — `restart: false`.
|
|
106
|
+
* - Otherwise `restart: true` with an exponential backoff (`backoffMs` doubled
|
|
107
|
+
* per in-window restart, capped at `maxBackoffMs`) and the new attempt
|
|
108
|
+
* appended to `recent`.
|
|
109
|
+
*
|
|
110
|
+
* This function is pure; the *meaning* of the budget is enforced by the caller,
|
|
111
|
+
* which **resets `recentRestarts` to `[]` once a respawn demonstrably succeeds**
|
|
112
|
+
* (the frontend port becomes bound — see `announceFrontendReady`). As a result
|
|
113
|
+
* only *consecutive failing* restarts accumulate toward `maxRestarts`: a
|
|
114
|
+
* frontend that legitimately restarts many times in a burst (e.g.
|
|
115
|
+
* editor-triggered Vite full reloads) refreshes its budget on each healthy bind
|
|
116
|
+
* and is never permanently left down — only a genuine crash loop that never
|
|
117
|
+
* rebinds the port trips the limit.
|
|
118
|
+
*/
|
|
119
|
+
export function evaluateFrontendRespawn(recentRestarts, now, policy = DEFAULT_FRONTEND_RESPAWN_POLICY) {
|
|
120
|
+
const recent = recentRestarts.filter((t) => now - t < policy.windowMs);
|
|
121
|
+
if (recent.length >= policy.maxRestarts) {
|
|
122
|
+
return { restart: false, delayMs: 0, recent };
|
|
123
|
+
}
|
|
124
|
+
const delayMs = Math.min(policy.backoffMs * 2 ** recent.length, policy.maxBackoffMs);
|
|
125
|
+
return { restart: true, delayMs, recent: [...recent, now] };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Wait (bounded) for a TCP port to STOP accepting connections, i.e. for the
|
|
129
|
+
* listener to actually release the socket. Used after killing the frontend so a
|
|
130
|
+
* `tsx watch` relaunch can rebind `:3100` cleanly instead of racing the kernel's
|
|
131
|
+
* socket teardown and hitting `--strictPort` `EADDRINUSE`. Resolves as soon as
|
|
132
|
+
* the port is free, or once `timeoutMs` elapses (never rejects).
|
|
133
|
+
*/
|
|
134
|
+
export async function waitForPortFree(port, timeoutMs = 2000) {
|
|
135
|
+
const { setTimeout: sleep } = await import('node:timers/promises');
|
|
136
|
+
const deadline = Date.now() + timeoutMs;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
const open = await new Promise((resolve) => {
|
|
139
|
+
const socket = createConnection({ port, host: 'localhost' }, () => {
|
|
140
|
+
socket.destroy();
|
|
141
|
+
resolve(true);
|
|
142
|
+
});
|
|
143
|
+
socket.on('error', () => { socket.destroy(); resolve(false); });
|
|
144
|
+
socket.setTimeout(200, () => { socket.destroy(); resolve(false); });
|
|
145
|
+
});
|
|
146
|
+
if (!open)
|
|
147
|
+
return;
|
|
148
|
+
await sleep(100);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Decide whether a "frontend is listening" probe should be *credited* as a
|
|
153
|
+
* successful (re)spawn — and thus reset the restart budget.
|
|
154
|
+
*
|
|
155
|
+
* `waitForPort` only proves *something* is listening on `:3100`; it cannot tell
|
|
156
|
+
* our Vite apart from a foreign listener (a leftover Vite, or a second dev
|
|
157
|
+
* server). Crediting any listener would let a foreign process on `:3100` make
|
|
158
|
+
* every `--strictPort`-failing respawn look successful, neutralizing the
|
|
159
|
+
* `maxRestarts` cap and hot-looping forever. So we credit the probe only when
|
|
160
|
+
* **our** spawned child is still the live frontend process — same identity and
|
|
161
|
+
* not yet exited. A child that already exited (e.g. it lost the `--strictPort`
|
|
162
|
+
* bind race to the foreign listener) is no longer `current`, so it is not
|
|
163
|
+
* credited and its failed attempt still counts toward the budget.
|
|
164
|
+
*/
|
|
165
|
+
export function shouldCreditFrontendReady(child, current) {
|
|
166
|
+
return (!!child &&
|
|
167
|
+
child === current &&
|
|
168
|
+
child.exitCode === null &&
|
|
169
|
+
child.signalCode === null);
|
|
170
|
+
}
|
|
90
171
|
export async function startDevServer(options) {
|
|
91
172
|
const { port = 3000, backendPath, frontendCommand, frontendPort = 3100, } = options;
|
|
92
173
|
const devStartTime = Date.now();
|
|
@@ -155,6 +236,165 @@ export async function startDevServer(options) {
|
|
|
155
236
|
res.writeHead(502);
|
|
156
237
|
res.end('Frontend server unavailable');
|
|
157
238
|
});
|
|
239
|
+
// ── Frontend supervisor ─────────────────────────────────────────────────
|
|
240
|
+
// The frontend runs under `shell: true`, so the real dev server (Vite) is a
|
|
241
|
+
// grandchild of this process. We spawn it `detached` (its own process group)
|
|
242
|
+
// on POSIX so cleanup/restart can signal the *whole* tree and free the port;
|
|
243
|
+
// otherwise the orphaned grandchild keeps `:3100` and every `/` request 502s
|
|
244
|
+
// forever (the proxy target is hardcoded to `frontendPort`). We also bound-
|
|
245
|
+
// respawn it on unexpected death and suppress all of this during shutdown.
|
|
246
|
+
//
|
|
247
|
+
// ── POST-EXIT GROUP-KILL POLICY ─────────────────────────────────────────
|
|
248
|
+
// The exact bug this supervisor fixes is the shell *exiting* while the
|
|
249
|
+
// detached grandchild survives, orphaned, still holding `:3100`. Reaping that
|
|
250
|
+
// orphan REQUIRES a group kill (`process.kill(-pid, …)`) issued *after* the
|
|
251
|
+
// shell has already exited — so all three post-exit kill sites below agree:
|
|
252
|
+
// the respawn path, `terminateFrontend`, and the `process.on('exit')` net all
|
|
253
|
+
// group-kill rather than skip when the shell is already gone.
|
|
254
|
+
//
|
|
255
|
+
// Why this is safe against the classic `-pid` PID-reuse hazard:
|
|
256
|
+
// 1. A surviving grandchild keeps the process group non-empty, so POSIX
|
|
257
|
+
// keeps `pid` reserved as the group id — it cannot be recycled as a new
|
|
258
|
+
// process id while it is still a live group's id. Hence `-pid` is
|
|
259
|
+
// guaranteed to target *our* group precisely when it matters (an orphan
|
|
260
|
+
// is still alive in it).
|
|
261
|
+
// 2. We only ever issue the kill synchronously, the instant we observe the
|
|
262
|
+
// shell's exit — there is no intervening `await` that could let the group
|
|
263
|
+
// drain and the pid be recycled — so the residual window is minimal.
|
|
264
|
+
// Residual accepted risk: if the ENTIRE group is already gone *and* `pid` has
|
|
265
|
+
// since been recycled into a brand-new group leader, `-pid` could signal an
|
|
266
|
+
// unrelated group. This is an accepted best-effort trade-off — there is then
|
|
267
|
+
// nothing of ours left to reap, whereas skipping the kill would otherwise
|
|
268
|
+
// leave `:3100` wedged, which is the failure this PR exists to prevent.
|
|
269
|
+
//
|
|
270
|
+
// Where each post-exit kill site lands on this trade-off: the two sites *in
|
|
271
|
+
// this file* — the respawn reap (in the child's `exit` handler) and the
|
|
272
|
+
// `process.on('exit')` net — fire synchronously the instant we observe the
|
|
273
|
+
// exit, so they lean on point (2) above and stay unconditional. The third
|
|
274
|
+
// path, `terminateFrontend` → `terminateProcessTree` (process-tree.ts), can
|
|
275
|
+
// run outside that minimal synchronous window, so it additionally PROBES group
|
|
276
|
+
// liveness (POSIX signal 0) and skips the reap once the group has fully
|
|
277
|
+
// drained — see its "POST-EXIT GROUP-KILL (scoped)" comment.
|
|
278
|
+
const usePosixProcessGroups = process.platform !== 'win32';
|
|
279
|
+
let isShuttingDown = false;
|
|
280
|
+
let frontendRestarts = [];
|
|
281
|
+
let respawnTimer = null;
|
|
282
|
+
const announceFrontendReady = async (child, suffix = '') => {
|
|
283
|
+
try {
|
|
284
|
+
await waitForPort(frontendPort);
|
|
285
|
+
// Reset the restart budget only when OUR child is the one now bound to
|
|
286
|
+
// `:3100`. `waitForPort` is a liveness-only probe — it cannot tell our
|
|
287
|
+
// Vite from a foreign listener (a leftover Vite or a second dev server),
|
|
288
|
+
// and crediting a foreign listener would make every `--strictPort`-failing
|
|
289
|
+
// respawn look successful, neutralizing the `maxRestarts` cap and
|
|
290
|
+
// hot-looping forever (see {@link shouldCreditFrontendReady}). Only
|
|
291
|
+
// *consecutive failing* restarts should count toward the give-up
|
|
292
|
+
// threshold, so a frontend that legitimately restarts many times (e.g.
|
|
293
|
+
// editor-triggered Vite full reloads) still never gets left down.
|
|
294
|
+
if (shouldCreditFrontendReady(child, frontendProcess)) {
|
|
295
|
+
frontendRestarts = [];
|
|
296
|
+
}
|
|
297
|
+
console.log(`\n ➜ http://localhost:${port}/${suffix}\n`);
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
console.error(`⚠️ Frontend did not start: ${e.message}`);
|
|
301
|
+
console.log(`\n ➜ http://localhost:${port}/ (API only — frontend unavailable)\n`);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const spawnFrontend = (command) => {
|
|
305
|
+
const child = spawn(command, {
|
|
306
|
+
shell: true,
|
|
307
|
+
// Own process group on POSIX so we can reap the Vite grandchild too.
|
|
308
|
+
detached: usePosixProcessGroups,
|
|
309
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
310
|
+
env: { ...process.env, NODE_OPTIONS: '' },
|
|
311
|
+
});
|
|
312
|
+
frontendProcess = child;
|
|
313
|
+
// Suppress frontend output — only show errors.
|
|
314
|
+
child.stderr?.on('data', (d) => {
|
|
315
|
+
const msg = d.toString();
|
|
316
|
+
if (!msg.includes('DeprecationWarning'))
|
|
317
|
+
process.stderr.write(msg);
|
|
318
|
+
});
|
|
319
|
+
child.on('exit', (code, signal) => {
|
|
320
|
+
// Ignore exits from a process we've already replaced or torn down.
|
|
321
|
+
if (child !== frontendProcess)
|
|
322
|
+
return;
|
|
323
|
+
frontendProcess = null;
|
|
324
|
+
if (isShuttingDown)
|
|
325
|
+
return;
|
|
326
|
+
// Reap any orphaned grandchild left in this child's group so `:3100` is
|
|
327
|
+
// free before we respawn — otherwise `--strictPort` makes the new Vite
|
|
328
|
+
// exit on bind and we'd spin until the restart budget is gone. The shell
|
|
329
|
+
// has already exited here (we are inside its `exit` handler), so this is a
|
|
330
|
+
// post-exit group kill; it is issued synchronously in this handler and is
|
|
331
|
+
// safe against PID reuse — see POST-EXIT GROUP-KILL POLICY above.
|
|
332
|
+
killFrontendTree(child, 'SIGKILL');
|
|
333
|
+
const decision = evaluateFrontendRespawn(frontendRestarts, Date.now());
|
|
334
|
+
frontendRestarts = decision.recent;
|
|
335
|
+
const why = `code=${code ?? 'null'}, signal=${signal ?? 'null'}`;
|
|
336
|
+
if (!decision.restart) {
|
|
337
|
+
console.error(`⚠️ Frontend dev server exited (${why}) and exceeded ` +
|
|
338
|
+
`${DEFAULT_FRONTEND_RESPAWN_POLICY.maxRestarts} restarts within ` +
|
|
339
|
+
`${DEFAULT_FRONTEND_RESPAWN_POLICY.windowMs / 1000}s — leaving it down. ` +
|
|
340
|
+
`Fix the error above, then restart \`npm run dev\`.`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
console.error(`⚠️ Frontend dev server exited (${why}); restarting in ${decision.delayMs}ms…`);
|
|
344
|
+
respawnTimer = setTimeout(() => {
|
|
345
|
+
respawnTimer = null;
|
|
346
|
+
if (isShuttingDown)
|
|
347
|
+
return;
|
|
348
|
+
// Before relaunching, wait (bounded) for `:3100` to actually free —
|
|
349
|
+
// mirroring the graceful `terminateFrontend` path. The synchronous
|
|
350
|
+
// post-exit SIGKILL above only *initiates* teardown of the orphaned
|
|
351
|
+
// group; the kernel can still hold the listening socket for a beat, and a
|
|
352
|
+
// relaunched `--strictPort` Vite would then hit `EADDRINUSE` and burn a
|
|
353
|
+
// restart-budget slot on a race that isn't a real crash. The budget was
|
|
354
|
+
// already debited above, so this never double-counts a restart; re-check
|
|
355
|
+
// `isShuttingDown` after the await, since a shutdown signal can land while
|
|
356
|
+
// we wait (`waitForPortFree` is bounded, so it can't deadlock shutdown).
|
|
357
|
+
void (async () => {
|
|
358
|
+
await waitForPortFree(frontendPort);
|
|
359
|
+
if (isShuttingDown)
|
|
360
|
+
return;
|
|
361
|
+
const next = spawnFrontend(command);
|
|
362
|
+
await announceFrontendReady(next, ' (frontend restarted)');
|
|
363
|
+
})();
|
|
364
|
+
}, decision.delayMs);
|
|
365
|
+
// INTENTIONAL unref: the listening HTTP `server` (created below) owns this
|
|
366
|
+
// process's lifetime — the backoff timer must NOT, by itself, keep the
|
|
367
|
+
// event loop alive. Without unref a pending respawn timer would hold the
|
|
368
|
+
// process up during shutdown (or after the server has closed), delaying or
|
|
369
|
+
// blocking a clean exit. This never drops a legitimately-needed respawn:
|
|
370
|
+
// `cleanup` explicitly clears this timer, and both the timer body and the
|
|
371
|
+
// awaited relaunch re-check `isShuttingDown`. Do NOT remove the unref to
|
|
372
|
+
// "fix" a perceived missed restart — it would reintroduce that shutdown hang.
|
|
373
|
+
respawnTimer.unref?.();
|
|
374
|
+
});
|
|
375
|
+
return child;
|
|
376
|
+
};
|
|
377
|
+
/**
|
|
378
|
+
* Gracefully terminate the frontend tree and wait (bounded) for the port to
|
|
379
|
+
* actually free before this process exits, so a `tsx watch` relaunch can
|
|
380
|
+
* rebind `:3100` cleanly. SIGTERM the group, escalate to SIGKILL if it lingers
|
|
381
|
+
* (via the shared {@link terminateProcessTree}), then poll until `:3100` is
|
|
382
|
+
* released. tsx-watch gives us ~5s before it force-kills us, so this budget is
|
|
383
|
+
* safe. Crucially the port-free wait runs on *both* paths — including when the
|
|
384
|
+
* shell has already exited — so the post-exit branch no longer drops the
|
|
385
|
+
* "wait for the port to free" guarantee.
|
|
386
|
+
*/
|
|
387
|
+
const terminateFrontend = async (child) => {
|
|
388
|
+
if (!child)
|
|
389
|
+
return;
|
|
390
|
+
// SIGTERM→SIGKILL the whole tree, reaping the detached Vite grandchild even
|
|
391
|
+
// when the shell has already exited (post-exit group kill — see policy).
|
|
392
|
+
await terminateProcessTree(child, 1500);
|
|
393
|
+
// Then wait (bounded) for `:3100` to be released. The old post-exit branch
|
|
394
|
+
// returned right after SIGKILL with no port poll, so a relaunch could race
|
|
395
|
+
// the kernel's socket teardown and hit `--strictPort` `EADDRINUSE`.
|
|
396
|
+
await waitForPortFree(frontendPort);
|
|
397
|
+
};
|
|
158
398
|
// ── API Gateway proxy (sandbox mode) ───────────────────────────────────
|
|
159
399
|
// `changeOrigin: true` rewrites the outgoing `Host` to the execute-api target
|
|
160
400
|
// (required for API Gateway's TLS SNI / host-based routing). That would make
|
|
@@ -266,35 +506,13 @@ export async function startDevServer(options) {
|
|
|
266
506
|
await writeClientCode(resolvedPath, clientPath);
|
|
267
507
|
}
|
|
268
508
|
// ── Start listening ────────────────────────────────────────────────────
|
|
269
|
-
server.listen(port,
|
|
509
|
+
server.listen(port, async () => {
|
|
270
510
|
console.log(`AWS Blocks local server running on http://localhost:${port}`);
|
|
271
511
|
buildAndSendEvent({ command: 'dev', state: 'SUCCESS', duration: Date.now() - devStartTime });
|
|
272
512
|
// Spawn frontend dev server after Blocks server is ready
|
|
273
513
|
if (frontendCommand) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
277
|
-
env: { ...process.env, NODE_OPTIONS: '' },
|
|
278
|
-
});
|
|
279
|
-
// Suppress frontend output — only show errors
|
|
280
|
-
frontendProcess.stderr?.on('data', (d) => {
|
|
281
|
-
const msg = d.toString();
|
|
282
|
-
if (!msg.includes('DeprecationWarning'))
|
|
283
|
-
process.stderr.write(msg);
|
|
284
|
-
});
|
|
285
|
-
frontendProcess.on('exit', (code) => {
|
|
286
|
-
if (code !== 0 && code !== null) {
|
|
287
|
-
console.error(`⚠️ Frontend process exited with code ${code}`);
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
try {
|
|
291
|
-
await waitForPort(frontendPort);
|
|
292
|
-
console.log(`\n ➜ http://localhost:${port}/\n`);
|
|
293
|
-
}
|
|
294
|
-
catch (e) {
|
|
295
|
-
console.error(`⚠️ Frontend did not start: ${e.message}`);
|
|
296
|
-
console.log(`\n ➜ http://localhost:${port}/ (API only — frontend unavailable)\n`);
|
|
297
|
-
}
|
|
514
|
+
const child = spawnFrontend(frontendCommand);
|
|
515
|
+
await announceFrontendReady(child);
|
|
298
516
|
}
|
|
299
517
|
else {
|
|
300
518
|
console.log(`\n ➜ http://localhost:${port}/\n`);
|
|
@@ -305,10 +523,26 @@ export async function startDevServer(options) {
|
|
|
305
523
|
buildAndSendEvent({ command: 'dev', state: 'FAIL', duration: Date.now() - devStartTime, error: { code: errorCode, phase: 'startup' } });
|
|
306
524
|
});
|
|
307
525
|
// ── Cleanup ────────────────────────────────────────────────────────────
|
|
526
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
527
|
+
let cleaningUp = false;
|
|
308
528
|
const cleanup = async () => {
|
|
529
|
+
if (cleaningUp)
|
|
530
|
+
return; // idempotent — a second signal must not re-enter
|
|
531
|
+
cleaningUp = true;
|
|
532
|
+
isShuttingDown = true; // stop the supervisor from respawning the frontend
|
|
309
533
|
console.log('\nShutting down...');
|
|
310
|
-
if (
|
|
311
|
-
|
|
534
|
+
if (respawnTimer) {
|
|
535
|
+
clearTimeout(respawnTimer);
|
|
536
|
+
respawnTimer = null;
|
|
537
|
+
}
|
|
538
|
+
// Detach our own listeners so repeated signals can't pile up handlers.
|
|
539
|
+
for (const sig of signals)
|
|
540
|
+
process.removeListener(sig, cleanup);
|
|
541
|
+
// Kill the frontend process *group* and wait for the port to free before
|
|
542
|
+
// we exit, so a tsx-watch restart can rebind `:3100` cleanly.
|
|
543
|
+
const child = frontendProcess;
|
|
544
|
+
frontendProcess = null;
|
|
545
|
+
await terminateFrontend(child);
|
|
312
546
|
if (typeof backend.__cleanup === 'function') {
|
|
313
547
|
try {
|
|
314
548
|
await backend.__cleanup();
|
|
@@ -320,8 +554,24 @@ export async function startDevServer(options) {
|
|
|
320
554
|
server.close(() => process.exit(0));
|
|
321
555
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
322
556
|
};
|
|
323
|
-
|
|
324
|
-
|
|
557
|
+
for (const sig of signals)
|
|
558
|
+
process.on(sig, cleanup);
|
|
559
|
+
// Last-resort safety net for paths that bypass `cleanup` (e.g. an uncaught
|
|
560
|
+
// exception terminating the process): synchronously reap the frontend tree so
|
|
561
|
+
// a `detached` Vite is never left orphaned on `:3100`. Reuses
|
|
562
|
+
// `killFrontendTree`, so unlike the old hand-rolled `process.kill(-pid)` it
|
|
563
|
+
// also reaps on Windows (via `taskkill`) instead of early-returning and
|
|
564
|
+
// leaking the Vite tree, and stays in lockstep with the other kill sites. Both
|
|
565
|
+
// the POSIX group kill and the Windows `taskkill` are synchronous, so this is
|
|
566
|
+
// legal in an `exit` handler; it reaps even when the shell has already exited
|
|
567
|
+
// (a surviving grandchild keeps the group alive) — see POST-EXIT GROUP-KILL
|
|
568
|
+
// POLICY above.
|
|
569
|
+
process.once('exit', () => {
|
|
570
|
+
const child = frontendProcess;
|
|
571
|
+
if (!child)
|
|
572
|
+
return;
|
|
573
|
+
killFrontendTree(child, 'SIGKILL');
|
|
574
|
+
});
|
|
325
575
|
}
|
|
326
576
|
// ── Local API handler ────────────────────────────────────────────────────────
|
|
327
577
|
function handleApiRequest(req, res, url, method, apis) {
|
package/dist/scripts/index.d.ts
CHANGED
|
@@ -9,4 +9,5 @@ export { openConsole, type ConsoleOptions } from './console.js';
|
|
|
9
9
|
export { ensureSecrets, loadProductionEnv, loadEnvFile } from './ensure-secrets.js';
|
|
10
10
|
export { trackCommand, buildAndSendEvent, classifyError, type CommandName, type CommandState, type BuildAndSendEventOptions, } from '../telemetry/index.js';
|
|
11
11
|
export { telemetry, type TelemetryOptions } from './telemetry.js';
|
|
12
|
+
export { getStackId, getSandboxId } from './stack-id.js';
|
|
12
13
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scripts/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpF,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,wBAAwB,GAC9B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scripts/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpF,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,wBAAwB,GAC9B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/scripts/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { openConsole } from './console.js';
|
|
|
11
11
|
export { ensureSecrets, loadProductionEnv, loadEnvFile } from './ensure-secrets.js';
|
|
12
12
|
export { trackCommand, buildAndSendEvent, classifyError, } from '../telemetry/index.js';
|
|
13
13
|
export { telemetry } from './telemetry.js';
|
|
14
|
+
export { getStackId, getSandboxId } from './stack-id.js';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Minimal child-process surface needed to terminate a frontend dev server. */
|
|
2
|
+
export interface KillableProcess {
|
|
3
|
+
pid?: number;
|
|
4
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Subset of {@link import('node:child_process').SpawnSyncReturns} that {@link windowsTreeKill} inspects. */
|
|
7
|
+
interface TreeKillResult {
|
|
8
|
+
status: number | null;
|
|
9
|
+
error?: Error;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Force-kill an entire process tree on Windows via `taskkill /T /F /PID <pid>`.
|
|
13
|
+
*
|
|
14
|
+
* Windows has no POSIX process groups, so a bare `child.kill()` only signals the
|
|
15
|
+
* spawned shell and orphans the real dev server (the Vite grandchild), which
|
|
16
|
+
* keeps holding `:3100` — the very wedge the POSIX process-group kill fixes.
|
|
17
|
+
* `taskkill /T` walks the live child tree by PID and terminates every
|
|
18
|
+
* descendant; `/F` is required because Windows cannot deliver a graceful
|
|
19
|
+
* shutdown to a non-console subtree anyway (Node maps SIGTERM/SIGKILL to
|
|
20
|
+
* `TerminateProcess`).
|
|
21
|
+
*
|
|
22
|
+
* Returns `true` only when `taskkill` ran AND reported the tree handled — exit
|
|
23
|
+
* `0` (reaped the tree) or `128` (`"process not found"`, i.e. already gone).
|
|
24
|
+
* Returns `false` when the command could not be spawned at all (e.g. not on
|
|
25
|
+
* `PATH`) OR when it ran but returned any other status (e.g. `1` = access
|
|
26
|
+
* denied): such a run did NOT reap the tree, so the caller must fall back to a
|
|
27
|
+
* direct `child.kill` rather than treat the leak as handled. (`child.kill`
|
|
28
|
+
* cannot reap the orphaned grandchild either, but the fallback is cheap and
|
|
29
|
+
* strictly correct — we never silently swallow a failed tree-kill.) Never
|
|
30
|
+
* throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function windowsTreeKill(pid: number, runner?: (command: string, args: readonly string[]) => TreeKillResult): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Terminate a process spawned with `shell: true`, including its descendants, on
|
|
35
|
+
* every platform.
|
|
36
|
+
*
|
|
37
|
+
* Under a shell the real dev server (e.g. Vite) is a **grandchild**: the direct
|
|
38
|
+
* child is the shell, so signalling only the shell (`child.kill`) orphans the
|
|
39
|
+
* grandchild, which keeps holding its port (`:3100`) and wedges the next
|
|
40
|
+
* restart.
|
|
41
|
+
*
|
|
42
|
+
* - **POSIX**: the process is spawned `detached` (its own process group,
|
|
43
|
+
* pgid === child.pid), so we signal the whole group with
|
|
44
|
+
* `process.kill(-pid, signal)` and every descendant dies, freeing the port.
|
|
45
|
+
* - **Windows**: there are no process groups, so we reap the tree with
|
|
46
|
+
* `taskkill /T /F /PID <pid>` (see {@link windowsTreeKill}), which walks the
|
|
47
|
+
* child tree by PID. A bare `child.kill` would leave the Vite grandchild
|
|
48
|
+
* bound to `:3100`, reproducing the POSIX wedge.
|
|
49
|
+
*
|
|
50
|
+
* Best-effort and never throws: a missing/invalid pid, an already-dead group
|
|
51
|
+
* (ESRCH), a failed group signal, or an unavailable `taskkill` all degrade to a
|
|
52
|
+
* direct `child.kill`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function killFrontendTree(child: KillableProcess, signal?: NodeJS.Signals, platform?: NodeJS.Platform, killFn?: (pid: number, signal: NodeJS.Signals) => void, winTreeKill?: (pid: number) => boolean): void;
|
|
55
|
+
/** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
|
|
56
|
+
export interface AwaitableChild extends KillableProcess {
|
|
57
|
+
exitCode: number | null;
|
|
58
|
+
signalCode: NodeJS.Signals | null;
|
|
59
|
+
once(event: 'exit', listener: () => void): unknown;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Grace (ms) we wait for the child's `exit` event *after* SIGKILL before giving
|
|
63
|
+
* up and reporting its last-known exit state. Deliberately shorter than — and
|
|
64
|
+
* intentionally decoupled from — the injectable SIGTERM `graceMs`: SIGKILL
|
|
65
|
+
* cannot be caught, blocked, or handled, so the child is already being
|
|
66
|
+
* force-terminated; we only need a brief beat to observe the `exit` event, not a
|
|
67
|
+
* full, tunable shutdown window. Fixed (not a parameter) because no caller needs
|
|
68
|
+
* to tune it — the injected `sleep` is the test seam.
|
|
69
|
+
*/
|
|
70
|
+
export declare const KILL_GRACE_MS = 500;
|
|
71
|
+
/**
|
|
72
|
+
* Probe whether a detached process *group* still has at least one live member,
|
|
73
|
+
* **without signalling it**. Used to scope the post-exit group SIGKILL in
|
|
74
|
+
* {@link terminateProcessTree} to the only window where the `-pid` group signal
|
|
75
|
+
* is PID-reuse-safe.
|
|
76
|
+
*
|
|
77
|
+
* The hazard: {@link killFrontendTree}'s POSIX reap is `process.kill(-pid, …)`,
|
|
78
|
+
* which targets the process group whose gid is `pid`. That is safe only while a
|
|
79
|
+
* group member is still alive — a survivor keeps the kernel from recycling
|
|
80
|
+
* `pid` as a brand-new (unrelated) group leader. Once the whole group has
|
|
81
|
+
* drained, `pid` is eligible for reuse and a blind `-pid` kill could land on an
|
|
82
|
+
* unrelated group. So before a *post-exit* reap we probe here and skip when the
|
|
83
|
+
* group has already drained (there is then nothing of ours left to reap).
|
|
84
|
+
*
|
|
85
|
+
* - **POSIX**: `kill(-pid, 0)` sends no signal — it only checks the group
|
|
86
|
+
* exists and is signallable. Success or `EPERM` (exists but owned by another
|
|
87
|
+
* user) ⇒ alive. `ESRCH` (or anything else) ⇒ treat as drained.
|
|
88
|
+
* - **Windows**: there are no process groups and the reap path
|
|
89
|
+
* (`taskkill /T /F /PID`) walks the live PID tree, so there is no `-pid`
|
|
90
|
+
* recycle hazard — always allow the reap (`true`).
|
|
91
|
+
*
|
|
92
|
+
* Never throws. `platform`/`kill` are injected for tests.
|
|
93
|
+
*/
|
|
94
|
+
export declare function isProcessGroupAlive(pid: number, platform?: NodeJS.Platform, kill?: (pid: number, signal: number) => void): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Terminate a child process *tree* and wait — bounded — for the child to exit,
|
|
97
|
+
* escalating SIGTERM → SIGKILL. Reuses {@link killFrontendTree} so every
|
|
98
|
+
* entrypoint reaps the same way (POSIX process-group kill / Windows `taskkill`)
|
|
99
|
+
* instead of hand-rolling its own group kill.
|
|
100
|
+
*
|
|
101
|
+
* Post-exit policy: if the child has *already* exited, a detached grandchild may
|
|
102
|
+
* still be orphaned (still holding a port), so we issue one best-effort group
|
|
103
|
+
* SIGKILL to reap it — but ONLY when the group still has a live member
|
|
104
|
+
* ({@link isProcessGroupAlive}). When the whole group has already drained (the
|
|
105
|
+
* common healthy shutdown — Vite was already gone), `pid` is eligible for
|
|
106
|
+
* recycling and a blind `-pid` signal could hit an unrelated, newly created
|
|
107
|
+
* group; since there is also nothing of ours left to reap, we skip the kill.
|
|
108
|
+
* See the dev server's "POST-EXIT GROUP-KILL POLICY" for the full rationale and
|
|
109
|
+
* the accepted residual (the synchronous probe→kill window). Otherwise we
|
|
110
|
+
* SIGTERM the tree, wait up to `graceMs` for a clean exit, then SIGKILL the tree
|
|
111
|
+
* and wait a short grace.
|
|
112
|
+
*
|
|
113
|
+
* Return value — IMPORTANT: the boolean reflects only the **direct child's**
|
|
114
|
+
* exit state (its `exitCode`/`signalCode`), NOT whole-group teardown or port
|
|
115
|
+
* release. On POSIX the SIGKILL is delivered to the whole group (`-pid`), but a
|
|
116
|
+
* surviving *detached grandchild* can outlive the awaited child and keep holding
|
|
117
|
+
* a port even after this resolves `true`. So `true` means only "the child we
|
|
118
|
+
* awaited has exited (or was already gone)" and `false` means "it was still
|
|
119
|
+
* alive when the budget elapsed" — neither guarantees the port is free. Callers
|
|
120
|
+
* that need a freed port MUST follow this with a bounded port-free wait (see
|
|
121
|
+
* `waitForPortFree` in dev-server.ts, which the dev-server child's own SIGTERM
|
|
122
|
+
* handler runs). Dependencies are injected for tests.
|
|
123
|
+
*/
|
|
124
|
+
export declare function terminateProcessTree(child: AwaitableChild, graceMs?: number, killTree?: (c: KillableProcess, signal: NodeJS.Signals) => void, sleep?: (ms: number) => Promise<void>, isGroupAlive?: (pid: number) => boolean): Promise<boolean>;
|
|
125
|
+
export {};
|
|
126
|
+
//# sourceMappingURL=process-tree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-tree.d.ts","sourceRoot":"","sources":["../../src/scripts/process-tree.ts"],"names":[],"mappings":"AAaA,+EAA+E;AAC/E,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;CACjD;AAED,6GAA6G;AAC7G,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,cACwB,GAC7E,OAAO,CAaT;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,eAAe,EACtB,MAAM,GAAE,MAAM,CAAC,OAAmB,EAClC,QAAQ,GAAE,MAAM,CAAC,QAA2B,EAC5C,MAAM,GAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,KAAK,IAAmC,EACpF,WAAW,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAyB,GACtD,IAAI,CAqBN;AAED,iGAAiG;AACjG,MAAM,WAAW,cAAe,SAAQ,eAAe;IACrD,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAClC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;CACpD;AAOD;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAM,CAAC,QAA2B,EAC5C,IAAI,GAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAmC,GACzE,OAAO,CAQT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,cAAc,EACrB,OAAO,SAAO,EACd,QAAQ,GAAE,CAAC,CAAC,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,KAAK,IAAuB,EACjF,KAAK,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAgB,EACnD,YAAY,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAA6B,GAC3D,OAAO,CAAC,OAAO,CAAC,CAkClB"}
|