diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 3dee90313a2b3..fd059322c9535 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -86,23 +86,32 @@ const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; readonly stdout?: () => string; + readonly fails?: () => boolean; }) => PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { - run: () => - Effect.succeed({ - stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, - stderr: "", - code: null, - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), + run: (run) => + input.fails?.() + ? Effect.fail( + new ProcessRunner.ProcessTimeoutError({ + command: run.command, + argumentCount: run.args.length, + timeoutMs: 5_000, + }), + ) + : Effect.succeed({ + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( @@ -363,6 +372,8 @@ effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", expect(requests).toEqual([]); stdout = owned; + expect(yield* scanner.scan()).toHaveLength(0); + yield* TestClock.adjust(Duration.seconds(3)); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([]); @@ -375,6 +386,61 @@ effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", }).pipe(Effect.provide(layer)); }); +effectIt.effect("lists a new owned listener once it outlives a poll", () => { + const listener = (pid: number, port: number) => `p${pid}\ncnode\nn127.0.0.1:${port}\n`; + const devServer = listener(1234, LSOF_TEST_PORT); + const testRunner = listener(1235, LSOF_TEST_PORT + 1); + let stdout = devServer; + let lsofFails = false; + const layer = layerLsofScanner({ + pid: () => 1234, + fetch: globalThis.fetch, + stdout: () => stdout, + fails: () => lsofFails, + }); + const ports = (servers: ReadonlyArray) => + servers.map((server) => server.port); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner([1234, 1235, 1236]); + expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT]); + + // A port held for moments, such as a test runner's, never shows. + stdout = `${devServer}${testRunner}`; + expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT]); + yield* TestClock.adjust(Duration.seconds(3)); + stdout = devServer; + expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT]); + + // One still listening a poll later does, even across a failed read, and a restart under a + // new pid keeps it listed. + stdout = `${devServer}${testRunner}`; + expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT]); + yield* TestClock.adjust(Duration.seconds(2)); + lsofFails = true; + expect(yield* scanner.scan()).toEqual([]); + lsofFails = false; + yield* TestClock.adjust(Duration.seconds(1)); + expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT, LSOF_TEST_PORT + 1]); + stdout = `${devServer}${listener(1236, LSOF_TEST_PORT + 1)}`; + expect(yield* scanner.scan()).toMatchObject([{ pid: 1234 }, { pid: 1236 }]); + + // Another thread taking the port waits like any new listener. + yield* scanner.registerTerminalProcesses({ + threadId: "other-thread", + terminalId: "other-terminal", + processIds: [1237], + }); + stdout = `${devServer}${listener(1237, LSOF_TEST_PORT + 1)}`; + expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT]); + + // Listeners that started while no client looked show as soon as one does. + stdout = `${devServer}${testRunner}${listener(1236, LSOF_TEST_PORT + 2)}`; + const reopened = yield* Effect.scoped(Effect.andThen(scanner.retain, scanner.scan())); + expect(ports(reopened)).toEqual([LSOF_TEST_PORT, LSOF_TEST_PORT + 1, LSOF_TEST_PORT + 2]); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents", () => { @@ -765,6 +831,8 @@ effectIt.effect.each(["linux", "darwin"] as const)( expect(yield* scanner.scan()).toEqual([]); environmentFails = false; } + expect(yield* scanner.scan()).toEqual([]); + yield* TestClock.adjust(Duration.seconds(3)); expect(yield* scanner.scan()).toMatchObject([ { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, ]); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index a75603ec02cee..8880e9903f142 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -73,6 +73,8 @@ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; const POLL_INTERVAL = Duration.seconds(3); +/** Test runners and build workers hold ports for moments, so a new listener waits one poll. */ +const LISTENER_SETTLE_MS = Duration.toMillis(POLL_INTERVAL); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; const SOCKET_OWNER_ATTEMPTS = 3; @@ -120,6 +122,10 @@ const UNOWNED: ServerOwner = { terminal: null, agent: null }; const isOwned = (server: DiscoveredLocalServer): boolean => server.terminal !== null || (server.agent ?? null) !== null; +/** Keyed by thread, not pid, so a server that restarts on its port stays settled. */ +const settleKey = (server: DiscoveredLocalServer): string => + `${server.terminal?.threadId ?? server.agent?.threadId}:${server.port}`; + interface ProcessTableRow { readonly pid: number; readonly ppid: number; @@ -463,6 +469,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { retainCount: 0, }); const webProbeCacheRef = yield* Ref.make>(new Map()); + // When each thread's port was first read, or null before the first read since idle: a client + // that just opened can't tell those listeners from long-running ones. + const listenerFirstSeenRef = yield* Ref.make | null>(null); const scanSemaphore = yield* Semaphore.make(1); const lsofMissingRef = yield* Ref.make(false); const windowsProcessNamesRef = yield* Ref.make< @@ -686,12 +695,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { // binary ones such as Unity's test runner or Steam's IPC. // Idle polls omit this span; requested scans and uncached probes retain their own spans. const probeWebServers = Effect.fnUntraced(function* ( - servers: ReadonlyArray, + servers: ReadonlyArray | null, configuredUrls: ReadonlyArray, ) { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); - const groups = makeWebProbeGroups(servers, configuredUrls); + const groups = makeWebProbeGroups(servers ?? [], configuredUrls); const probed = yield* Effect.forEach( groups, (group) => { @@ -722,7 +731,21 @@ export const make = Effect.gen(function* PortDiscoveryMake() { if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url }); } yield* Ref.set(webProbeCacheRef, nextCache); - return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot; + // A failed listener read says nothing about which listeners are new. + if (servers === null) return { discovered: [], configured } satisfies WebProbeSnapshot; + const owned = servers.filter(isOwned); + const known = yield* Ref.get(listenerFirstSeenRef); + const firstSeen = new Map( + owned.map((server) => { + const key = settleKey(server); + return [key, known === null ? Number.NEGATIVE_INFINITY : (known.get(key) ?? nowMillis)]; + }), + ); + yield* Ref.set(listenerFirstSeenRef, firstSeen); + const discovered = owned.filter( + (server) => nowMillis - (firstSeen.get(settleKey(server)) ?? nowMillis) >= LISTENER_SETTLE_MS, + ); + return { discovered, configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -821,14 +844,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverWindowsProbeFailure, }), ); - return yield* withWindowsProcessNames( - yield* probeWebServers(listeners ?? [], configuredUrls), - ); + return yield* withWindowsProcessNames(yield* probeWebServers(listeners, configuredUrls)); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(resolveOwners); - return yield* probeWebServers(fromProc ?? [], configuredUrls); + return yield* probeWebServers(fromProc, configuredUrls); } const lsofResult = yield* processRunner .run({ @@ -857,7 +878,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(resolveOwners); - return yield* probeWebServers(fromProc ?? [], configuredUrls); + return yield* probeWebServers(fromProc, configuredUrls); }); const scanSnapshot = (configuredUrls: ReadonlyArray) => @@ -912,6 +933,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { { ...state, retainCount: state.retainCount + 1 }, ]); if (wasIdle) { + yield* scanSemaphore.withPermits(1)(Ref.set(listenerFirstSeenRef, null)); yield* pollTick(); } }); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 25095b70f1c3d..f207627617e86 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -283,8 +283,9 @@ finishes; the call's own result shows its status. On web and desktop, the thread details card lists the local servers that the thread's terminals or its Claude agent started, with their ports. Not every one -serves a web page. A server the agent started stays listed after the agent's -turn ends. +serves a web page. A new server shows once it has listened for a few seconds, +so ports that tests and build tools open for a moment stay out of the list. A +server the agent started stays listed after the agent's turn ends. Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows.