@pi-archimedes/mcp 2.3.0
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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { createServer, get as httpGet } from "node:http";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CALLBACK_TIMEOUT_MS,
|
|
8
|
+
DEFAULT_CALLBACK_PATH,
|
|
9
|
+
DEFAULT_CALLBACK_PORT,
|
|
10
|
+
ensureCallbackServer,
|
|
11
|
+
getCallbackPath,
|
|
12
|
+
getCallbackPort,
|
|
13
|
+
reserveAuthState,
|
|
14
|
+
stopCallbackServer,
|
|
15
|
+
waitForCallback,
|
|
16
|
+
} from "./callback-server.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GET helper returning status + body. `Connection: close` is sent so the
|
|
20
|
+
* server-side socket does not linger in the keep-alive pool after the
|
|
21
|
+
* response — otherwise `server.close()` (and thus `stopCallbackServer`)
|
|
22
|
+
* would wait for idle sockets and tests would hang.
|
|
23
|
+
*/
|
|
24
|
+
function httpGetText(
|
|
25
|
+
port: number,
|
|
26
|
+
path: string,
|
|
27
|
+
): Promise<{ status: number; body: string; contentType?: string | undefined }> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const req = httpGet(
|
|
30
|
+
{ host: "127.0.0.1", port, path, headers: { connection: "close" } },
|
|
31
|
+
(res) => {
|
|
32
|
+
const chunks: Buffer[] = [];
|
|
33
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
34
|
+
res.on("end", () =>
|
|
35
|
+
resolve({
|
|
36
|
+
status: res.statusCode ?? 0,
|
|
37
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
38
|
+
contentType: res.headers["content-type"],
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
res.on("error", reject);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
req.on("error", reject);
|
|
45
|
+
req.end();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Connect probe: true when something accepts TCP on 127.0.0.1:<port>. */
|
|
50
|
+
async function isPortListening(port: number): Promise<boolean> {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
53
|
+
let done = false;
|
|
54
|
+
const finish = (ok: boolean): void => {
|
|
55
|
+
if (done) return;
|
|
56
|
+
done = true;
|
|
57
|
+
socket.destroy();
|
|
58
|
+
resolve(ok);
|
|
59
|
+
};
|
|
60
|
+
socket.once("connect", () => finish(true));
|
|
61
|
+
socket.once("error", () => finish(false));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ENV_KEY = "MCP_OAUTH_CALLBACK_PORT";
|
|
66
|
+
|
|
67
|
+
/** The singleton persists across tests within this file — one start, stop at the end. */
|
|
68
|
+
let port: number;
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
port = await ensureCallbackServer({ port: 0 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await stopCallbackServer();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("callback-server config", () => {
|
|
79
|
+
const originalEnv = process.env[ENV_KEY];
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
if (originalEnv === undefined) delete process.env[ENV_KEY];
|
|
83
|
+
else process.env[ENV_KEY] = originalEnv;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("exposes the default port, path, and timeout constants", () => {
|
|
87
|
+
expect(DEFAULT_CALLBACK_PORT).toBe(19876);
|
|
88
|
+
expect(DEFAULT_CALLBACK_PATH).toBe("/callback");
|
|
89
|
+
expect(CALLBACK_TIMEOUT_MS).toBe(5 * 60 * 1000);
|
|
90
|
+
expect(getCallbackPath()).toBe("/callback");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns the default port when the env override is unset", () => {
|
|
94
|
+
delete process.env[ENV_KEY];
|
|
95
|
+
expect(getCallbackPort()).toBe(DEFAULT_CALLBACK_PORT);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("honors a valid MCP_OAUTH_CALLBACK_PORT override", () => {
|
|
99
|
+
process.env[ENV_KEY] = "24567";
|
|
100
|
+
expect(getCallbackPort()).toBe(24567);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to the default port for invalid env values", () => {
|
|
104
|
+
for (const value of ["not-a-number", "0", "-3", "70000", "12.5"]) {
|
|
105
|
+
process.env[ENV_KEY] = value;
|
|
106
|
+
expect(getCallbackPort(), `env=${value}`).toBe(DEFAULT_CALLBACK_PORT);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("callback server", () => {
|
|
112
|
+
it("ensureCallbackServer returns a real port for port 0 and reuses the singleton", async () => {
|
|
113
|
+
expect(port).toBeGreaterThan(0);
|
|
114
|
+
expect(await ensureCallbackServer({ port: 0 })).toBe(port);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("resolves the waiter with the code and serves a 200 HTML page", async () => {
|
|
118
|
+
reserveAuthState("state-success");
|
|
119
|
+
const promise = waitForCallback("state-success");
|
|
120
|
+
|
|
121
|
+
const res = await httpGetText(port, "/callback?code=abc&state=state-success");
|
|
122
|
+
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
expect(res.contentType).toContain("text/html");
|
|
125
|
+
expect(res.body.toLowerCase()).toContain("<html");
|
|
126
|
+
expect(await promise).toEqual({ code: "abc" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("passes the iss parameter through to the resolved value", async () => {
|
|
130
|
+
reserveAuthState("state-iss");
|
|
131
|
+
const promise = waitForCallback("state-iss");
|
|
132
|
+
const iss = "https://issuer.example.com/tenant-1";
|
|
133
|
+
|
|
134
|
+
const res = await httpGetText(
|
|
135
|
+
port,
|
|
136
|
+
`/callback?code=xyz&iss=${encodeURIComponent(iss)}&state=state-iss`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
expect(await promise).toEqual({ code: "xyz", iss });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("supersedes a previously registered waiter for the same state", async () => {
|
|
144
|
+
const first = waitForCallback("state-superseded");
|
|
145
|
+
const firstOutcome = first.then(
|
|
146
|
+
() => "resolved" as const,
|
|
147
|
+
(error: Error) => `rejected: ${error.message}`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const second = waitForCallback("state-superseded");
|
|
151
|
+
await expect(firstOutcome).resolves.toMatch(/^rejected:/);
|
|
152
|
+
|
|
153
|
+
const res = await httpGetText(port, "/callback?code=abc&state=state-superseded");
|
|
154
|
+
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
await expect(second).resolves.toEqual({ code: "abc" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("answers 404 for the wrong path", async () => {
|
|
160
|
+
const res = await httpGetText(port, "/elsewhere?code=abc&state=state-404");
|
|
161
|
+
expect(res.status).toBe(404);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("answers 400 when the state parameter is missing", async () => {
|
|
165
|
+
const res = await httpGetText(port, "/callback?code=abc");
|
|
166
|
+
expect(res.status).toBe(400);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("answers 400 for an unknown state and leaves the waiter pending until aborted", async () => {
|
|
170
|
+
const ac = new AbortController();
|
|
171
|
+
const promise = waitForCallback("state-live", ac.signal);
|
|
172
|
+
|
|
173
|
+
const res = await httpGetText(port, "/callback?code=abc&state=state-unknown");
|
|
174
|
+
|
|
175
|
+
expect(res.status).toBe(400);
|
|
176
|
+
// The unknown-state request must not have settled the live waiter: it
|
|
177
|
+
// still rejects with "OAuth cancelled" when aborted.
|
|
178
|
+
ac.abort();
|
|
179
|
+
await expect(promise).rejects.toThrow("OAuth cancelled");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("answers 400 for an error callback with an unknown state", async () => {
|
|
183
|
+
const res = await httpGetText(port, "/callback?error=access_denied&state=state-bogus");
|
|
184
|
+
expect(res.status).toBe(400);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects the waiter with the error_description for error= callbacks", async () => {
|
|
188
|
+
reserveAuthState("state-error");
|
|
189
|
+
const promise = waitForCallback("state-error");
|
|
190
|
+
// Attach the rejection handler BEFORE the server rejects, so the
|
|
191
|
+
// rejection can never be unhandled (the server settles the waiter
|
|
192
|
+
// while processing the request, before the response is delivered).
|
|
193
|
+
const assertion = expect(promise).rejects.toThrow("User denied");
|
|
194
|
+
|
|
195
|
+
const res = await httpGetText(
|
|
196
|
+
port,
|
|
197
|
+
"/callback?error=access_denied&error_description=User%20denied&state=state-error",
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
expect(res.contentType).toContain("text/html");
|
|
202
|
+
expect(res.body).toContain("User denied");
|
|
203
|
+
await assertion;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("rejects a waiter with exactly 'OAuth cancelled' when the signal aborts", async () => {
|
|
207
|
+
const ac = new AbortController();
|
|
208
|
+
reserveAuthState("state-abort");
|
|
209
|
+
const promise = waitForCallback("state-abort", ac.signal);
|
|
210
|
+
|
|
211
|
+
ac.abort();
|
|
212
|
+
|
|
213
|
+
const error = await promise.then(
|
|
214
|
+
() => {
|
|
215
|
+
throw new Error("expected the waiter to reject");
|
|
216
|
+
},
|
|
217
|
+
(err: Error) => err,
|
|
218
|
+
);
|
|
219
|
+
expect(error.message).toBe("OAuth cancelled");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("rejects immediately when the signal is already aborted", async () => {
|
|
223
|
+
const ac = new AbortController();
|
|
224
|
+
ac.abort();
|
|
225
|
+
|
|
226
|
+
await expect(waitForCallback("state-preaborted", ac.signal)).rejects.toThrow("OAuth cancelled");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects the waiter after the 5-minute callback timeout", async () => {
|
|
230
|
+
vi.useFakeTimers();
|
|
231
|
+
try {
|
|
232
|
+
const promise = waitForCallback("state-timeout");
|
|
233
|
+
vi.advanceTimersByTime(CALLBACK_TIMEOUT_MS + 1);
|
|
234
|
+
await expect(promise).rejects.toThrow("OAuth callback timed out");
|
|
235
|
+
} finally {
|
|
236
|
+
vi.useRealTimers();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("serves the 200 manual hand-off page for a reserved state with no code, no error, and no live waiter", async () => {
|
|
241
|
+
reserveAuthState("state-no-code");
|
|
242
|
+
|
|
243
|
+
const res = await httpGetText(port, "/callback?state=state-no-code");
|
|
244
|
+
|
|
245
|
+
// The manual hand-off HTML page — not error HTML, not a text response.
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
expect(res.contentType).toContain("text/html");
|
|
248
|
+
expect(res.body).toContain("manual hand-off");
|
|
249
|
+
expect(res.body).toContain("Copy & paste");
|
|
250
|
+
expect(res.body).toContain("without an authorization code");
|
|
251
|
+
|
|
252
|
+
// The reservation is consumed: a second hit on the same state is unknown.
|
|
253
|
+
const repeat = await httpGetText(port, "/callback?state=state-no-code");
|
|
254
|
+
expect(repeat.status).toBe(400);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("rejects the live waiter with a clear no-code error and still serves the manual hand-off page", async () => {
|
|
258
|
+
reserveAuthState("state-no-code-waiter");
|
|
259
|
+
const promise = waitForCallback("state-no-code-waiter");
|
|
260
|
+
// Attach the assertion before the server settles the waiter so the
|
|
261
|
+
// rejection can never be unhandled (the server rejects the waiter
|
|
262
|
+
// while processing the request, before the response is delivered).
|
|
263
|
+
const assertion = expect(promise).rejects.toThrow("No authorization code received");
|
|
264
|
+
|
|
265
|
+
const res = await httpGetText(port, "/callback?state=state-no-code-waiter");
|
|
266
|
+
|
|
267
|
+
expect(res.status).toBe(200);
|
|
268
|
+
expect(res.contentType).toContain("text/html");
|
|
269
|
+
expect(res.body).toContain("manual hand-off");
|
|
270
|
+
await assertion;
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("strict port binding", () => {
|
|
275
|
+
// NOTE: ensureCallbackServer with a non-matching strictPort rebinds — it
|
|
276
|
+
// stops the running server before attempting the fixed-port bind. Placed
|
|
277
|
+
// after the functional tests on purpose; the stop-lifecycle block below
|
|
278
|
+
// re-establishes a server for its own assertions.
|
|
279
|
+
it("rejects with a clear error naming the port when the fixed port is already taken", async () => {
|
|
280
|
+
const blocker = createServer();
|
|
281
|
+
await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve));
|
|
282
|
+
const blockerPort = (blocker.address() as AddressInfo).port;
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await expect(
|
|
286
|
+
ensureCallbackServer({ strictPort: true, port: blockerPort }),
|
|
287
|
+
).rejects.toThrow(String(blockerPort));
|
|
288
|
+
} finally {
|
|
289
|
+
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("stopCallbackServer", () => {
|
|
295
|
+
it("rejects pending waiters, releases the server, and allows re-binding", async () => {
|
|
296
|
+
const freshPort = await ensureCallbackServer({ port: 0 });
|
|
297
|
+
expect(freshPort).toBeGreaterThan(0);
|
|
298
|
+
|
|
299
|
+
const promise = waitForCallback("state-stop");
|
|
300
|
+
// stopCallbackServer rejects waiters synchronously — attach first.
|
|
301
|
+
const assertion = expect(promise).rejects.toThrow("OAuth callback server stopped");
|
|
302
|
+
await stopCallbackServer();
|
|
303
|
+
await assertion;
|
|
304
|
+
|
|
305
|
+
// The module is back in the unbound state and can bind again.
|
|
306
|
+
const reboundPort = await ensureCallbackServer({ port: 0 });
|
|
307
|
+
expect(reboundPort).toBeGreaterThan(0);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("concurrent bind races", () => {
|
|
312
|
+
/**
|
|
313
|
+
* Occupy a free port (the "in use" blocker) and hand back a releaser.
|
|
314
|
+
* Each test below stops the singleton first and last, so the singleton
|
|
315
|
+
* state does not leak between these tests and the groups above.
|
|
316
|
+
*/
|
|
317
|
+
async function takeFreePort(): Promise<{ port: number; release: () => Promise<void> }> {
|
|
318
|
+
const blocker = createServer();
|
|
319
|
+
await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", resolve));
|
|
320
|
+
const busyPort = (blocker.address() as AddressInfo).port;
|
|
321
|
+
return {
|
|
322
|
+
port: busyPort,
|
|
323
|
+
release: () => new Promise<void>((resolve) => blocker.close(() => resolve())),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
it("does not bleed a foreign strict bind's rejection into a different-target caller", async () => {
|
|
328
|
+
const { port: busyPort, release } = await takeFreePort();
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
await stopCallbackServer();
|
|
332
|
+
|
|
333
|
+
const strict = ensureCallbackServer({ strictPort: true, port: busyPort });
|
|
334
|
+
// Attach before the bind can settle so the rejection is observed.
|
|
335
|
+
const strictAssertion = expect(strict).rejects.toThrow(
|
|
336
|
+
new RegExp(`port ${busyPort} is already in use`),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// The dynamic caller joins the in-flight strict bind, but its target
|
|
340
|
+
// differs — it must NOT inherit A's EADDRINUSE and must rebind.
|
|
341
|
+
const dynamicPort = await ensureCallbackServer({ port: 0 });
|
|
342
|
+
|
|
343
|
+
expect(dynamicPort).toBeGreaterThan(0);
|
|
344
|
+
expect(dynamicPort).not.toBe(busyPort);
|
|
345
|
+
|
|
346
|
+
// The original strict caller still sees the clear error naming the port.
|
|
347
|
+
await strictAssertion;
|
|
348
|
+
} finally {
|
|
349
|
+
await release();
|
|
350
|
+
await stopCallbackServer();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("lets a same-target joiner inherit the in-flight strict bind's rejection", async () => {
|
|
355
|
+
const { port: busyPort, release } = await takeFreePort();
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await stopCallbackServer();
|
|
359
|
+
|
|
360
|
+
const first = ensureCallbackServer({ strictPort: true, port: busyPort });
|
|
361
|
+
const second = ensureCallbackServer({ strictPort: true, port: busyPort });
|
|
362
|
+
|
|
363
|
+
// Same target (port + path) → the joiner must inherit the rejection,
|
|
364
|
+
// not silently rebind (which would also fail with the same error).
|
|
365
|
+
await expect(first).rejects.toThrow(`port ${busyPort} is already in use`);
|
|
366
|
+
await expect(second).rejects.toThrow(`port ${busyPort} is already in use`);
|
|
367
|
+
} finally {
|
|
368
|
+
await release();
|
|
369
|
+
await stopCallbackServer();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("lets an in-flight same-target (dynamic) joiner get the first caller's resolved port", async () => {
|
|
374
|
+
await stopCallbackServer();
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const first = ensureCallbackServer({ port: 0 });
|
|
378
|
+
const second = ensureCallbackServer({ port: 0 });
|
|
379
|
+
|
|
380
|
+
const firstPort = await first;
|
|
381
|
+
const secondPort = await second;
|
|
382
|
+
|
|
383
|
+
expect(firstPort).toBeGreaterThan(0);
|
|
384
|
+
// Same-target join inherits success: B returns A's OS-assigned port.
|
|
385
|
+
expect(secondPort).toBe(firstPort);
|
|
386
|
+
} finally {
|
|
387
|
+
await stopCallbackServer();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("both dynamic callers survive a swallowed foreign strict failure (no spurious double bind)", async () => {
|
|
392
|
+
const { port: busyPort, release } = await takeFreePort();
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
await stopCallbackServer();
|
|
396
|
+
|
|
397
|
+
// All three start in the SAME tick (no awaits between them): A's
|
|
398
|
+
// in-flight bind promise is already registered before B and C run, so
|
|
399
|
+
// B and C are guaranteed to capture A's in-flight promise — the
|
|
400
|
+
// stale-capture window of this regression. A's EADDRINUSE cannot have
|
|
401
|
+
// been delivered yet (it arrives on a later IO tick), so A is freshly
|
|
402
|
+
// failing when B and C wake up.
|
|
403
|
+
const strict = ensureCallbackServer({ strictPort: true, port: busyPort });
|
|
404
|
+
const dynamicB = ensureCallbackServer({ port: 0 });
|
|
405
|
+
const dynamicC = ensureCallbackServer({ port: 0 });
|
|
406
|
+
|
|
407
|
+
const [strictRes, bRes, cRes] = await Promise.allSettled([
|
|
408
|
+
strict,
|
|
409
|
+
dynamicB,
|
|
410
|
+
dynamicC,
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
// The strict caller still sees the clear EADDRINUSE error naming the port.
|
|
414
|
+
expect(strictRes.status).toBe("rejected");
|
|
415
|
+
if (strictRes.status === "rejected") {
|
|
416
|
+
expect(String(strictRes.reason)).toContain(
|
|
417
|
+
`port ${busyPort} is already in use`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Both dynamic callers must resolve — regressed when C kept a stale
|
|
422
|
+
// in-flight promise and rebound anyway, bumping the generation counter
|
|
423
|
+
// and invalidating B's still-in-flight bind (B spuriously rejected
|
|
424
|
+
// with "OAuth callback server stopped" though no stop() happened).
|
|
425
|
+
expect(bRes.status, `dynamic B (got: ${describeSettled(bRes)})`).toBe("fulfilled");
|
|
426
|
+
expect(cRes.status, `dynamic C (got: ${describeSettled(cRes)})`).toBe("fulfilled");
|
|
427
|
+
if (bRes.status !== "fulfilled" || cRes.status !== "fulfilled") return;
|
|
428
|
+
|
|
429
|
+
// C re-joined B's in-flight bind rather than starting a second one.
|
|
430
|
+
expect(cRes.value).toBe(bRes.value);
|
|
431
|
+
expect(bRes.value).toBeGreaterThan(0);
|
|
432
|
+
expect(bRes.value).not.toBe(busyPort);
|
|
433
|
+
// Both returned ports are actually accepting connections.
|
|
434
|
+
expect(await isPortListening(bRes.value)).toBe(true);
|
|
435
|
+
} finally {
|
|
436
|
+
await release();
|
|
437
|
+
await stopCallbackServer();
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
function describeSettled(res: PromiseSettledResult<number>): string {
|
|
443
|
+
return res.status === "fulfilled"
|
|
444
|
+
? `fulfilled: ${res.value}`
|
|
445
|
+
: `rejected: ${String(res.reason)}`;
|
|
446
|
+
}
|