@shogo-ai/worker 2.5.3 → 2.5.5
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shogo-ai/worker",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
4
|
"description": "Shogo Cloud Agent Worker — run Shogo agents on your own machine (laptop, devbox, CI).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shogo Technologies, Inc.",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./tunnel": "./src/lib/tunnel.ts",
|
|
21
21
|
"./tunnel-host": "./src/lib/tunnel-host.ts",
|
|
22
22
|
"./runtime-manager": "./src/lib/runtime-manager.ts",
|
|
23
|
+
"./port-pool": "./src/lib/port-pool.ts",
|
|
23
24
|
"./runtime-activity": "./src/lib/runtime-activity.ts",
|
|
24
25
|
"./resource-limits": "./src/lib/resource-limits.ts",
|
|
25
26
|
"./cloud-sync-watcher": "./src/lib/cloud-sync-watcher.ts",
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
"prepublishOnly": "bun run typecheck && bun run build:js"
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
53
|
-
"@shogo-ai/sdk": "^2.5.
|
|
54
|
+
"@shogo-ai/sdk": "^2.5.4",
|
|
54
55
|
"commander": "^12.1.0",
|
|
55
56
|
"picocolors": "^1.1.1"
|
|
56
57
|
},
|
|
@@ -708,18 +708,10 @@ describe('doStart() via ensureRunning — makeSlot + proc handlers (L968-1072)',
|
|
|
708
708
|
});
|
|
709
709
|
|
|
710
710
|
// ──────────────────────────────────────────────────────────────────────
|
|
711
|
-
//
|
|
711
|
+
// Port occupancy now lives in port-pool.ts (SHO-335): the worker's HTTP HEAD
|
|
712
|
+
// probe is gone, so its unit tests moved to runtime-manager-ports.test.ts.
|
|
712
713
|
// ──────────────────────────────────────────────────────────────────────
|
|
713
714
|
|
|
714
|
-
describe('isPortListening() (L1356-1366)', () => {
|
|
715
|
-
it('returns false when nothing is listening on the port', async () => {
|
|
716
|
-
const mgr = makeManager();
|
|
717
|
-
// Port 1 is always closed/refused
|
|
718
|
-
const result = await (mgr as any).isPortListening(1);
|
|
719
|
-
expect(result).toBe(false);
|
|
720
|
-
});
|
|
721
|
-
});
|
|
722
|
-
|
|
723
715
|
// ──────────────────────────────────────────────────────────────────────
|
|
724
716
|
// armGraceTimer() callback body — covers L1135-1138
|
|
725
717
|
// ──────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* SHO-335 — port allocation must never hand out a busy port.
|
|
5
|
+
*
|
|
6
|
+
* The worker's `allocatePort` used to probe with an HTTP `HEAD`: a listener
|
|
7
|
+
* that accepted the connection but never answered (Vite mid-compile, a
|
|
8
|
+
* saturated event loop, any non-HTTP socket) read as FREE, and the agent
|
|
9
|
+
* runtime then died at `Bun.serve` with `EADDRINUSE` and crash-looped.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin the two behaviours the fix depends on:
|
|
12
|
+
* 1. a busy-but-silent TCP listener is skipped (red on the HTTP probe), and
|
|
13
|
+
* 2. a probe that TIMES OUT counts as in use, never as free.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
16
|
+
import { createServer, type Server, type Socket } from 'node:net';
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_PORT_RANGE_END,
|
|
19
|
+
DEFAULT_PORT_RANGE_START,
|
|
20
|
+
PortPool,
|
|
21
|
+
probeTcpPort,
|
|
22
|
+
} from '../port-pool.ts';
|
|
23
|
+
import { WorkerRuntimeManager } from '../runtime-manager.ts';
|
|
24
|
+
|
|
25
|
+
const RUNTIME_PORT_BLOCK = 16;
|
|
26
|
+
|
|
27
|
+
/** A manager whose pool draws candidates from `random`; every level is logged. */
|
|
28
|
+
function managerWithPool(pool: PortPool, logs: string[] = []): any {
|
|
29
|
+
return new WorkerRuntimeManager({
|
|
30
|
+
env: {} as NodeJS.ProcessEnv,
|
|
31
|
+
ports: pool,
|
|
32
|
+
logger: {
|
|
33
|
+
log: (line: string) => logs.push(line),
|
|
34
|
+
warn: (line: string) => logs.push(line),
|
|
35
|
+
error: (line: string) => logs.push(line),
|
|
36
|
+
},
|
|
37
|
+
}) as any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Random value that makes `allocate` start its candidate search at `port`. */
|
|
41
|
+
function randomTargeting(port: number, blockSize: number): () => number {
|
|
42
|
+
const candidates = DEFAULT_PORT_RANGE_END - DEFAULT_PORT_RANGE_START - blockSize + 2;
|
|
43
|
+
const index = port - DEFAULT_PORT_RANGE_START;
|
|
44
|
+
// +0.5 keeps the floor() away from float error at either edge of the bucket.
|
|
45
|
+
return () => (index + 0.5) / candidates;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const servers: Server[] = [];
|
|
49
|
+
const sockets: Socket[] = [];
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
for (const socket of sockets.splice(0)) socket.destroy();
|
|
53
|
+
for (const server of servers.splice(0)) server.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Listen on `port`, accepting connections and never writing a byte — exactly
|
|
58
|
+
* what an HTTP HEAD probe misreads as free.
|
|
59
|
+
*/
|
|
60
|
+
async function silentListener(port: number): Promise<Server> {
|
|
61
|
+
const server = createServer((socket) => {
|
|
62
|
+
sockets.push(socket);
|
|
63
|
+
});
|
|
64
|
+
servers.push(server);
|
|
65
|
+
await new Promise<void>((resolve, reject) => {
|
|
66
|
+
server.once('error', reject);
|
|
67
|
+
server.listen(port, '127.0.0.1', () => resolve());
|
|
68
|
+
});
|
|
69
|
+
return server;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when `port` can be bound right now (the probe is released again). */
|
|
73
|
+
async function portIsFree(port: number): Promise<boolean> {
|
|
74
|
+
const server = createServer();
|
|
75
|
+
try {
|
|
76
|
+
await new Promise<void>((resolve, reject) => {
|
|
77
|
+
server.once('error', reject);
|
|
78
|
+
server.listen(port, '127.0.0.1', () => resolve());
|
|
79
|
+
});
|
|
80
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** First port in the allocatable range that will accept a listener. */
|
|
88
|
+
async function parkSilentListenerInRange(): Promise<number> {
|
|
89
|
+
for (let port = DEFAULT_PORT_RANGE_END - RUNTIME_PORT_BLOCK + 1; port > DEFAULT_PORT_RANGE_START; port--) {
|
|
90
|
+
try {
|
|
91
|
+
await silentListener(port);
|
|
92
|
+
return port;
|
|
93
|
+
} catch {
|
|
94
|
+
/* this one is taken by a real runtime (or another test) — try the next */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error('no free port in the allocatable range');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A candidate port outside `port`'s block whose eagerly-bound ports are free. */
|
|
101
|
+
async function freeCandidateAwayFrom(port: number): Promise<number> {
|
|
102
|
+
for (let candidate = DEFAULT_PORT_RANGE_START; candidate <= DEFAULT_PORT_RANGE_END - RUNTIME_PORT_BLOCK + 1; candidate++) {
|
|
103
|
+
if (candidate >= port - RUNTIME_PORT_BLOCK && candidate <= port + RUNTIME_PORT_BLOCK) continue;
|
|
104
|
+
if ((await portIsFree(candidate)) && (await portIsFree(candidate + 1))) return candidate;
|
|
105
|
+
}
|
|
106
|
+
throw new Error('no free candidate port in the allocatable range');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('WorkerRuntimeManager.allocatePort — occupancy probe', () => {
|
|
110
|
+
it('skips a listener that accepts but never answers (HTTP HEAD reads it as free)', async () => {
|
|
111
|
+
const busyPort = await parkSilentListenerInRange();
|
|
112
|
+
const free = await freeCandidateAwayFrom(busyPort);
|
|
113
|
+
|
|
114
|
+
const pool = new PortPool();
|
|
115
|
+
// First candidate lands on the busy port; every later attempt lands on a
|
|
116
|
+
// port nothing is listening on.
|
|
117
|
+
const picks = [
|
|
118
|
+
randomTargeting(busyPort, RUNTIME_PORT_BLOCK),
|
|
119
|
+
randomTargeting(free, RUNTIME_PORT_BLOCK),
|
|
120
|
+
];
|
|
121
|
+
(pool as any).random = () => (picks.length ? picks.shift()! : randomTargeting(free, RUNTIME_PORT_BLOCK))();
|
|
122
|
+
|
|
123
|
+
const block = await managerWithPool(pool).allocatePort();
|
|
124
|
+
|
|
125
|
+
expect(block).not.toBe(busyPort);
|
|
126
|
+
expect(block).toBe(free);
|
|
127
|
+
// The busy port is not claimed, in this block or any other.
|
|
128
|
+
expect(pool.isReserved(busyPort)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('probeTcpPort — timeout semantics', () => {
|
|
133
|
+
function fakeSocket(emit: 'connect' | 'error' | 'timeout') {
|
|
134
|
+
const handlers = new Map<string, Array<(...args: any[]) => void>>();
|
|
135
|
+
const socket = {
|
|
136
|
+
setTimeout: () => socket,
|
|
137
|
+
destroy: () => socket,
|
|
138
|
+
once(event: string, listener: (...args: any[]) => void) {
|
|
139
|
+
handlers.set(event, [...(handlers.get(event) ?? []), listener]);
|
|
140
|
+
if (event === emit) queueMicrotask(() => listener(new Error('probe')));
|
|
141
|
+
return socket;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
return socket as any;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('treats a timed-out connect as in use', async () => {
|
|
148
|
+
await expect(
|
|
149
|
+
probeTcpPort(12345, { connect: (() => fakeSocket('timeout')) as any }),
|
|
150
|
+
).resolves.toBe('in-use');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('treats a completed connect as in use', async () => {
|
|
154
|
+
await expect(
|
|
155
|
+
probeTcpPort(12345, { connect: (() => fakeSocket('connect')) as any }),
|
|
156
|
+
).resolves.toBe('in-use');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('treats a refused connect as free', async () => {
|
|
160
|
+
await expect(
|
|
161
|
+
probeTcpPort(12345, { connect: (() => fakeSocket('error')) as any }),
|
|
162
|
+
).resolves.toBe('free');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('never allocates a port whose probe did not answer', async () => {
|
|
166
|
+
const timedOut = DEFAULT_PORT_RANGE_START + 10;
|
|
167
|
+
const free = DEFAULT_PORT_RANGE_START + 40;
|
|
168
|
+
const pool = new PortPool({
|
|
169
|
+
probe: async (port) => (port === timedOut ? 'in-use' : 'free'),
|
|
170
|
+
});
|
|
171
|
+
const picks = [randomTargeting(timedOut, RUNTIME_PORT_BLOCK), randomTargeting(free, RUNTIME_PORT_BLOCK)];
|
|
172
|
+
(pool as any).random = () => (picks.length ? picks.shift()! : randomTargeting(free, RUNTIME_PORT_BLOCK))();
|
|
173
|
+
|
|
174
|
+
const block = await pool.allocate({
|
|
175
|
+
start: DEFAULT_PORT_RANGE_START,
|
|
176
|
+
end: DEFAULT_PORT_RANGE_END,
|
|
177
|
+
blockSize: RUNTIME_PORT_BLOCK,
|
|
178
|
+
probeOffsets: [0, 1],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(block).toBe(free);
|
|
182
|
+
expect(pool.isReserved(timedOut)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('WorkerRuntimeManager.allocatePort — shared pool', () => {
|
|
187
|
+
it('reserves a block in the pool it was given and releases it again', async () => {
|
|
188
|
+
const pool = new PortPool({ probe: async () => 'free' });
|
|
189
|
+
const logs: string[] = [];
|
|
190
|
+
const mgr = managerWithPool(pool, logs);
|
|
191
|
+
|
|
192
|
+
const block = await mgr.allocatePort();
|
|
193
|
+
expect(pool.isReserved(block)).toBe(true);
|
|
194
|
+
expect(pool.isReserved(block + RUNTIME_PORT_BLOCK - 1)).toBe(true);
|
|
195
|
+
// A host log must be able to attribute the next EADDRINUSE without a trace.
|
|
196
|
+
expect(logs.some((line) => line.includes(
|
|
197
|
+
`[WorkerRuntimeManager] Allocated agent block ${block}-${block + RUNTIME_PORT_BLOCK - 1}`,
|
|
198
|
+
))).toBe(true);
|
|
199
|
+
|
|
200
|
+
mgr.releasePort(block);
|
|
201
|
+
for (let offset = 0; offset < RUNTIME_PORT_BLOCK; offset++) {
|
|
202
|
+
expect(pool.isReserved(block + offset)).toBe(false);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('WorkerRuntimeManager.handleExit — the dead port is not reused', () => {
|
|
208
|
+
/** A running slot holding `block`, with no live process behind it. */
|
|
209
|
+
function runningSlot(mgr: any, projectId: string, block: number): any {
|
|
210
|
+
const slot = mgr.makeSlot(projectId, {});
|
|
211
|
+
slot.agentPort = block;
|
|
212
|
+
slot.apiServerPort = block + 1;
|
|
213
|
+
slot.status = 'running';
|
|
214
|
+
slot.pid = null;
|
|
215
|
+
slot.startedAt = Date.now() - 1_000;
|
|
216
|
+
mgr.runtimes.set(projectId, slot);
|
|
217
|
+
return slot;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
it('hands the block back on a non-clean exit so the restart must re-probe', async () => {
|
|
221
|
+
const pool = new PortPool({ probe: async () => 'free' });
|
|
222
|
+
const mgr = managerWithPool(pool);
|
|
223
|
+
const block = await mgr.allocatePort();
|
|
224
|
+
const slot = runningSlot(mgr, 'proj-crash', block);
|
|
225
|
+
|
|
226
|
+
mgr.handleExit(slot, 1, null);
|
|
227
|
+
if (slot.restartTimer) clearTimeout(slot.restartTimer);
|
|
228
|
+
|
|
229
|
+
// `doStart` only allocates when `agentPort` is 0 — keeping the block here
|
|
230
|
+
// is what re-booted the same stolen port for every attempt in the loop.
|
|
231
|
+
expect(slot.agentPort).toBe(0);
|
|
232
|
+
expect(slot.apiServerPort).toBe(0);
|
|
233
|
+
expect(pool.isReserved(block)).toBe(false);
|
|
234
|
+
expect(pool.isReserved(block + RUNTIME_PORT_BLOCK - 1)).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('moves to a fresh block when the next boot finds the old one taken', async () => {
|
|
238
|
+
const taken = new Set<number>();
|
|
239
|
+
const pool = new PortPool({ probe: async () => 'free' });
|
|
240
|
+
const mgr = managerWithPool(pool);
|
|
241
|
+
const block = await mgr.allocatePort();
|
|
242
|
+
const slot = runningSlot(mgr, 'proj-stolen', block);
|
|
243
|
+
|
|
244
|
+
mgr.handleExit(slot, 1, null);
|
|
245
|
+
if (slot.restartTimer) clearTimeout(slot.restartTimer);
|
|
246
|
+
|
|
247
|
+
// Something outside the pool grabbed the block between probe and bind.
|
|
248
|
+
for (let offset = 0; offset < RUNTIME_PORT_BLOCK; offset++) taken.add(block + offset);
|
|
249
|
+
(pool as any).probe = async (port: number) => (taken.has(port) ? 'in-use' : 'free');
|
|
250
|
+
|
|
251
|
+
const next = await mgr.allocatePort();
|
|
252
|
+
expect(next).not.toBe(block);
|
|
253
|
+
expect(pool.isReserved(next)).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('WorkerRuntimeManager.logPortHolder — attributing an EADDRINUSE', () => {
|
|
258
|
+
it('names the process holding the port', async () => {
|
|
259
|
+
const logs: string[] = [];
|
|
260
|
+
const mgr = managerWithPool(new PortPool(), logs);
|
|
261
|
+
const busyPort = await parkSilentListenerInRange();
|
|
262
|
+
const slot = mgr.makeSlot('proj-held', {});
|
|
263
|
+
slot.agentPort = busyPort;
|
|
264
|
+
|
|
265
|
+
await mgr.logPortHolder(slot);
|
|
266
|
+
|
|
267
|
+
const holderLine = logs.find((line) => line.includes(`port ${busyPort}`));
|
|
268
|
+
expect(holderLine).toBeDefined();
|
|
269
|
+
expect(holderLine).toContain('is held by');
|
|
270
|
+
// The listener is this very test process — a real PID, not a placeholder.
|
|
271
|
+
expect(holderLine).toContain(String(process.pid));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('says nothing holds the port when the bind failed for another reason', async () => {
|
|
275
|
+
const logs: string[] = [];
|
|
276
|
+
const mgr = managerWithPool(new PortPool(), logs);
|
|
277
|
+
const free = await freeCandidateAwayFrom(DEFAULT_PORT_RANGE_START - RUNTIME_PORT_BLOCK);
|
|
278
|
+
const slot = mgr.makeSlot('proj-free', {});
|
|
279
|
+
slot.agentPort = free;
|
|
280
|
+
|
|
281
|
+
await mgr.logPortHolder(slot);
|
|
282
|
+
|
|
283
|
+
expect(logs.some((line) => line.includes(`port ${free}`) && line.includes('no listener now'))).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* PortPool — the single in-process allocator for the runtime port range.
|
|
5
|
+
*
|
|
6
|
+
* Two managers hand out localhost ports inside one process: `apps/api`'s
|
|
7
|
+
* `RuntimeManager` (Vite / remote-forward port, plus `+1000`/`+1001` for the
|
|
8
|
+
* agent and its API server) and the `WorkerRuntimeManager` that api embeds
|
|
9
|
+
* (a 16-port block per agent runtime). Each used to keep its own `Set` over
|
|
10
|
+
* 37100–37900, so neither could see the other's reservations: api could hand
|
|
11
|
+
* 37566 to a Vite server while the worker picked the block containing 37566
|
|
12
|
+
* for an agent, and the child crash-looped on `EADDRINUSE` (SHO-335).
|
|
13
|
+
*
|
|
14
|
+
* The fix is one pool per process, passed into the worker by api. Disjoint
|
|
15
|
+
* sub-ranges were the alternative; they would have halved capacity and left
|
|
16
|
+
* the same bug for the next caller that picks a range.
|
|
17
|
+
*
|
|
18
|
+
* Occupancy probing is a kernel-level TCP connect, never an HTTP request:
|
|
19
|
+
* a listener that is up but slow to answer (Vite mid-compile, a saturated
|
|
20
|
+
* event loop, any non-HTTP socket) is still a busy port. A connect that is
|
|
21
|
+
* refused means free; a connect that TIMES OUT means in use — an accept
|
|
22
|
+
* queue that never completes is not a free port.
|
|
23
|
+
*/
|
|
24
|
+
import { connect, type Socket } from 'node:net';
|
|
25
|
+
|
|
26
|
+
/** Port range for random allocation (mirrors the desktop manager). */
|
|
27
|
+
export const DEFAULT_PORT_RANGE_START = 37100;
|
|
28
|
+
export const DEFAULT_PORT_RANGE_END = 37900;
|
|
29
|
+
|
|
30
|
+
/** Per-probe connect timeout. */
|
|
31
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 500;
|
|
32
|
+
|
|
33
|
+
/** How many random candidates one `allocate()` tries before giving up. */
|
|
34
|
+
const DEFAULT_MAX_ATTEMPTS = 50;
|
|
35
|
+
|
|
36
|
+
export type PortProbeResult = 'free' | 'in-use';
|
|
37
|
+
|
|
38
|
+
export type ConnectFactory = (options: { host: string; port: number }) => Socket;
|
|
39
|
+
|
|
40
|
+
export interface PortProbeOptions {
|
|
41
|
+
/** Connect timeout in ms. Defaults to {@link DEFAULT_PROBE_TIMEOUT_MS}. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
/** Interface to dial. Defaults to the IPv4 loopback literal. */
|
|
44
|
+
host?: string;
|
|
45
|
+
/** Test seam: dial with something other than `net.connect`. */
|
|
46
|
+
connect?: ConnectFactory;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Probe a port with a TCP connect.
|
|
51
|
+
*
|
|
52
|
+
* - `connect` → `'in-use'`
|
|
53
|
+
* - `error` (ECONNREFUSED, EHOSTUNREACH, …) → `'free'`
|
|
54
|
+
* - `timeout` → `'in-use'` (a listener whose accept queue is full is busy)
|
|
55
|
+
*/
|
|
56
|
+
export async function probeTcpPort(
|
|
57
|
+
port: number,
|
|
58
|
+
options: PortProbeOptions = {},
|
|
59
|
+
): Promise<PortProbeResult> {
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
61
|
+
const host = options.host ?? '127.0.0.1';
|
|
62
|
+
const dial: ConnectFactory = options.connect ?? ((target) => connect(target));
|
|
63
|
+
|
|
64
|
+
return await new Promise<PortProbeResult>((resolve) => {
|
|
65
|
+
let settled = false;
|
|
66
|
+
const finish = (result: PortProbeResult) => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
try {
|
|
70
|
+
socket.destroy();
|
|
71
|
+
} catch {
|
|
72
|
+
/* socket may already be torn down */
|
|
73
|
+
}
|
|
74
|
+
resolve(result);
|
|
75
|
+
};
|
|
76
|
+
const socket = dial({ host, port });
|
|
77
|
+
socket.setTimeout(timeoutMs);
|
|
78
|
+
socket.once('connect', () => finish('in-use'));
|
|
79
|
+
socket.once('error', () => finish('free'));
|
|
80
|
+
socket.once('timeout', () => finish('in-use'));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PortPoolOptions {
|
|
85
|
+
/** Override the occupancy probe (tests). */
|
|
86
|
+
probe?: (port: number) => Promise<PortProbeResult>;
|
|
87
|
+
/** Connect timeout for the default TCP probe. */
|
|
88
|
+
probeTimeoutMs?: number;
|
|
89
|
+
/** Override randomness (tests). Defaults to `Math.random`. */
|
|
90
|
+
random?: () => number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface PortBlockRequest {
|
|
94
|
+
/** First candidate port. Defaults to {@link DEFAULT_PORT_RANGE_START}. */
|
|
95
|
+
start?: number;
|
|
96
|
+
/** Last port the candidate BLOCK may reach. Defaults to {@link DEFAULT_PORT_RANGE_END}. */
|
|
97
|
+
end?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Contiguous ports reserved at the candidate, starting with the candidate
|
|
100
|
+
* itself. Defaults to 1.
|
|
101
|
+
*/
|
|
102
|
+
blockSize?: number;
|
|
103
|
+
/**
|
|
104
|
+
* Offsets from the candidate that are reserved AND liveness-probed. Unlike
|
|
105
|
+
* {@link blockSize} these are not bounded by `end`: the desktop manager's
|
|
106
|
+
* agent port lives at `+1000`, well past its Vite port range.
|
|
107
|
+
*/
|
|
108
|
+
probeOffsets?: readonly number[];
|
|
109
|
+
maxAttempts?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reservation pool over the runtime port range. One instance per process;
|
|
114
|
+
* every allocator in that process must be handed the same one.
|
|
115
|
+
*/
|
|
116
|
+
export class PortPool {
|
|
117
|
+
/**
|
|
118
|
+
* Every port handed out and not yet released. Exposed so callers (and the
|
|
119
|
+
* managers' `usedPorts` accessor used by tests) share one source of truth
|
|
120
|
+
* instead of keeping a parallel set.
|
|
121
|
+
*/
|
|
122
|
+
readonly reserved = new Set<number>();
|
|
123
|
+
|
|
124
|
+
private readonly probe: (port: number) => Promise<PortProbeResult>;
|
|
125
|
+
private readonly random: () => number;
|
|
126
|
+
|
|
127
|
+
constructor(options: PortPoolOptions = {}) {
|
|
128
|
+
this.probe =
|
|
129
|
+
options.probe ??
|
|
130
|
+
((port) => probeTcpPort(port, { timeoutMs: options.probeTimeoutMs }));
|
|
131
|
+
this.random = options.random ?? Math.random;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Reserve ports that something outside this pool already bound. */
|
|
135
|
+
reserve(ports: Iterable<number>): void {
|
|
136
|
+
for (const port of ports) if (port > 0) this.reserved.add(port);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
release(ports: Iterable<number>): void {
|
|
140
|
+
for (const port of ports) this.reserved.delete(port);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Release a contiguous block previously returned by {@link allocate}. */
|
|
144
|
+
releaseBlock(start: number, size: number): void {
|
|
145
|
+
if (!start) return;
|
|
146
|
+
for (let offset = 0; offset < size; offset++) this.reserved.delete(start + offset);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
isReserved(port: number): boolean {
|
|
150
|
+
return this.reserved.has(port);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** True when nothing is reserved in-process and nothing answers on the port. */
|
|
154
|
+
async isPortAvailable(port: number): Promise<boolean> {
|
|
155
|
+
if (port <= 0) return false;
|
|
156
|
+
if (this.reserved.has(port)) return false;
|
|
157
|
+
return await this.probeFree(port);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pick a random candidate block and reserve it.
|
|
162
|
+
*
|
|
163
|
+
* Reserves BEFORE probing: the probe awaits, and two concurrent allocations
|
|
164
|
+
* must never both see the same block as free (the previous per-manager code
|
|
165
|
+
* check-then-set across an `await`, so a burst of starts could collide).
|
|
166
|
+
* The block is released again if any probed port turns out to be busy.
|
|
167
|
+
*/
|
|
168
|
+
async allocate(request: PortBlockRequest = {}): Promise<number> {
|
|
169
|
+
const start = request.start ?? DEFAULT_PORT_RANGE_START;
|
|
170
|
+
const end = request.end ?? DEFAULT_PORT_RANGE_END;
|
|
171
|
+
const blockSize = request.blockSize ?? 1;
|
|
172
|
+
const probeOffsets = request.probeOffsets ?? [0];
|
|
173
|
+
const candidates = end - start - blockSize + 2;
|
|
174
|
+
if (candidates < 1) {
|
|
175
|
+
throw new Error(`Port range ${start}-${end} cannot hold a ${blockSize}-port block`);
|
|
176
|
+
}
|
|
177
|
+
const maxAttempts = request.maxAttempts ?? Math.min(candidates, DEFAULT_MAX_ATTEMPTS);
|
|
178
|
+
|
|
179
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
180
|
+
const candidate = start + Math.floor(this.random() * candidates);
|
|
181
|
+
const ports = this.candidatePorts(candidate, blockSize, probeOffsets);
|
|
182
|
+
if (ports.some((port) => this.reserved.has(port))) continue;
|
|
183
|
+
|
|
184
|
+
this.reserve(ports);
|
|
185
|
+
let free = true;
|
|
186
|
+
for (const offset of probeOffsets) {
|
|
187
|
+
if (!(await this.probeFree(candidate + offset))) {
|
|
188
|
+
free = false;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!free) {
|
|
193
|
+
this.release(ports);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
return candidate;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Cannot allocate port after ${maxAttempts} attempts in range ${start}-${end}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private candidatePorts(
|
|
205
|
+
start: number,
|
|
206
|
+
blockSize: number,
|
|
207
|
+
probeOffsets: readonly number[],
|
|
208
|
+
): number[] {
|
|
209
|
+
const ports = new Set<number>();
|
|
210
|
+
for (let offset = 0; offset < blockSize; offset++) ports.add(start + offset);
|
|
211
|
+
for (const offset of probeOffsets) ports.add(start + offset);
|
|
212
|
+
return [...ports].filter((port) => port > 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async probeFree(port: number): Promise<boolean> {
|
|
216
|
+
if (port <= 0) return false;
|
|
217
|
+
return (await this.probe(port)) === 'free';
|
|
218
|
+
}
|
|
219
|
+
}
|