Skip to content

Commit bf8e132

Browse files
t3dotggPR Batch Testerclaude
authored
fix(server): preview discovery stops sending HTTP and TLS to agent-started ports (#27893)
Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 961026c commit bf8e132

6 files changed

Lines changed: 209 additions & 136 deletions

File tree

‎apps/server/src/preview/PortScanner.test.ts‎

Lines changed: 153 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ const layerProbeFailure = (
6262

6363
const LSOF_TEST_PORT = 43_123;
6464

65+
const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({
66+
stdout,
67+
stderr: "",
68+
code: ChildProcessSpawner.ExitCode(0),
69+
timedOut: false,
70+
stdoutTruncated: false,
71+
stderrTruncated: false,
72+
stdoutInvalidUtf8: false,
73+
stderrInvalidUtf8: false,
74+
});
75+
6576
const ownedScanner = (processIds: ReadonlyArray<number> = [1234]) =>
6677
Effect.tap(PortScanner.PortDiscovery, (scanner) =>
6778
scanner.registerTerminalProcesses({
@@ -174,18 +185,19 @@ effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites"
174185
effectIt.effect("projects configured paths independently for simultaneous subscribers", () => {
175186
const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`;
176187
const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`;
188+
let ready = true;
177189
const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
178190
const url = String(input);
179191
return Promise.resolve(
180-
url === docsUrl || url === adminUrl
192+
ready && (url === docsUrl || url === adminUrl)
181193
? new Response("app", { headers: { "content-type": "text/html" } })
182194
: new Response("not found", { status: 404 }),
183195
);
184196
}) as typeof globalThis.fetch;
185197
const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
186198

187199
return Effect.gen(function* () {
188-
const scanner = yield* PortScanner.PortDiscovery;
200+
const scanner = yield* ownedScanner();
189201
const docsSnapshots: ReadonlyArray<DiscoveredLocalServer>[] = [];
190202
const adminSnapshots: ReadonlyArray<DiscoveredLocalServer>[] = [];
191203
yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) =>
@@ -198,6 +210,11 @@ effectIt.effect("projects configured paths independently for simultaneous subscr
198210

199211
expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl);
200212
expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl);
213+
214+
ready = false;
215+
yield* TestClock.adjust(Duration.seconds(15));
216+
expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl);
217+
expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl);
201218
}).pipe(Effect.scoped, Effect.provide(layer));
202219
});
203220

@@ -289,28 +306,45 @@ effectIt.effect("uses the current configured fragment when readiness comes from
289306
}).pipe(Effect.provide(layer));
290307
});
291308

292-
effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => {
293-
const redirects: Array<string | undefined> = [];
294-
const fetchFn = (async (
295-
input: Parameters<typeof globalThis.fetch>[0],
296-
init?: Parameters<typeof globalThis.fetch>[1],
297-
) => {
298-
redirects.push(init?.redirect);
299-
if (String(input).startsWith("http:")) throw new TypeError("TLS listener");
300-
return new Response(null, { status: 302, headers: { location: "https://example.com" } });
301-
}) as typeof globalThis.fetch;
302-
const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
309+
effectIt.effect(
310+
"probes only a configured HTTPS URL, keeps it for an owned listener when the check fails, and does not follow redirects",
311+
() => {
312+
const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`;
313+
const requests: Array<readonly [string, string | undefined]> = [];
314+
let respond = async () =>
315+
new Response(null, { status: 302, headers: { location: "https://example.com" } });
316+
const fetchFn = (async (
317+
input: Parameters<typeof globalThis.fetch>[0],
318+
init?: Parameters<typeof globalThis.fetch>[1],
319+
) => {
320+
requests.push([String(input), init?.redirect]);
321+
return respond();
322+
}) as typeof globalThis.fetch;
323+
const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
303324

304-
return Effect.gen(function* () {
305-
const scanner = yield* ownedScanner();
306-
const servers = yield* scanner.scan();
307-
expect(servers).toHaveLength(1);
308-
expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`);
309-
expect(redirects).toEqual(["manual", "manual"]);
310-
}).pipe(Effect.provide(layer));
311-
});
325+
return Effect.gen(function* () {
326+
const scanner = yield* ownedScanner();
327+
expect(yield* scanner.scan([configuredUrl])).toMatchObject([
328+
{ pid: 1234, url: configuredUrl },
329+
]);
330+
331+
respond = async () => new Response("unavailable", { status: 503 });
332+
yield* TestClock.adjust(Duration.seconds(15));
333+
expect(yield* scanner.scan([configuredUrl])).toMatchObject([
334+
{ pid: 1234, url: configuredUrl },
335+
]);
312336

313-
effectIt.effect("probes a port only while T3 terminals own every listener on it", () => {
337+
respond = () => Promise.reject(new TypeError("fetch failed"));
338+
yield* TestClock.adjust(Duration.seconds(15));
339+
expect(yield* scanner.scan([configuredUrl])).toMatchObject([
340+
{ pid: 1234, url: configuredUrl },
341+
]);
342+
expect(requests).toEqual(Array.from({ length: 3 }, () => [configuredUrl, "manual"]));
343+
}).pipe(Effect.provide(layer));
344+
},
345+
);
346+
347+
effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => {
314348
const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`;
315349
const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`;
316350
let stdout = unowned;
@@ -330,61 +364,63 @@ effectIt.effect("probes a port only while T3 terminals own every listener on it"
330364

331365
stdout = owned;
332366
expect(yield* scanner.scan()).toHaveLength(1);
333-
expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]);
367+
expect(requests).toEqual([]);
334368

