@shogo-ai/worker 2.5.3 → 2.5.4

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.
@@ -31,7 +31,7 @@
31
31
  * - No Prisma reads. All policy comes from the spawn config.
32
32
  * - No security policy build. Cloud signs the policy and sends it.
33
33
  */
34
- import { type ChildProcess, spawn, spawnSync } from './child-process.ts';
34
+ import { type ChildProcess, execFile, spawn, spawnSync } from './child-process.ts';
35
35
  import { createHmac, randomBytes } from 'node:crypto';
36
36
  import { existsSync, mkdirSync, readdirSync, rmSync, unwatchFile, watch, watchFile } from 'node:fs';
37
37
  import { createConnection } from 'node:net';
@@ -65,10 +65,19 @@ import {
65
65
  type ActivitySample,
66
66
  type BusyDecision,
67
67
  } from './runtime-activity.ts';
68
+ import {
69
+ DEFAULT_PORT_RANGE_END,
70
+ DEFAULT_PORT_RANGE_START,
71
+ PortPool,
72
+ } from './port-pool.ts';
68
73
 
69
- /** Port range for random allocation (mirrors apps/api desktop manager). */
70
- const PORT_RANGE_START = 37100;
71
- const PORT_RANGE_END = 37900;
74
+ /**
75
+ * Port range agents are allocated from. The same range is used by `apps/api`'s
76
+ * manager for its Vite ports, so both allocators in one process must share one
77
+ * {@link PortPool} — see port-pool.ts.
78
+ */
79
+ const PORT_RANGE_START = DEFAULT_PORT_RANGE_START;
80
+ const PORT_RANGE_END = DEFAULT_PORT_RANGE_END;
72
81
  const API_PORT_OFFSET = 1; // API server port = agentPort + 1.
73
82
 
74
83
  /**
@@ -117,6 +126,15 @@ const ACTIVITY_PROBE_TIMEOUT_MS = 3000;
117
126
  */
118
127
  const STDERR_TAIL_LINES = 8;
119
128
 
129
+ /**
130
+ * The child's own words when it loses the bind race for its port. `Bun.serve`
131
+ * reports `Failed to start server. Is port 37404 in use?` followed by an
132
+ * `EADDRINUSE` code, and the runtime's health gate then reports
133
+ * `agent-runtime exited … before becoming healthy`. Matching either lets
134
+ * {@link WorkerRuntimeManager.logPortHolder} name the holder.
135
+ */
136
+ const PORT_CONFLICT_RE = /EADDRINUSE|Is port \d+ in use/;
137
+
120
138
  /** Restart backoff bounds. */
121
139
  const RESTART_BACKOFF_BASE_MS = 1_000;
122
140
  const RESTART_BACKOFF_MAX_MS = 60_000;
@@ -424,6 +442,14 @@ export interface WorkerRuntimeManagerOptions {
424
442
  * callback are swallowed.
425
443
  */
426
444
  onRuntimeGone?: (projectId: string, info: RuntimeGoneInfo) => void;
445
+ /**
446
+ * Shared port reservation pool. Pass the SAME pool the host process uses for
447
+ * its own port allocations (the desktop API does) so two allocators in one
448
+ * process can never hand out the same port — the EADDRINUSE crash-loop in
449
+ * SHO-335. When absent the manager creates its own pool, which is correct for
450
+ * a standalone `shogo worker start` (it is the only allocator there).
451
+ */
452
+ ports?: PortPool;
427
453
  }
428
454
 
