@shogo-ai/worker 2.5.2 → 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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
-
|
|
2460
|
+
this.ports.releaseBlock(port, RUNTIME_PORT_BLOCK);
|
|
2413
2461
|
}
|
|
2414
2462
|
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
|
2434
|
-
*
|
|
2435
|
-
* bound the socket, NOT whether its request handler is
|
|
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
|
+
}
|