335369
yield* scanner.unregisterTerminal({
336370
threadId: "scanner-thread",
337371
terminalId: "scanner-terminal",
338372
});
339373
expect(yield* scanner.scan()).toHaveLength(0);
340-
expect(requests).toHaveLength(1);
374+
expect(requests).toEqual([]);
341375
}).pipe(Effect.provide(layer));
342376
});
343377

344378
effectIt.effect(
345-
"excludes HTTP errors, non-navigation responses, and successful non-documents",
379+
"excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents",
346380
() => {
347381
let pid = 1;
348382
let makeResponse = () =>
349383
new Response("not found", { status: 404, headers: { "content-type": "text/html" } });
350384
const fetchFn = ((_input: Parameters<typeof globalThis.fetch>[0]) =>
351385
Promise.resolve(makeResponse())) as typeof globalThis.fetch;
352386
const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn });
387+
const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/`;
353388

354389
return Effect.gen(function* () {
355-
const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]);
356-
expect(yield* scanner.scan()).toHaveLength(0);
390+
const scanner = yield* PortScanner.PortDiscovery;
391+
const scan = () => scanner.scan([configuredUrl]);
392+
expect(yield* scan()).toHaveLength(0);
357393

358394
pid += 1;
359395
makeResponse = () =>
360396
new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
361-
expect(yield* scanner.scan()).toHaveLength(0);
397+
expect(yield* scan()).toHaveLength(0);
362398

363399
pid += 1;
364400
makeResponse = () =>
365401
new Response("ready", { status: 200, headers: { "content-type": "text/plain" } });
366-
expect(yield* scanner.scan()).toHaveLength(0);
402+
expect(yield* scan()).toHaveLength(0);
367403

368404
pid += 1;
369405
makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } });
370-
expect(yield* scanner.scan()).toHaveLength(0);
406+
expect(yield* scan()).toHaveLength(0);
371407

372408
pid += 1;
373409
makeResponse = () =>
374410
new Response(null, { status: 204, headers: { "content-type": "text/html" } });
375-
expect(yield* scanner.scan()).toHaveLength(0);
411+
expect(yield* scan()).toHaveLength(0);
376412

377413
pid += 1;
378414
makeResponse = () => new Response(null, { status: 302 });
379-
expect(yield* scanner.scan()).toHaveLength(0);
415+
expect(yield* scan()).toHaveLength(0);
380416

381417
pid += 1;
382418
makeResponse = () =>
383419
new Response("<html />", {
384420
status: 200,
385421
headers: { "content-type": "application/xhtml+xml; charset=utf-8" },
386422
});
387-
expect(yield* scanner.scan()).toHaveLength(1);
423+
expect(yield* scan()).toHaveLength(1);
388424
}).pipe(Effect.provide(layer));
389425
},
390426
);
@@ -433,10 +469,11 @@ effectIt.effect(
433469
return target === undefined ? missing.readLink(path) : Effect.succeed(target);
434470
},
435471
});
436-
const fetchFn = (() =>
437-
Promise.resolve(
438-
new Response("hello", { headers: { "content-type": "text/html" } }),
439-
)) as typeof globalThis.fetch;
472+
const requests: string[] = [];
473+
const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
474+
requests.push(String(input));
475+
return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } }));
476+
}) as typeof globalThis.fetch;
440477
const layer = layerProbeFailure(
441478
(input) => {
442479
lsofSpawns += 1;
@@ -449,37 +486,45 @@ effectIt.effect(
449486
return Effect.gen(function* () {
450487
const scanner = yield* ownedScanner([4242]);
451488
const first = yield* scanner.scan();
452-
expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([
453-
{ port: 8765, pid: 4242, processName: "python3" },
454-
]);
489+
expect(
490+
first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })),
491+
).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]);
455492
yield* scanner.scan();
456493
expect(lsofSpawns).toBe(1);
457494
expect(fdWalks).toEqual(["77", "4242", "77", "4242"]);
458495
yield* scanner.scan();
459496
yield* scanner.scan();
460497
expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]);
498+
expect(requests).toEqual([]);
461499
}).pipe(Effect.provide(layer));
462500
},
463501
);
464502

465503
effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => {
466-
const fetchFn = (() =>
467-
Promise.resolve(
468-
new Response("hello", { headers: { "content-type": "text/html" } }),
469-
)) as typeof globalThis.fetch;
504+
let ipv6Fails = true;
505+
const requests: string[] = [];
506+
const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
507+
requests.push(String(input));
508+
return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } }));
509+
}) as typeof globalThis.fetch;
470510
const layer = PortScanner.layer.pipe(
471511
Layer.provide(
472512
Layer.mergeAll(
473513
layerNoProc,
474514
Layer.succeed(ProcessRunner.ProcessRunner, {
475515
run: (input) => {
516+
if (input.command === "tasklist.exe") {
517+
return Effect.succeed(
518+
processOutput('"node.exe","1234","Console","1","50,000 K"\r\n'),
519+
);
520+
}
476521
const isIpv6 = input.args.includes("tcpv6");
477522
return Effect.succeed({
478523
stdout: isIpv6
479524
? ""
480525
: " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n",
481526
stderr: "",
482-
code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null,
527+
code: isIpv6 && ipv6Fails ? ChildProcessSpawner.ExitCode(1) : null,
483528
timedOut: false,
484529
stdoutTruncated: false,
485530
stderrTruncated: false,
@@ -502,20 +547,14 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits
502547
processIds: [1234],
503548
});
504549
expect(yield* scanner.scan()).toEqual([]);
550+
ipv6Fails = false;
551+
expect(yield* scanner.scan()).toMatchObject([
552+
{ port: 5173, pid: 1234, processName: "node", url: "http://localhost:5173" },
553+
]);
554+
expect(requests).toEqual([]);
505555
}).pipe(Effect.provide(layer));
506556
});
507557

508-
const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({
509-
stdout,
510-
stderr: "",
511-
code: ChildProcessSpawner.ExitCode(0),
512-
timedOut: false,
513-
stdoutTruncated: false,
514-
stderrTruncated: false,
515-
stdoutInvalidUtf8: false,
516-
stderrInvalidUtf8: false,
517-
});
518-
519558
effectIt.effect("rechecks the socket owner before stopping a Linux server without lsof", () => {
520559
let holder = "4242";
521560
const missing = FileSystem.makeNoop({});
@@ -569,6 +608,63 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou
569608
}).pipe(Effect.provide(layer));
570609
});
571610

611+
effectIt.effect.each(["linux", "darwin", "win32"] as const)(
612+
"reads no listeners on %s while T3CODE_PORT_DISCOVERY=0, and still probes configured URLs",
613+
(platform) => {
614+
const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`;
615+
const commands: string[] = [];
616+
const fileReads: string[] = [];
617+
const requests: string[] = [];
618+
const missing = FileSystem.makeNoop({});
619+
const layer = PortScanner.layer.pipe(
620+
Layer.provide(
621+
Layer.mergeAll(
622+
FileSystem.layerNoop({
623+
readFileString: (path) => {
624+
fileReads.push(path);
625+
return missing.readFileString(path);
626+
},
627+
readDirectory: (path) => {
628+
fileReads.push(path);
629+
return missing.readDirectory(path);
630+
},
631+
}),
632+
Layer.succeed(ProcessRunner.ProcessRunner, {
633+
run: (input) => {
634+
commands.push(input.command);
635+
return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`));
636+
},
637+
}),
638+
Layer.succeed(HostProcess.Platform, platform),
639+
Layer.succeed(HostProcess.Environment, { T3CODE_PORT_DISCOVERY: "0" }),
640+
FetchHttpClient.layer.pipe(
641+
Layer.provide(
642+
Layer.succeed(FetchHttpClient.Fetch, (input) => {
643+
requests.push(String(input));
644+
return Promise.resolve(
645+
new Response("docs", { headers: { "content-type": "text/html" } }),
646+
);
647+
}),
648+
),
649+
),
650+
),
651+
),
652+
);
653+
654+
return Effect.gen(function* () {
655+
const scanner = yield* ownedScanner();
656+
scanner.trackAgent();
657+
expect(yield* scanner.scan()).toEqual([]);
658+
expect(yield* scanner.scan([configuredUrl])).toMatchObject([
659+
{ port: LSOF_TEST_PORT, pid: null, terminal: null, url: configuredUrl },
660+
]);
661+
expect(requests).toEqual([configuredUrl]);
662+
expect(commands).toEqual([]);
663+
expect(fileReads).toEqual([]);
664+
}).pipe(Effect.provide(layer));
665+
},
666+
);
667+
572668
effectIt.effect.each(["linux", "darwin"] as const)(
573669
"attributes listeners to the agent thread in their environment on %s, after the agent exits",
574670
(platform) => {
@@ -672,7 +768,7 @@ effectIt.effect.each(["linux", "darwin"] as const)(
672768
expect(yield* scanner.scan()).toMatchObject([
673769
{ port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } },
674770
]);
675-
expect(requests).toEqual([`http://localhost:${agentPort}/`]);
771+
expect(requests).toEqual([]);
676772

677773
expect(yield* scanner.scan()).toHaveLength(1);
678774
expect(environmentReads).toBe(2);

0 commit comments

Comments
 (0)