From c506905cdb0b7e01236ba11be9d9dfd576246801 Mon Sep 17 00:00:00 2001 From: Salloxy <197323176+Salloxy@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:41:31 -0500 Subject: [PATCH 1/2] fix(server): thread details no longer flashes ports held for moments Since port discovery stopped probing owned ports, any listener a thread's terminal or agent opened was listed on the next poll, including ports that test runners and build tools hold for a moment. They flashed into the thread details Servers list and out again. A new owned port is now listed once it has been up for one poll interval. Ports already listening when a client first subscribes show at once, a failed listener read does not reset the wait, and a server that restarts on the same port under a new pid stays listed. Created with Claude Opus 5.5 in Claude Code, running in T3 Code. --- apps/server/src/preview/PortScanner.test.ts | 43 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 34 ++++++++++++---- docs/user/thread-sidebar.md | 5 ++- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 3dee90313a2b3..4b8c6575f7a1a 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -363,6 +363,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 +377,45 @@ 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; + const layer = layerLsofScanner({ + pid: () => 1234, + fetch: globalThis.fetch, + stdout: () => stdout, + }); + 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, 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(3)); + 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 }]); + + // 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 +806,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 e026b204c03d8..37f42d6815882 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -72,6 +72,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; @@ -514,6 +516,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { retainCount: 0, }); const webProbeCacheRef = yield* Ref.make>(new Map()); + // When each owned 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< @@ -736,12 +741,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { // request: owning a process says nothing about its protocol, and HTTP or TLS bytes break // binary ones such as Unity's test runner or Steam's IPC. const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(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) => { @@ -772,7 +777,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(({ port }) => [ + port, + known === null ? Number.NEGATIVE_INFINITY : (known.get(port) ?? nowMillis), + ]), + ); + yield* Ref.set(listenerFirstSeenRef, firstSeen); + const discovered = owned.filter( + ({ port }) => nowMillis - (firstSeen.get(port) ?? nowMillis) >= LISTENER_SETTLE_MS, + ); + return { discovered, configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -881,14 +900,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({ @@ -917,7 +934,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 = Effect.fn("PortDiscovery.scanSnapshot")( @@ -973,6 +990,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 bed4ed73a40c5..7fec479abedde 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -274,8 +274,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. From ce055cade136feb36bb06ca02c329ced65b751ba Mon Sep 17 00:00:00 2001 From: Salloxy <197323176+Salloxy@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:00:00 -0500 Subject: [PATCH 2/2] fix(server): a port another thread takes over waits like a new one Settling was keyed by port only, so a listener from a different thread that took a port within one poll inherited the previous owner's settled time and showed at once. Key it by owner thread and port, which still keeps a server that restarts in the same thread listed. The test also covers a failed listener read during the wait. Created with Claude Opus 5.5 in Claude Code, running in T3 Code. --- apps/server/src/preview/PortScanner.test.ts | 51 +++++++++++++++------ apps/server/src/preview/PortScanner.ts | 20 ++++---- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 4b8c6575f7a1a..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( @@ -382,10 +391,12 @@ effectIt.effect("lists a new owned listener once it outlives a poll", () => { 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); @@ -401,14 +412,28 @@ effectIt.effect("lists a new owned listener once it outlives a poll", () => { stdout = devServer; expect(ports(yield* scanner.scan())).toEqual([LSOF_TEST_PORT]); - // One still listening a poll later does, and a restart under a new pid keeps it listed. + // 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(3)); + 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())); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 37f42d6815882..16aedf8d377dc 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -123,6 +123,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; @@ -516,9 +520,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { retainCount: 0, }); const webProbeCacheRef = yield* Ref.make>(new Map()); - // When each owned 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); + // 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< @@ -782,14 +786,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const owned = servers.filter(isOwned); const known = yield* Ref.get(listenerFirstSeenRef); const firstSeen = new Map( - owned.map(({ port }) => [ - port, - known === null ? Number.NEGATIVE_INFINITY : (known.get(port) ?? nowMillis), - ]), + 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( - ({ port }) => nowMillis - (firstSeen.get(port) ?? nowMillis) >= LISTENER_SETTLE_MS, + (server) => nowMillis - (firstSeen.get(settleKey(server)) ?? nowMillis) >= LISTENER_SETTLE_MS, ); return { discovered, configured } satisfies WebProbeSnapshot; });