diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index db13a996255ea..a05ee65c7cfb7 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -363,6 +363,13 @@ effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", expect(requests).toEqual([]); stdout = owned; + expect(yield* scanner.scan()).toHaveLength(0); + stdout = unowned; + yield* TestClock.adjust(Duration.seconds(3)); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(0); + yield* TestClock.adjust(Duration.seconds(3)); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([]); @@ -521,6 +528,8 @@ effectIt.effect( procFiles["/proc/net/tcp"] = PROC_NET_TCP; procFiles["/proc/net/tcp6"] = PROC_NET_TCP6; + expect(yield* scanner.scan()).toEqual([]); + yield* TestClock.adjust(Duration.seconds(3)); expect((yield* scanner.scan()).map(({ pid }) => pid)).toEqual([4242, 4242]); yield* scanner.retain.pipe(Effect.scoped); procFds["77"] = procFds["4242"]!; @@ -730,6 +739,7 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou .pipe(Effect.match({ onFailure: (error) => error.reason, onSuccess: () => null })); expect(signal).not.toHaveBeenCalled(); expect(result).toBe("not-found"); + yield* TestClock.adjust(Duration.seconds(3)); expect(yield* scanner.scan()).toMatchObject([{ pid: 5000, port: 8765 }]); yield* scanner.stopServer({ pid: 5000, port: 8765 }); expect(signal).toHaveBeenCalledExactlyOnceWith(5300, "SIGTERM"); @@ -893,6 +903,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 78f134fc1b813..fbc9e022f31fb 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -73,6 +73,7 @@ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; const POLL_INTERVAL = Duration.seconds(3); +const LISTENER_MIN_AGE_MS = Duration.toMillis(POLL_INTERVAL); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; const SOCKET_OWNER_ATTEMPTS = 3; @@ -463,6 +464,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { retainCount: 0, }); const webProbeCacheRef = yield* Ref.make>(new Map()); + // The first successful read after idle shows existing listeners immediately. + const listenerFirstSeenRef = yield* Ref.make | null>(null); const scanSemaphore = yield* Semaphore.make(1); const lsofMissingRef = yield* Ref.make(false); const windowsProcessNamesRef = yield* Ref.make< @@ -701,12 +704,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) => { @@ -737,7 +740,28 @@ 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; + let discovered = servers?.filter(isOwned) ?? []; + // A failed listener read cannot establish when listeners first appeared. + if (servers !== null) { + const known = yield* Ref.get(listenerFirstSeenRef); + const firstSeen = new Map( + discovered.map((server) => { + const key = `${server.pid}:${server.port}`; + return [ + key, + known === null ? Number.NEGATIVE_INFINITY : (known.get(key) ?? completedAtMillis), + ] as const; + }), + ); + yield* Ref.set(listenerFirstSeenRef, firstSeen); + discovered = discovered.filter( + (server) => + completedAtMillis - + (firstSeen.get(`${server.pid}:${server.port}`) ?? completedAtMillis) >= + LISTENER_MIN_AGE_MS, + ); + } + return { discovered, configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -836,14 +860,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 fromProc = yield* scanProcListeners(resolveOwners); if (fromProc !== null) return yield* probeWebServers(fromProc, configuredUrls); const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); - if (yield* Ref.get(lsofMissingRef)) return yield* probeWebServers([], configuredUrls); + if (yield* Ref.get(lsofMissingRef)) return yield* probeWebServers(null, configuredUrls); const lsofResult = yield* processRunner .run({ command: "lsof", @@ -874,7 +896,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const withoutLsof = (yield* Ref.get(lsofMissingRef)) ? yield* scanProcListeners(resolveOwners) : null; - return yield* probeWebServers(withoutLsof ?? [], configuredUrls); + return yield* probeWebServers(withoutLsof, configuredUrls); }); const scanSnapshot = (configuredUrls: ReadonlyArray) => @@ -929,6 +951,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(); } }); @@ -941,6 +964,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { if (!idle) return; yield* scanSemaphore.withPermits(1)( Effect.gen(function* () { + yield* Ref.set(listenerFirstSeenRef, null); yield* Ref.set(socketOwnersRef, new Map()); yield* Ref.set(socketOwnerMissesRef, new Map()); const threads = yield* Ref.getAndSet(agentThreadsRef, new Map()); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 75910d9405c39..e1da2955170aa 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -265,7 +265,8 @@ apps. ### Local servers -The browser lists ports held by processes your terminals and agents started. +The browser lists ports held by processes your terminals and agents started, +once a port has stayed open for a few seconds. T3 Code reads which process holds each port and never connects to it, so a listed port opens as `http://localhost:` and might not be a web page. For an HTTPS server or a specific path, set the **Preview URL** on the project