429
455
  export interface RuntimeGoneInfo {
@@ -559,6 +585,12 @@ interface InternalRuntime {
559
585
  activity?: ActivitySample | null;
560
586
  /** Last few stderr lines, logged when the process exits unrequested. */
561
587
  stderrTail?: string[];
588
+ /**
589
+ * Set once the current incarnation has reported an `EADDRINUSE` on its own
590
+ * port, so the holder lookup runs once per spawn instead of once per stderr
591
+ * line (the child repeats the error when it exits).
592
+ */
593
+ portConflictDiagnosed?: boolean;
562
594
  }
563
595
 
564
596
  /**
@@ -671,7 +703,14 @@ export class WorkerRuntimeManager implements RuntimeResolver {
671
703
  private readonly opts: WorkerRuntimeManagerOptions;
672
704
  private readonly log: Pick<Console, 'log' | 'warn' | 'error'>;
673
705
  private readonly runtimes = new Map<string, InternalRuntime>();
674
- private readonly usedPorts = new Set<number>();
706
+ /**
707
+ * Port reservations, shared with the host when it passes one in (`apps/api`).
708
+ * Exposed to tests as `usedPorts` — the pool's set is the only one.
709
+ */
710
+ private readonly ports: PortPool;
711
+ private get usedPorts(): Set<number> {
712
+ return this.ports.reserved;
713
+ }
675
714
  private readonly spawnCommand: SpawnCommandFactory;
676
715
  private resolved: ResolvedRuntime | null = null;
677
716
  private stopped = false;
@@ -702,6 +741,7 @@ export class WorkerRuntimeManager implements RuntimeResolver {
702
741
  constructor(opts: WorkerRuntimeManagerOptions = {}) {
703
742
  this.opts = opts;
704
743
  this.log = opts.logger ?? console;
744
+ this.ports = opts.ports ?? new PortPool();
705
745
  this.spawnCommand = opts.spawnCommand ?? defaultSpawnCommand;
706
746
  this.sweepOrphanedViteWatches();
707
747
  // Activity is sampled on its own timer rather than probed at eviction
@@ -1492,6 +1532,7 @@ export class WorkerRuntimeManager implements RuntimeResolver {
1492
1532
  // one.
1493
1533
  slot.activity = null;
1494
1534
  slot.stderrTail = [];
1535
+ slot.portConflictDiagnosed = false;
1495
1536
  const openAttemptId = slot.spawnConfig.extraEnv?.SHOGO_OPEN_ID;
1496
1537
  perfLog('agent-spawned', slot.projectId, openAttemptId, {
1497
1538
  pid: slot.pid,
@@ -1560,6 +1601,12 @@ export class WorkerRuntimeManager implements RuntimeResolver {
1560
1601
  this.log.error(`${prefix} ${line}`);
1561
1602
  tail.push(line);
1562
1603
  if (tail.length > STDERR_TAIL_LINES) tail.splice(0, tail.length - STDERR_TAIL_LINES);
1604
+ // A child that cannot bind its port stays alive for seconds printing
1605
+ // this, then exits — name the holder while it is still a question.
1606
+ if (!slot.portConflictDiagnosed && PORT_CONFLICT_RE.test(line)) {
1607
+ slot.portConflictDiagnosed = true;
1608
+ void this.logPortHolder(slot);
1609
+ }
1563
1610
  }
1564
1611
  });
1565
1612
 
@@ -2088,6 +2135,16 @@ export class WorkerRuntimeManager implements RuntimeResolver {
2088
2135
  this.reapChildLedgerFor(slot.projectId, slot.spawnConfig.projectDir);
2089
2136
  slot.pid = null;
2090
2137
 
2138
+ // Hand the block back before the restart. `doStart` only allocates when
2139
+ // `agentPort` is 0, so keeping it would re-boot the SAME port for every
2140
+ // attempt in the loop — up to MAX_CONSECUTIVE_RESTARTS times on a port
2141
+ // that is already somebody else's (SHO-335: block 37404-37419 allocated,
2142
+ // EADDRINUSE 2s later, then eight boots of the same dead port). Releasing
2143
+ // makes the next doStart() re-probe and move on.
2144
+ this.releasePort(slot.agentPort);
2145
+ slot.agentPort = 0;
2146
+ slot.apiServerPort = 0;
2147
+
2091
2148
  const now = Date.now();
2092
2149
  const withinWindow = now - slot.lastFailureAt <= RESTART_FAILURE_WINDOW_MS;
2093
2150
  slot.consecutiveFailures = withinWindow ? slot.consecutiveFailures + 1 : 1;
@@ -2379,50 +2436,71 @@ export class WorkerRuntimeManager implements RuntimeResolver {
2379
2436
  }
2380
2437
 
2381
2438
  private async allocatePort(): Promise<number> {
2382
- const range = PORT_RANGE_END - PORT_RANGE_START;
2383
- const maxAttempts = Math.min(range, 50);
2384
- for (let i = 0; i < maxAttempts; i++) {
2385
- const candidate = PORT_RANGE_START + Math.floor(Math.random() * range);
2386
- // Reserve a contiguous per-runtime block so the agent port, its API
2387
- // server AND every preview sidecar (WORKSPACE_API_PORT_BASE + idx) live
2388
- // in a range that no other warm runtime can overlap.
2389
- if (candidate + RUNTIME_PORT_BLOCK - 1 > PORT_RANGE_END) continue;
2390
- let blockFree = true;
2391
- for (let off = 0; off < RUNTIME_PORT_BLOCK; off++) {
2392
- if (this.usedPorts.has(candidate + off)) { blockFree = false; break; }
2393
- }
2394
- if (!blockFree) continue;
2395
- // Liveness-probe only the two ports we bind eagerly (agent + its API
2396
- // server). The sidecar ports are bound lazily by the agent-runtime and
2397
- // guarded by its own leaked-process force-kill, so probing the whole
2398
- // block here would just slow allocation down.
2399
- const agentInUse = await this.isPortListening(candidate);
2400
- const apiInUse = await this.isPortListening(candidate + API_PORT_OFFSET);
2401
- if (agentInUse || apiInUse) continue;
2402
- for (let off = 0; off < RUNTIME_PORT_BLOCK; off++) this.usedPorts.add(candidate + off);
2403
- return candidate;
2404
- }
2405
- throw new Error(
2406
- `Cannot allocate port in range ${PORT_RANGE_START}-${PORT_RANGE_END} after ${maxAttempts} attempts`,
2439
+ // The agent port, its API server (+1) and every preview sidecar
2440
+ // (+2 … +15) live in one contiguous block that no other runtime — and no
2441
+ // other allocator in this process (api's Vite port, SHO-335) — can hand
2442
+ // out. Only the two eagerly-bound ports are liveness-probed; the sidecar
2443
+ // ports are bound lazily by the agent-runtime and guarded by its own
2444
+ // leaked-process force-kill, so probing the whole block would just slow
2445
+ // allocation down.
2446
+ const agentPort = await this.ports.allocate({
2447
+ start: PORT_RANGE_START,
2448
+ end: PORT_RANGE_END,
2449
+ blockSize: RUNTIME_PORT_BLOCK,
2450
+ probeOffsets: [0, API_PORT_OFFSET],
2451
+ });
2452
+ this.log.log(
2453
+ `[WorkerRuntimeManager] Allocated agent block ${agentPort}-${agentPort + RUNTIME_PORT_BLOCK - 1}`,
2407
2454
  );
2455
+ return agentPort;
2408
2456
  }
2409
2457
 
2410
2458
  private releasePort(port: number): void {
2411
2459
  if (!port) return;
2412
- for (let off = 0; off < RUNTIME_PORT_BLOCK; off++) this.usedPorts.delete(port + off);
2460
+ this.ports.releaseBlock(port, RUNTIME_PORT_BLOCK);
2413
2461
  }
2414
2462
 
2415
- private async isPortListening(port: number): Promise<boolean> {
2416
- const controller = new AbortController();
2417
- const timer = setTimeout(() => controller.abort(), 250);
2418
- try {
2419
- await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD', signal: controller.signal });
2420
- clearTimeout(timer);
2421
- return true;
2422
- } catch {
2423
- clearTimeout(timer);
2424
- return false;
2463
+ /**
2464
+ * Name whoever holds the port the child failed to bind.
2465
+ *
2466
+ * The child's own `EADDRINUSE` says nothing about who won, and the block was
2467
+ * reserved from a pool that no in-process allocator can duplicate, so the
2468
+ * only remaining candidates are: a leaked child of an earlier incarnation, a
2469
+ * neighbouring process on the host, or the runtime itself binding twice
2470
+ * (SHO-335 QA round 1 could not tell these apart from the CI log alone).
2471
+ * Best-effort: a missing `lsof`/`ss` is not worth a failure.
2472
+ */
2473
+ private async logPortHolder(slot: InternalRuntime): Promise<void> {
2474
+ const port = slot.agentPort;
2475
+ if (!port) return;
2476
+ const label = `[WorkerRuntimeManager] port ${port} (${slot.projectId})`;
2477
+ for (const [command, args] of this.portHolderCommands(port)) {
2478
+ const result = await runDiagnostic(command, args);
2479
+ if (!result) continue;
2480
+ const holders = result.stdout
2481
+ .split('\n')
2482
+ .map((line) => line.trim())
2483
+ .filter((line) => line.includes(String(port)));
2484
+ if (holders.length) {
2485
+ this.log.error(`${label} is held by:\n ${holders.join('\n ')}`);
2486
+ } else {
2487
+ this.log.error(
2488
+ `${label} has no listener now — the bind failure came from this runtime's own tree ` +
2489
+ `(or the holder exited with it), not from a neighbouring process`,
2490
+ );
2491
+ }
2492
+ return;
2425
2493
  }
2494
+ this.log.warn(`${label}: no port-listing tool available, holder unknown`);
2495
+ }
2496
+
2497
+ /** Host tools that can name a TCP listener's owner, best first. */
2498
+ private portHolderCommands(port: number): Array<[string, string[]]> {
2499
+ if (process.platform === 'win32') return [['netstat', ['-ano', '-p', 'tcp']]];
2500
+ return [
2501
+ ['lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN']],
2502
+ ['ss', ['-ltnp', `sport = :${port}`]],
2503
+ ];
2426
2504
  }
2427
2505
 
2428
2506
  /**
@@ -2430,9 +2508,10 @@ export class WorkerRuntimeManager implements RuntimeResolver {
2430
2508
  * to `127.0.0.1:port` succeeds within {@link TCP_CONNECT_TIMEOUT_MS},
2431
2509
  * `false` for any failure mode (refused, timed out, host unreachable).
2432
2510
  *
2433
- * This is intentionally distinct from {@link isPortListening} (which
2434
- * fires an HTTP HEAD): we want to know whether `Bun.serve()` has
2435
- * bound the socket, NOT whether its request handler is responding.
2511
+ * This is intentionally distinct from the occupancy probe in
2512
+ * `port-pool.ts` (which a timeout counts as BUSY): we want to know whether
2513
+ * `Bun.serve()` has bound the socket, NOT whether its request handler is
2514
+ * responding. A timeout here means "not ready yet".
2436
2515
  * Once the kernel has the listener, the agent-runtime's
2437
2516
  * `export default { port, fetch }` has been evaluated — which is
2438
2517
  * sufficient evidence to clear the boot gate even if the event loop
@@ -2638,3 +2717,18 @@ export class WorkerRuntimeManager implements RuntimeResolver {
2638
2717
  };
2639
2718
  }
2640
2719
  }
2720
+
2721
+ /**
2722
+ * Run a short-lived listing command. `null` means the tool is not installed
2723
+ * (ENOENT) — try the next one; `{ stdout: '' }` means it ran and found
2724
+ * nothing, which is itself an answer. Never throws: this only runs on an
2725
+ * already-failing path.
2726
+ */
2727
+ function runDiagnostic(command: string, args: string[]): Promise<{ stdout: string } | null> {
2728
+ return new Promise((resolve) => {
2729
+ execFile(command, args, { timeout: 3_000 }, (err: any, stdout: string) => {
2730
+ if (err?.code === 'ENOENT') return resolve(null);
2731
+ resolve({ stdout: stdout ?? '' });
2732
+ });
2733
+ });
2734
+ }