Skip to content

Commit 47eb743

Browse files
committed
test(stack): remove port allocator race
1 parent c2f5e4c commit 47eb743

2 files changed

Lines changed: 90 additions & 50 deletions

File tree

packages/stack/src/PortAllocator.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ export const DEFAULT_PORTS: Partial<AllocatedPorts> = {
119119
interface PortAllocationOptions {
120120
readonly reserved?: ReadonlySet<number>;
121121
readonly preferred?: Partial<AllocatedPorts>;
122+
readonly probe?: PortProbe;
123+
}
124+
125+
interface PortProbe {
126+
readonly exact: (port: number) => Effect.Effect<number, PortAllocationError>;
127+
readonly random: (exclude: ReadonlySet<number>) => Effect.Effect<number, PortAllocationError>;
122128
}
123129

124130
/** Bind port 0 to get an OS-assigned random port, then close immediately. */
@@ -159,20 +165,25 @@ const probeExactPort = (port: number): Effect.Effect<number, PortAllocationError
159165
const chooseExactPort = (
160166
port: number,
161167
exclude: ReadonlySet<number>,
168+
probe: PortProbe,
162169
): Effect.Effect<number, PortAllocationError> =>
163170
exclude.has(port)
164171
? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available` }))
165-
: probeExactPort(port);
172+
: probe.exact(port);
166173

167174
const choosePreferredPort = (
168175
port: number,
169176
exclude: ReadonlySet<number>,
177+
probe: PortProbe,
170178
): Effect.Effect<number, PortAllocationError> =>
171179
exclude.has(port)
172-
? probeRandomPort(exclude)
173-
: probeExactPort(port).pipe(
174-
Effect.catchTag("PortAllocationError", () => probeRandomPort(exclude)),
175-
);
180+
? probe.random(exclude)
181+
: probe.exact(port).pipe(Effect.catchTag("PortAllocationError", () => probe.random(exclude)));
182+
183+
const defaultPortProbe: PortProbe = {
184+
exact: probeExactPort,
185+
random: probeRandomPort,
186+
};
176187

177188
export const allocatePorts = (
178189
input: PortInput,
@@ -181,6 +192,7 @@ export const allocatePorts = (
181192
Effect.gen(function* () {
182193
const reserved = options.reserved ?? new Set<number>();
183194
const preferred = options.preferred ?? {};
195+
const probe = options.probe ?? defaultPortProbe;
184196
const allocated = new Set<number>();
185197

186198
const alloc = (port: number) => {
@@ -193,15 +205,15 @@ export const allocatePorts = (
193205
const resolvePort = (field: PortField) => {
194206
const explicit = input[field];
195207
if (explicit !== undefined) {
196-
return chooseExactPort(explicit, exclude());
208+
return chooseExactPort(explicit, exclude(), probe);
197209
}
198210

199211
const preferredPort = preferred[field];
200212
if (preferredPort !== undefined) {
201-
return choosePreferredPort(preferredPort, exclude());
213+
return choosePreferredPort(preferredPort, exclude(), probe);
202214
}
203215

204-
return probeRandomPort(exclude());
216+
return probe.random(exclude());
205217
};
206218

207219
const partial: Partial<Record<PortField, number>> = {};

packages/stack/src/PortAllocator.unit.test.ts

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
22
import { createServer } from "node:net";
33
import type { Server } from "node:net";
44
import { Effect } from "effect";
5-
import { allocatePorts, DEFAULT_PORTS } from "./PortAllocator.ts";
5+
import { allocatePorts, DEFAULT_PORTS, PortAllocationError } from "./PortAllocator.ts";
66

77
const listen = (port: number) =>
88
Effect.callback<Server, Error>((resume) => {
@@ -22,20 +22,6 @@ const close = (server: Server) =>
2222
return Effect.void;
2323
});
2424

25-
const getFreePort = () =>
26-
Effect.acquireUseRelease(
27-
listen(0),
28-
(server) =>
29-
Effect.sync(() => {
30-
const addr = server.address();
31-
if (addr == null || typeof addr === "string") {
32-
throw new Error("Expected TCP server address");
33-
}
34-
return addr.port;
35-
}),
36-
close,
37-
);
38-
3925
/** Occupy an OS-assigned port for the duration of a scoped effect. */
4026
const occupyFreePort = () =>
4127
Effect.acquireRelease(
@@ -49,9 +35,45 @@ const occupyFreePort = () =>
4935
({ server }) => close(server),
5036
);
5137

38+
const fakePortProbe = (
39+
options: {
40+
readonly unavailable?: ReadonlySet<number>;
41+
readonly randomPorts?: readonly number[];
42+
} = {},
43+
) => {
44+
const unavailable = options.unavailable ?? new Set<number>();
45+
const randomPorts =
46+
options.randomPorts ?? Array.from({ length: 100 }, (_, index) => 30001 + index);
47+
let randomIndex = 0;
48+
49+
return {
50+
exact: (port: number) =>
51+
unavailable.has(port)
52+
? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available` }))
53+
: Effect.succeed(port),
54+
random: (exclude: ReadonlySet<number>) =>
55+
Effect.gen(function* () {
56+
while (randomIndex < randomPorts.length) {
57+
const port = randomPorts[randomIndex];
58+
randomIndex += 1;
59+
if (port === undefined) {
60+
continue;
61+
}
62+
if (!exclude.has(port) && !unavailable.has(port)) {
63+
return port;
64+
}
65+
}
66+
67+
return yield* Effect.fail(
68+
new PortAllocationError({ detail: "No fake random ports available" }),
69+
);
70+
}),
71+
};
72+
};
73+
5274
describe("allocatePorts", () => {
5375
it("all allocated ports are unique", async () => {
54-
const ports = await Effect.runPromise(allocatePorts({}));
76+
const ports = await Effect.runPromise(allocatePorts({}, { probe: fakePortProbe() }));
5577
const values = Object.values(ports) as number[];
5678
const unique = new Set(values);
5779
expect(unique.size).toBe(values.length);
@@ -61,9 +83,11 @@ describe("allocatePorts", () => {
6183
});
6284

6385
it("reserved ports are skipped by later allocations", async () => {
64-
const a = await Effect.runPromise(allocatePorts({}));
86+
const a = await Effect.runPromise(allocatePorts({}, { probe: fakePortProbe() }));
6587
const aPorts = new Set(Object.values(a) as number[]);
66-
const b = await Effect.runPromise(allocatePorts({}, { reserved: aPorts }));
88+
const b = await Effect.runPromise(
89+
allocatePorts({}, { reserved: aPorts, probe: fakePortProbe() }),
90+
);
6791
const bPorts = Object.values(b) as number[];
6892

6993
for (const port of bPorts) {
@@ -72,10 +96,13 @@ describe("allocatePorts", () => {
7296
});
7397

7498
it("explicit port is respected when available", async () => {
75-
const requestedApiPort = await Effect.runPromise(getFreePort());
76-
const requestedDbPort = await Effect.runPromise(getFreePort());
99+
const requestedApiPort = 21001;
100+
const requestedDbPort = 21002;
77101
const ports = await Effect.runPromise(
78-
allocatePorts({ apiPort: requestedApiPort, dbPort: requestedDbPort }),
102+
allocatePorts(
103+
{ apiPort: requestedApiPort, dbPort: requestedDbPort },
104+
{ probe: fakePortProbe() },
105+
),
79106
);
80107
expect(ports.apiPort).toBe(requestedApiPort);
81108
expect(ports.dbPort).toBe(requestedDbPort);
@@ -99,9 +126,9 @@ describe("allocatePorts", () => {
99126
});
100127

101128
it("preferred ports are reused when available", async () => {
102-
const apiPort = await Effect.runPromise(getFreePort());
103-
const dbPort = await Effect.runPromise(getFreePort());
104-
const studioPort = await Effect.runPromise(getFreePort());
129+
const apiPort = 21003;
130+
const dbPort = 21004;
131+
const studioPort = 21005;
105132
const ports = await Effect.runPromise(
106133
allocatePorts(
107134
{},
@@ -111,6 +138,7 @@ describe("allocatePorts", () => {
111138
dbPort,
112139
studioPort,
113140
},
141+
probe: fakePortProbe(),
114142
},
115143
),
116144
);
@@ -121,27 +149,26 @@ describe("allocatePorts", () => {
121149
});
122150

123151
it("preferred ports fall back to random ports when unavailable", async () => {
124-
const dbPort = await Effect.runPromise(getFreePort());
125-
const exit = await Effect.runPromise(
126-
Effect.scoped(
127-
Effect.gen(function* () {
128-
const occupied = yield* occupyFreePort();
129-
130-
return yield* allocatePorts(
131-
{},
132-
{
133-
preferred: {
134-
apiPort: occupied.port,
135-
dbPort,
136-
},
137-
},
138-
);
139-
}),
152+
const apiPort = 21006;
153+
const dbPort = 21007;
154+
const ports = await Effect.runPromise(
155+
allocatePorts(
156+
{},
157+
{
158+
preferred: {
159+
apiPort,
160+
dbPort,
161+
},
162+
probe: fakePortProbe({
163+
unavailable: new Set([apiPort]),
164+
randomPorts: Array.from({ length: 20 }, (_, index) => 31001 + index),
165+
}),
166+
},
140167
),
141168
);
142169

143-
expect(exit.apiPort).not.toBe(exit.dbPort);
144-
expect(exit.dbPort).toBe(dbPort);
170+
expect(ports.apiPort).toBe(31001);
171+
expect(ports.dbPort).toBe(dbPort);
145172
});
146173

147174
it("explicit ports cannot override reserved ownership", async () => {
@@ -170,6 +197,7 @@ describe("allocatePorts", () => {
170197
apiPort: 23001,
171198
},
172199
reserved: new Set([23001]),
200+
probe: fakePortProbe(),
173201
},
174202
),
175203
);

0 commit comments

Comments
 (0)