@aws-blocks/core 0.1.10 → 0.1.12

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.
@@ -4,13 +4,14 @@
4
4
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
5
5
  import { pathToFileURL, URL } from 'node:url';
6
6
  import { resolve, dirname, join } from 'node:path';
7
- import { writeFileSync, mkdirSync } from 'node:fs';
7
+ import { writeFileSync, mkdirSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
8
8
  import { spawn, type ChildProcess } from 'node:child_process';
9
9
  import { createConnection } from 'node:net';
10
10
  import httpProxy from 'http-proxy';
11
11
  import { writeClientCode } from './generate-client.js';
12
12
  import { ApiError } from '../errors.js';
13
13
  import { BLOCKS_RPC_PREFIX, BLOCKS_SANDBOX_PREFIX } from '../constants.js';
14
+ import { BLOCKS_SANDBOX_DIR } from '../common/constants.js';
14
15
  import { matchRoute, lockRouteRegistry } from '../raw-route.js';
15
16
  import { registerBuiltinRoutes } from '../builtin-routes.js';
16
17
  import {
@@ -22,7 +23,7 @@ import {
22
23
  import { redactToJson } from '../redact.js';
23
24
  import { buildAndSendEvent } from '../telemetry/client.js';
24
25
  import { applyDevMigrations } from './external-migrations-step.js';
25
- import { killFrontendTree, terminateProcessTree } from './process-tree.js';
26
+ import { killFrontendTree, terminateProcessTree, findListenerPids, killListenerTree } from './process-tree.js';
26
27
 
27
28
  function toBodyStream(text: string): ReadableStream<Uint8Array> | null {
28
29
  if (!text) return null;
@@ -102,19 +103,45 @@ async function deployLocal(backend: Record<string, any>): Promise<void> {
102
103
  await Promise.all(initPromises);
103
104
  }
104
105
 
105
- /** Wait for a port to accept TCP connections. */
106
- async function waitForPort(port: number, maxAttempts = 60): Promise<void> {
107
- const { setTimeout: sleep } = await import('node:timers/promises');
108
- for (let i = 0; i < maxAttempts; i++) {
109
- const connected = await new Promise<boolean>((resolve) => {
110
- const socket = createConnection({ port, host: 'localhost' }, () => {
106
+ /**
107
+ * Single-shot TCP probe: resolves `true` iff a connection to `port` succeeds
108
+ * within `timeoutMs`, else `false` (connection error or timeout). Never rejects.
109
+ *
110
+ * When `host` is omitted the port is probed on BOTH loopback families —
111
+ * `127.0.0.1` (IPv4) and `::1` (IPv6) and reported open if EITHER answers.
112
+ * This matters because `server.listen(port, …)` (below) passes no host, so Node
113
+ * binds dual-stack on `::` (all interfaces, both families). A single `localhost`
114
+ * probe resolves to only one of `::1`/`127.0.0.1` on a given host, so an orphan
115
+ * holding the port on the *other* family would be invisible — leaving the
116
+ * startup/EADDRINUSE reclaim path blind to a port that `listen` will still
117
+ * reject. Probing both families keeps every "is the port bound" check in
118
+ * agreement with what `listen` actually contends for. Pass an explicit `host`
119
+ * to probe only that address.
120
+ *
121
+ * Shared by {@link waitForPort} (wait until open), {@link waitForPortFree} (wait
122
+ * until closed) and the startup/EADDRINUSE reclaim path so all three agree on
123
+ * exactly what "the port is bound" means.
124
+ */
125
+ export async function isPortOpen(port: number, host?: string, timeoutMs = 200): Promise<boolean> {
126
+ const probeOne = (h: string): Promise<boolean> =>
127
+ new Promise<boolean>((resolve) => {
128
+ const socket = createConnection({ port, host: h }, () => {
111
129
  socket.destroy();
112
130
  resolve(true);
113
131
  });
114
132
  socket.on('error', () => { socket.destroy(); resolve(false); });
115
- socket.setTimeout(300, () => { socket.destroy(); resolve(false); });
133
+ socket.setTimeout(timeoutMs, () => { socket.destroy(); resolve(false); });
116
134
  });
117
- if (connected) return;
135
+ if (host !== undefined) return probeOne(host);
136
+ const [v4, v6] = await Promise.all([probeOne('127.0.0.1'), probeOne('::1')]);
137
+ return v4 || v6;
138
+ }
139
+
140
+ /** Wait for a port to accept TCP connections. */
141
+ async function waitForPort(port: number, maxAttempts = 60): Promise<void> {
142
+ const { setTimeout: sleep } = await import('node:timers/promises');
143
+ for (let i = 0; i < maxAttempts; i++) {
144
+ if (await isPortOpen(port, undefined, 300)) return;
118
145
  await sleep(500);
119
146
  }
120
147
  throw new Error(`Frontend server on port ${port} did not start within ${maxAttempts * 500}ms`);
@@ -198,15 +225,7 @@ export async function waitForPortFree(port: number, timeoutMs = 2000): Promise<v
198
225
  const { setTimeout: sleep } = await import('node:timers/promises');
199
226
  const deadline = Date.now() + timeoutMs;
200
227
  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;
228
+ if (!(await isPortOpen(port, undefined, 200))) return;
210
229
  await sleep(100);
211
230
  }
212
231
  }
@@ -237,6 +256,265 @@ export function shouldCreditFrontendReady(
237
256
  );
238
257
  }
239
258
 
259
+ // ── Startup / EADDRINUSE port reclaim ──────────────────────────────────────
260
+
261
+ /** Outcome of {@link reclaimPort}. */
262
+ export interface ReclaimResult {
263
+ /** Whether the port was bound when reclaim started. */
264
+ wasOpen: boolean;
265
+ /** Whether the port is free once reclaim finishes (true when it was never open). */
266
+ reclaimed: boolean;
267
+ /** Listener PIDs discovered (and signalled). Empty when the port was free, or no owner PID was found. */
268
+ pids: number[];
269
+ }
270
+
271
+ /** Injectable seams for {@link reclaimPort} (real implementations by default). */
272
+ export interface ReclaimPortDeps {
273
+ /** True iff the port is currently bound. */
274
+ probe: (port: number) => Promise<boolean>;
275
+ /** PIDs of the listener(s) holding the port. */
276
+ listPids: (port: number) => number[];
277
+ /** Terminate a listener PID's tree (POSIX group kill / Windows taskkill). */
278
+ killTree: (pid: number, signal: NodeJS.Signals) => void;
279
+ /** Wait (bounded) for the port to be released. */
280
+ waitFree: (port: number, timeoutMs?: number) => Promise<void>;
281
+ }
282
+
283
+ /**
284
+ * Free a port left bound by a crashed / SIGKILL'd predecessor so a fresh dev
285
+ * server can bind the `:3000` front door — or spawn its `--strictPort` frontend
286
+ * on `:3100` — instead of colliding on it and crashing.
287
+ *
288
+ * No-op when the port is already free. Otherwise it discovers the listener PID(s)
289
+ * ({@link findListenerPids}, an `lsof`/`netstat` probe — the same fuser-style
290
+ * mechanism `cleanup` uses), SIGTERMs each ({@link killListenerTree}, which
291
+ * reuses the frontend process-group kill), waits (bounded) for release
292
+ * ({@link waitForPortFree}), then escalates to SIGKILL if the port is still held.
293
+ * This deliberately mirrors the respawn path (process-group kill + port-free
294
+ * wait) rather than inventing a new teardown mechanism.
295
+ *
296
+ * The caller is responsible for NOT reclaiming a *healthy peer* dev server — the
297
+ * singleton guard ({@link evaluateSingleton}) runs first and bows out when a live
298
+ * peer owns the front door, so anything still holding these ports here is an
299
+ * orphan. Dependencies are injected for tests; returns what it did for
300
+ * logging/assertions. Best-effort: never throws.
301
+ *
302
+ * Probe contract: the default `probe` is {@link isPortOpen} with no host, which
303
+ * checks BOTH `127.0.0.1` and `::1`. `server.listen` binds dual-stack on `::`,
304
+ * so an orphan holding the port on either loopback family is detected (and thus
305
+ * reclaimed) — a single-family `localhost` probe could miss it and no-op.
306
+ */
307
+ export async function reclaimPort(port: number, deps: Partial<ReclaimPortDeps> = {}): Promise<ReclaimResult> {
308
+ const probe = deps.probe ?? ((p) => isPortOpen(p));
309
+ const listPids = deps.listPids ?? ((p) => findListenerPids(p));
310
+ const killTree = deps.killTree ?? ((pid, sig) => killListenerTree(pid, sig));
311
+ const waitFree = deps.waitFree ?? ((p, t) => waitForPortFree(p, t));
312
+
313
+ if (!(await probe(port))) return { wasOpen: false, reclaimed: true, pids: [] };
314
+
315
+ const pids = listPids(port);
316
+ for (const pid of pids) killTree(pid, 'SIGTERM');
317
+ await waitFree(port, 2000);
318
+
319
+ if (await probe(port)) {
320
+ // Still held after a graceful SIGTERM — escalate to SIGKILL. ALWAYS re-list
321
+ // first: the owner set can change during the wait — the original process may
322
+ // have exited and a NEW one grabbed the port, so SIGKILLing the stale `pids`
323
+ // would miss the real owner. Fall back to the stale list only if the re-list
324
+ // comes back empty (e.g. lsof was momentarily unavailable).
325
+ const killPids = listPids(port);
326
+ for (const pid of killPids.length ? killPids : pids) killTree(pid, 'SIGKILL');
327
+ await waitFree(port, 2000);
328
+ }
329
+
330
+ return { wasOpen: true, reclaimed: !(await probe(port)), pids };
331
+ }
332
+
333
+ /**
334
+ * Build the console message for a startup reclaim of a port that was in use,
335
+ * distinguishing the three meaningfully different outcomes so the operator knows
336
+ * what (if anything) to do next:
337
+ * - reclaimed → we freed it; startup continues.
338
+ * - not reclaimed but owner PID(s) known → tell the operator exactly which
339
+ * process(es) to stop and retry.
340
+ * - not reclaimed and no owner PID found → nothing to point at (lsof/netstat
341
+ * found no listener), so surface the generic "in use" message.
342
+ * `subject` describes what was reclaimed (e.g. 'a stale/orphaned listener'). Pure.
343
+ */
344
+ export function reclaimMessage(port: number, result: ReclaimResult, subject: string): string {
345
+ if (result.reclaimed) {
346
+ return `♻️ Reclaimed port ${port} from ${subject} before startup.`;
347
+ }
348
+ if (result.pids.length > 0) {
349
+ return (
350
+ `⚠️ Port ${port} held by pid(s) [${result.pids.join(', ')}] and could not be freed — ` +
351
+ `stop that process and retry.`
352
+ );
353
+ }
354
+ return `⚠️ Port ${port} is in use and could not be reclaimed automatically (no owner PID found).`;
355
+ }
356
+
357
+ /** Bounded retry policy for binding the `:3000` front door under EADDRINUSE. */
358
+ export interface PortBindRetryPolicy {
359
+ /** Total bind attempts tolerated before giving up (exit non-zero). */
360
+ maxAttempts: number;
361
+ /** Base backoff (ms); scaled by the attempt number between retries. */
362
+ backoffMs: number;
363
+ }
364
+
365
+ /** Default front-door bind retry budget: 3 attempts, 250ms→750ms linear backoff. */
366
+ export const DEFAULT_PORT_BIND_RETRY_POLICY: PortBindRetryPolicy = {
367
+ maxAttempts: 3,
368
+ backoffMs: 250,
369
+ };
370
+
371
+ /**
372
+ * Decide whether an EADDRINUSE on the `:3000` front door should trigger another
373
+ * reclaim-and-rebind attempt. `attempt` is the number of failures so far
374
+ * (1-based). Returns `retry: false` once the budget is exhausted so the caller
375
+ * exits non-zero with a clear message rather than looping forever. Pure.
376
+ */
377
+ export function evaluatePortBindRetry(
378
+ attempt: number,
379
+ policy: PortBindRetryPolicy = DEFAULT_PORT_BIND_RETRY_POLICY,
380
+ ): { retry: boolean; delayMs: number } {
381
+ if (attempt >= policy.maxAttempts) return { retry: false, delayMs: 0 };
382
+ return { retry: true, delayMs: policy.backoffMs * attempt };
383
+ }
384
+
385
+ /** Injectable seams for {@link createBindRetryController} (real implementations wired at the call site). */
386
+ export interface BindRetryDeps {
387
+ /** Reclaim the contended port; its result drives the operator message. */
388
+ reclaim: (port: number) => Promise<ReclaimResult>;
389
+ /** Re-attempt the bind (`server.listen(port, onListening)` in prod). */
390
+ relisten: () => void;
391
+ /** Schedule the next attempt after a backoff (`setTimeout` in prod). */
392
+ scheduleRetry: (fn: () => void, delayMs: number) => void;
393
+ /** Called once the attempt budget is exhausted (`process.exit(1)` in prod). */
394
+ onExhausted: () => void;
395
+ /** Warning/error sink (`console.error` in prod). */
396
+ warn: (msg: string) => void;
397
+ }
398
+
399
+ /**
400
+ * Build the `:3000` front-door EADDRINUSE bind-retry handler. Extracted from the
401
+ * `server.on('error')` closure so the retry *wiring* — the 1-based attempt
402
+ * counter, the bounded {@link evaluatePortBindRetry} decision, reclaim-result
403
+ * routing, and retry scheduling — is unit-testable without a real socket.
404
+ *
405
+ * Returns a function to invoke on each EADDRINUSE. Per invocation it:
406
+ * - increments the attempt counter and consults {@link evaluatePortBindRetry};
407
+ * - on exhaustion: warns with an actionable message and calls `onExhausted`
408
+ * (no further retry is scheduled);
409
+ * - otherwise: warns it is retrying, then (async) reclaims the port and — when
410
+ * reclaim did NOT free it — surfaces the {@link reclaimMessage} naming the
411
+ * holding pid(s) so the operator isn't left with only the generic banner,
412
+ * then schedules the next `relisten` after the decided backoff.
413
+ * Never throws.
414
+ */
415
+ export function createBindRetryController(
416
+ port: number,
417
+ deps: BindRetryDeps,
418
+ policy: PortBindRetryPolicy = DEFAULT_PORT_BIND_RETRY_POLICY,
419
+ ): () => void {
420
+ let attempts = 0;
421
+ return () => {
422
+ attempts += 1;
423
+ const decision = evaluatePortBindRetry(attempts, policy);
424
+ if (!decision.retry) {
425
+ deps.warn(
426
+ `\n❌ Port ${port} is still in use after ${policy.maxAttempts} ` +
427
+ `attempts to reclaim it — another process is holding :${port}. Stop it (or run the ` +
428
+ `cleanup script) and retry \`npm run dev\`.\n`,
429
+ );
430
+ deps.onExhausted();
431
+ return;
432
+ }
433
+ deps.warn(
434
+ `⚠️ Port ${port} already in use (EADDRINUSE) — reclaiming the stale owner and retrying ` +
435
+ `(attempt ${attempts}/${policy.maxAttempts})…`,
436
+ );
437
+ void (async () => {
438
+ const result = await deps.reclaim(port);
439
+ if (!result.reclaimed) deps.warn(reclaimMessage(port, result, 'a stale/orphaned listener'));
440
+ deps.scheduleRetry(deps.relisten, decision.delayMs);
441
+ })();
442
+ };
443
+ }
444
+
445
+ // ── Singleton guard (pidfile) ──────────────────────────────────────────────
446
+
447
+ /** Persisted identity of the dev server that owns a given front-door port. */
448
+ export interface DevServerPidRecord {
449
+ /** The supervisor process's own pid. */
450
+ pid: number;
451
+ /** The supervisor's parent pid — the stable `tsx watch` watcher across reloads. */
452
+ ppid: number;
453
+ /** The front-door port this record guards. */
454
+ port: number;
455
+ }
456
+
457
+ /** Parse a pidfile body into a {@link DevServerPidRecord}; `null` if absent/corrupt/incomplete. */
458
+ export function parsePidRecord(text: string): DevServerPidRecord | null {
459
+ try {
460
+ const o = JSON.parse(text);
461
+ if (o && Number.isInteger(o.pid) && Number.isInteger(o.ppid) && Number.isInteger(o.port)) {
462
+ return { pid: o.pid, ppid: o.ppid, port: o.port };
463
+ }
464
+ } catch {
465
+ // Corrupt / empty pidfile — treat as absent.
466
+ }
467
+ return null;
468
+ }
469
+
470
+ /** True iff a signal can be delivered to `pid` (exists). `EPERM` (exists, not ours) counts as alive. */
471
+ export function isPidAlive(pid: number, kill: (pid: number, signal: number) => void = (p, s) => process.kill(p, s)): boolean {
472
+ if (!Number.isInteger(pid) || pid <= 1) return false;
473
+ try {
474
+ kill(pid, 0);
475
+ return true;
476
+ } catch (e) {
477
+ return (e as NodeJS.ErrnoException).code === 'EPERM';
478
+ }
479
+ }
480
+
481
+ /** Result of {@link evaluateSingleton}: proceed with startup, or exit cleanly (a live peer owns the port). */
482
+ export type SingletonDecision = { action: 'proceed' } | { action: 'exit'; reason: string };
483
+
484
+ /**
485
+ * Decide whether a *new* dev-server invocation should start, or bow out because
486
+ * another supervisor already owns `port`. This is the singleton guard that stops
487
+ * the "two fighting supervisors" restart loop — a second `npm run dev` racing the
488
+ * first on `:3000`/`:3100` — WITHOUT breaking `tsx watch`'s own restart of the
489
+ * *same* supervisor on a file change.
490
+ *
491
+ * - **No / corrupt pidfile** → proceed (first start; startup reclaim covers any orphan socket).
492
+ * - **Same pid** → proceed (defensive; the record is our own).
493
+ * - **Same parent (`ppid`)** → proceed. `tsx watch` is the stable parent across
494
+ * reloads, so a matching parent means the watcher is relaunching OUR OWN script
495
+ * — not a competitor. A second `npm run dev` runs under a *different* watcher,
496
+ * so it never matches here. This carve-out is what preserves hot reload.
497
+ * - **Different, still-live owner actually holding the port** → exit cleanly with
498
+ * a clear message (do not spawn a competing supervisor).
499
+ * - **Otherwise** (recorded owner is dead → stale pidfile, or the port is free)
500
+ * → proceed; startup reclaim frees any orphaned socket.
501
+ */
502
+ export function evaluateSingleton(
503
+ existing: DevServerPidRecord | null,
504
+ self: { pid: number; ppid: number },
505
+ portInUse: boolean,
506
+ isAlive: (pid: number) => boolean,
507
+ ): SingletonDecision {
508
+ if (!existing) return { action: 'proceed' };
509
+ if (existing.pid === self.pid) return { action: 'proceed' };
510
+ if (existing.ppid === self.ppid) return { action: 'proceed' }; // tsx-watch relaunch of our own supervisor
511
+ const ownerAlive = isAlive(existing.pid) || (existing.ppid > 1 && isAlive(existing.ppid));
512
+ if (ownerAlive && portInUse) {
513
+ return { action: 'exit', reason: `dev server already running on :${existing.port} (pid ${existing.pid})` };
514
+ }
515
+ return { action: 'proceed' };
516
+ }
517
+
240
518
  export async function startDevServer(options: DevServerOptions) {
241
519
  const {
242
520
  port = 3000,
@@ -246,6 +524,67 @@ export async function startDevServer(options: DevServerOptions) {
246
524
  } = options;
247
525
  const devStartTime = Date.now();
248
526
 
527
+ // ── Singleton guard ─────────────────────────────────────────────────────
528
+ // Prevent two fighting supervisors: a second `npm run dev` must not spawn a
529
+ // competing supervisor that races the first on :3000/:3100 (backend + Vite
530
+ // EADDRINUSE → mutual Vite kills → restart loop). We record {pid, ppid, port}
531
+ // in a per-port pidfile and consult it here. The `ppid` (stable `tsx watch`
532
+ // watcher) lets us tell a hot-reload relaunch of OUR OWN script apart from a
533
+ // genuine second invocation — see {@link evaluateSingleton}. A stale pidfile
534
+ // (dead owner) never blocks startup.
535
+ const pidfilePath = join(BLOCKS_SANDBOX_DIR, `dev-server.${port}.pid`);
536
+ const removeOwnPidfile = (): void => {
537
+ // Close the read-check-then-delete TOCTOU: a naive `read → if mine → unlink`
538
+ // could delete a tsx-watch SUCCESSOR's pidfile if it wrote between our read
539
+ // and our unlink. Instead we atomically `rename` the current pidfile to a
540
+ // pid-private path (rename(2) is atomic — a concurrent writer can't observe a
541
+ // half-state), THEN inspect the snapshot:
542
+ // • it's ours → unlink the private copy (done; a successor that
543
+ // writes a fresh pidfile afterwards is untouched
544
+ // because we never unlink the canonical path).
545
+ // • it's a successor → we grabbed its file (it wrote before our rename);
546
+ // put it back so the live successor keeps its guard.
547
+ // We only ever unlink the private claim, never the canonical path, so a
548
+ // successor's fresh pidfile is never destroyed.
549
+ const claimPath = `${pidfilePath}.${process.pid}.gc`;
550
+ try {
551
+ renameSync(pidfilePath, claimPath);
552
+ } catch {
553
+ return; // No pidfile to remove (already gone / never written).
554
+ }
555
+ let rec: DevServerPidRecord | null = null;
556
+ try { rec = parsePidRecord(readFileSync(claimPath, 'utf-8')); } catch { /* unreadable snapshot */ }
557
+ if (rec && rec.pid !== process.pid) {
558
+ // Snapshot belongs to a successor — restore it and leave its guard intact.
559
+ // Residual (astronomically narrow) window: between this rename-away and
560
+ // rename-back the canonical path briefly has no pidfile, so a THIRD
561
+ // concurrent start could momentarily see "no guard" and proceed — the same
562
+ // benign "weakened guard, never broken startup" degradation the write path
563
+ // already tolerates. tsx-watch drains the old supervisor before relaunching,
564
+ // so overlap here does not occur in practice.
565
+ try { renameSync(claimPath, pidfilePath); return; } catch { /* fall through to cleanup */ }
566
+ }
567
+ try { unlinkSync(claimPath); } catch { /* already gone */ }
568
+ };
569
+ {
570
+ mkdirSync(BLOCKS_SANDBOX_DIR, { recursive: true });
571
+ let existing: DevServerPidRecord | null = null;
572
+ try { existing = parsePidRecord(readFileSync(pidfilePath, 'utf-8')); } catch { /* absent */ }
573
+ const portInUse = await isPortOpen(port);
574
+ const decision = evaluateSingleton(existing, { pid: process.pid, ppid: process.ppid }, portInUse, isPidAlive);
575
+ if (decision.action === 'exit') {
576
+ console.error(
577
+ `\n⚠️ ${decision.reason}.\n` +
578
+ ` Not starting a second dev server. Stop the other process (or run the ` +
579
+ `cleanup script) and retry \`npm run dev\`.\n`,
580
+ );
581
+ process.exit(0);
582
+ }
583
+ try {
584
+ writeFileSync(pidfilePath, JSON.stringify({ pid: process.pid, ppid: process.ppid, port }));
585
+ } catch { /* best-effort — a missing pidfile only weakens the guard, never breaks startup */ }
586
+ }
587
+
249
588
  // Load .env.local if present (connection strings, project refs, etc.)
250
589
  try { process.loadEnvFile('.env.local'); } catch (e: any) {
251
590
  if (e.code !== 'ENOENT') throw e;
@@ -273,8 +612,8 @@ export async function startDevServer(options: DevServerOptions) {
273
612
  // This makes sandbox single-origin, matching `npm run dev` and the prod
274
613
  // CloudFront proxy; `crossDomain` stays unnecessary.
275
614
  const blocksConfig = buildBlocksConfig(port, isSandbox);
276
- mkdirSync('.blocks-sandbox', { recursive: true });
277
- writeFileSync('.blocks-sandbox/config.json', JSON.stringify(blocksConfig, null, 2));
615
+ mkdirSync(BLOCKS_SANDBOX_DIR, { recursive: true });
616
+ writeFileSync(join(BLOCKS_SANDBOX_DIR, 'config.json'), JSON.stringify(blocksConfig, null, 2));
278
617
 
279
618
  // 1. Set up global collectors for plugin discovery
280
619
  (globalThis as any).__BLOCKS_CLIENT_MIDDLEWARE__ = [];
@@ -598,8 +937,31 @@ export async function startDevServer(options: DevServerOptions) {
598
937
  await writeClientCode(resolvedPath, clientPath);
599
938
  }
600
939
 
940
+ // ── Startup reclaim ──────────────────────────────────────────────────────
941
+ // Free any port left bound by a crashed / SIGKILL'd predecessor before we bind
942
+ // the front door or spawn the `--strictPort` frontend. tsx-watch only gives the
943
+ // previous process ~5s to run cleanup(); if it was SIGKILL'd or crashed, its
944
+ // detached Vite grandchild (or an orphaned backend) can still hold :3100/:3000
945
+ // and a fresh `--strictPort` start would collide and crash. The singleton guard
946
+ // above already ruled out a live *peer* supervisor, so anything still holding
947
+ // these ports is an orphan — reclaim it (see {@link reclaimPort}).
948
+ // A successful reclaim (♻️) is an informational confirmation → stdout; a
949
+ // failed reclaim (⚠️) is a warning the operator must act on → stderr. Keeping
950
+ // healthy-startup output off stderr avoids false alarms in CI pipelines that
951
+ // treat any stderr line as a failure.
952
+ const r3000 = await reclaimPort(port);
953
+ if (r3000.wasOpen) {
954
+ (r3000.reclaimed ? console.log : console.error)(reclaimMessage(port, r3000, 'a stale/orphaned listener'));
955
+ }
956
+ if (frontendCommand) {
957
+ const rFrontend = await reclaimPort(frontendPort);
958
+ if (rFrontend.wasOpen) {
959
+ (rFrontend.reclaimed ? console.log : console.error)(reclaimMessage(frontendPort, rFrontend, 'a stale/orphaned dev server'));
960
+ }
961
+ }
962
+
601
963
  // ── Start listening ────────────────────────────────────────────────────
602
- server.listen(port, async () => {
964
+ const onListening = async (): Promise<void> => {
603
965
  console.log(`AWS Blocks local server running on http://localhost:${port}`);
604
966
  buildAndSendEvent({ command: 'dev', state: 'SUCCESS', duration: Date.now() - devStartTime });
605
967
 
@@ -610,13 +972,47 @@ export async function startDevServer(options: DevServerOptions) {
610
972
  } else {
611
973
  console.log(`\n ➜ http://localhost:${port}/\n`);
612
974
  }
613
- });
975
+ };
614
976
 
977
+ // Front-door EADDRINUSE robustness — mirror the treatment :3100 already gets:
978
+ // emit a REAL console error (not just telemetry), reclaim the stale owner and
979
+ // retry the bind (bounded), and on unrecoverable failure exit non-zero with a
980
+ // clear message so a contended :3000 never silently fails to serve. Startup
981
+ // reclaim above makes this a rare race (someone grabbed :3000 between reclaim
982
+ // and listen); the retry closes that window. The retry wiring lives in the
983
+ // testable {@link createBindRetryController}; telemetry stays here.
984
+ const onEaddrinuse = createBindRetryController(port, {
985
+ reclaim: (p) => reclaimPort(p),
986
+ relisten: () => server.listen(port, onListening),
987
+ scheduleRetry: (fn, delayMs) => { setTimeout(fn, delayMs).unref?.(); },
988
+ onExhausted: () => process.exit(1),
989
+ warn: (msg) => console.error(msg),
990
+ });
615
991
  server.on('error', (err: NodeJS.ErrnoException) => {
616
- const errorCode = err.code === 'EADDRINUSE' ? 'PORT_IN_USE' : 'UNKNOWN';
617
- buildAndSendEvent({ command: 'dev', state: 'FAIL', duration: Date.now() - devStartTime, error: { code: errorCode, phase: 'startup' } });
992
+ if (err.code === 'EADDRINUSE') {
993
+ // Keep the telemetry signal (unchanged)
994
+ buildAndSendEvent({
995
+ command: 'dev',
996
+ state: 'FAIL',
997
+ duration: Date.now() - devStartTime,
998
+ error: { code: 'PORT_IN_USE', phase: 'startup' },
999
+ });
1000
+ onEaddrinuse();
1001
+ return;
1002
+ }
1003
+ // Non-EADDRINUSE startup error: telemetry + a real error, then exit non-zero.
1004
+ buildAndSendEvent({
1005
+ command: 'dev',
1006
+ state: 'FAIL',
1007
+ duration: Date.now() - devStartTime,
1008
+ error: { code: 'UNKNOWN', phase: 'startup' },
1009
+ });
1010
+ console.error(`\n❌ Dev server failed to start: ${err.message}\n`);
1011
+ process.exit(1);
618
1012
  });
619
1013
 
1014
+ server.listen(port, onListening);
1015
+
620
1016
  // ── Cleanup ────────────────────────────────────────────────────────────
621
1017
  const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
622
1018
  let cleaningUp = false;
@@ -639,6 +1035,10 @@ export async function startDevServer(options: DevServerOptions) {
639
1035
  if (typeof backend.__cleanup === 'function') {
640
1036
  try { await backend.__cleanup(); } catch {}
641
1037
  }
1038
+ // Release the singleton pidfile so the next `npm run dev` isn't blocked by
1039
+ // our own stale record (only removed if it still points at us — a hot-reload
1040
+ // successor may already own it).
1041
+ removeOwnPidfile();
642
1042
  frontendProxy?.close();
643
1043
  apiProxy?.close();
644
1044
  server.close(() => process.exit(0));
@@ -658,6 +1058,9 @@ export async function startDevServer(options: DevServerOptions) {
658
1058
  // (a surviving grandchild keeps the group alive) — see POST-EXIT GROUP-KILL
659
1059
  // POLICY above.
660
1060
  process.once('exit', () => {
1061
+ // Release our singleton pidfile on any exit path (crash, uncaught exception)
1062
+ // that bypassed cleanup(), so it never lingers and blocks the next start.
1063
+ removeOwnPidfile();
661
1064
  const child = frontendProcess;
662
1065
  if (!child) return;
663
1066
  killFrontendTree(child, 'SIGKILL');
@@ -41,13 +41,14 @@ interface TreeKillResult {
41
41
  * denied): such a run did NOT reap the tree, so the caller must fall back to a
42
42
  * direct `child.kill` rather than treat the leak as handled. (`child.kill`
43
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.
44
+ * strictly correct — we never silently swallow a failed tree-kill.) Runs with a
45
+ * 3s `timeout` so a wedged `taskkill` can't stall teardown; a timed-out run
46
+ * surfaces as `{error}` and degrades to the fallback. Never throws.
46
47
  */
47
48
  export function windowsTreeKill(
48
49
  pid: number,
49
50
  runner: (command: string, args: readonly string[]) => TreeKillResult = (command, args) =>
50
- spawnSync(command, args as string[], { stdio: 'ignore', windowsHide: true }),
51
+ spawnSync(command, args as string[], { stdio: 'ignore', windowsHide: true, timeout: 3000 }),
51
52
  ): boolean {
52
53
  try {
53
54
  const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
@@ -113,6 +114,103 @@ export function killFrontendTree(
113
114
  }
114
115
  }
115
116
 
117
+ /** Subset of a `spawnSync` result that {@link findListenerPids} inspects. */
118
+ interface CommandOutput {
119
+ stdout?: string | null;
120
+ status?: number | null;
121
+ error?: Error;
122
+ }
123
+
124
+ /**
125
+ * Find the PIDs of processes holding a TCP *listener* on `port`, so a fresh dev
126
+ * server can reclaim a port left bound by a crashed / SIGKILL'd predecessor (its
127
+ * orphaned backend, or a detached Vite grandchild) instead of colliding on it.
128
+ * This mirrors the `lsof -ti:<port>` discovery the `cleanup` script already uses
129
+ * — it does NOT introduce a new port-to-PID mechanism.
130
+ *
131
+ * - **POSIX**: `lsof -ti tcp:<port> -sTCP:LISTEN` — `-t` prints bare PIDs and the
132
+ * `-sTCP:LISTEN` state filter restricts the match to the *listener*, so a
133
+ * transient client socket on the same port is never targeted.
134
+ * - **Windows**: `netstat -ano -p tcp`, keeping the trailing PID column of
135
+ * `LISTENING` rows whose local address ends in `:<port>`.
136
+ *
137
+ * Best-effort and never throws: a missing tool, a non-zero exit ("nothing is
138
+ * listening"), or unparseable output all yield `[]`. The `spawnSync` runs with a
139
+ * 3s `timeout` so a hung `lsof`/`netstat` (e.g. an unresponsive NFS mount) can't
140
+ * block the event loop during startup — a timed-out probe returns `{error}`,
141
+ * which the `catch` degrades to `[]`. PIDs `<= 1` are dropped
142
+ * defensively (never target init / the whole current group). `runner`/`platform`
143
+ * are injected for tests.
144
+ */
145
+ export function findListenerPids(
146
+ port: number,
147
+ runner: (command: string, args: readonly string[]) => CommandOutput = (command, args) =>
148
+ spawnSync(command, args as string[], { encoding: 'utf-8', windowsHide: true, timeout: 3000 }),
149
+ platform: NodeJS.Platform = process.platform,
150
+ ): number[] {
151
+ try {
152
+ const pids = new Set<number>();
153
+ if (platform === 'win32') {
154
+ const { stdout } = runner('netstat', ['-ano', '-p', 'tcp']);
155
+ if (!stdout) return [];
156
+ for (const line of stdout.split(/\r?\n/)) {
157
+ if (!/LISTENING/i.test(line)) continue;
158
+ // Columns: Proto Local-Address Foreign-Address State PID
159
+ const cols = line.trim().split(/\s+/);
160
+ const local = cols[1] ?? '';
161
+ if (!local.endsWith(`:${port}`)) continue;
162
+ const pid = Number(cols[cols.length - 1]);
163
+ if (Number.isInteger(pid) && pid > 1) pids.add(pid);
164
+ }
165
+ return [...pids];
166
+ }
167
+ const { stdout } = runner('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN']);
168
+ if (!stdout) return [];
169
+ for (const token of stdout.split(/\s+/)) {
170
+ const pid = Number(token.trim());
171
+ if (Number.isInteger(pid) && pid > 1) pids.add(pid);
172
+ }
173
+ return [...pids];
174
+ } catch {
175
+ return [];
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Force-terminate whatever process (and, on POSIX, its process group) currently
181
+ * holds a port — used by the dev server's startup / EADDRINUSE *reclaim* path on
182
+ * a PID discovered via {@link findListenerPids}, i.e. a process this dev server
183
+ * did NOT spawn. Reuses {@link killFrontendTree} (POSIX `-pid` group kill /
184
+ * Windows `taskkill /T`, with a direct-`kill` fallback) so reclaim reaps exactly
185
+ * like our own frontend teardown — no bespoke kill mechanism. Best-effort; never
186
+ * throws (a since-exited PID just yields ESRCH, swallowed by killFrontendTree).
187
+ */
188
+ export function killListenerTree(
189
+ pid: number,
190
+ signal: NodeJS.Signals = 'SIGTERM',
191
+ platform: NodeJS.Platform = process.platform,
192
+ killFn: (pid: number, signal: NodeJS.Signals) => void = (p, s) => process.kill(p, s),
193
+ winTreeKill: (pid: number) => boolean = windowsTreeKill,
194
+ ): void {
195
+ killFrontendTree(
196
+ {
197
+ pid,
198
+ kill: (s) => {
199
+ try {
200
+ process.kill(pid, s ?? signal);
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ },
206
+ },
207
+ signal,
208
+ platform,
209
+ killFn,
210
+ winTreeKill,
211
+ );
212
+ }
213
+
116
214
  /** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
117
215
  export interface AwaitableChild extends KillableProcess {
118
216
  exitCode: number | null;
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.10';
2
+ export const CORE_VERSION = '0.1.12';