Repository navigation
[Bug]: Preview PortScanner sends HTTP(S) to unrelated listeners and can crash other apps #8407
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Run T3 Code Alpha 0.0.34 on macOS.
- In Settings → General → Legacy features, enable Sidebar (legacy). Browser Preview does not need to be open.
- Leave T3 Code running with the legacy sidebar mounted.
PortDiscovery.pollTickcontinues approximately every three seconds because the sidebar subscribes to environment-wide discovered servers. - Start a local non-HTTP service that expects a binary handshake. The reproduced service is the private JxBrowser RPC listener created during thinkorswim login.
- T3 Code's
PortScannerdiscovers the listener through the system-widelsofscan and sendsGET /first over HTTP and then HTTPS. - Observe the foreign-protocol connection in T3's trace and the receiving application's failure.
An alternative retainer is an empty Browser Preview surface, which also subscribes to discovered servers.
The thinkorswim reproduction is intermittent because its JxBrowser listener uses a random loopback port and the failure depends on timing during browser startup. It has occurred repeatedly during login after reboot/update.
Expected behavior
Browser Preview discovery should not write HTTP protocol bytes to arbitrary system-wide TCP listeners. Active probes should be limited to T3-managed process trees, explicitly configured Preview URLs, or another opt-in/safe eligibility boundary.
Actual behavior
On macOS/Linux, PortScanner enumerates all listening TCP sockets. Terminal ownership is recorded as metadata but is not an eligibility filter. For every discovered listener, T3 constructs HTTP and HTTPS URLs and performs GET requests.
Preview does not need to be open. The legacy sidebar invokes useThreadDiscoveredPorts for displayed threads, which maps to the environment-wide previewEnvironment.discoveredServers query. With legacySidebarEnabled: true, a live trace showed PortDiscovery.pollTick approximately every three seconds while no Preview interaction was occurring. The query layer may deduplicate identical environment subscriptions, but the mounted legacy sidebar still retains at least one discovery subscription.
This behavior was introduced by #6021 to distinguish browser-ready servers from services such as MySQL and Redis. A listener must therefore receive HTTP traffic before T3 decides that it is not an HTTP service.
In the captured reproduction, T3 probed thinkorswim's JxBrowser 9.0.1 binary RPC listener on port 63261. thinkorswim then raised a NullPointerException in the connection handshake and hung during login.
Relevant source:
- Legacy-sidebar retention path:
t3code/apps/web/src/components/LegacySidebar.tsx
Lines 374 to 386 in 9fd788b
const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, }); - Discovery-query mapping:
t3code/apps/web/src/portDiscoveryState.ts
Lines 53 to 65 in 9fd788b
export function useDiscoveredPortsState( environmentId: EnvironmentId | null, configuredUrls?: ReadonlyArray<string>, ): DiscoveredPortsState { const boundedConfiguredUrls = boundConfiguredLocalServerUrls(configuredUrls); const query = useEnvironmentQuery( environmentId === null ? null : previewEnvironment.discoveredServers({ environmentId, input: boundedConfiguredUrls.length ? { configuredUrls: boundedConfiguredUrls } : {}, }), ); - WebSocket retain/scan subscription:
Lines 2159 to 2184 in 9fd788b
); }), ), { "rpc.aggregate": "preview" }, ), [WS_METHODS.subscribeServerConfig]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, type: "keybindingsUpdated" as const, payload: { keybindings: event.keybindings, issues: event.issues, }, })), ); const providerStatuses = providerRegistry.streamChanges.pipe( Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, payload: { providers }, })), Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), - Empty-Preview retention path: https://github.com/pingdotgg/t3code/blob/9fd788b5a92254a2afa72c2a53513ed2cb730f0d/apps/web/src/features/preview/PreviewEmptyState.tsx#L20-L31
- Listener enumeration:
t3code/apps/server/src/preview/PortScanner.ts
Lines 480 to 535 in 9fd788b
const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray<string>, ) { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map<number, TerminalProcessOwner>(); for (const registration of state.terminalProcesses.values()) { for (const processId of registration.processIds) { terminalByProcessId.set(processId, registration.owner); } } if (hostPlatform === "win32") { const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner .run({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), Effect.catchTags({ ProcessSpawnError: recoverWindowsProbeFailure, ProcessStdinError: recoverWindowsProbeFailure, ProcessOutputLimitError: recoverWindowsProbeFailure, ProcessReadError: recoverWindowsProbeFailure, ProcessTimeoutError: recoverWindowsProbeFailure, }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), Effect.catchTags({ ProcessSpawnError: recoverLsofProbeFailure, ProcessStdinError: recoverLsofProbeFailure, ProcessOutputLimitError: recoverLsofProbeFailure, ProcessReadError: recoverLsofProbeFailure, ProcessTimeoutError: recoverLsofProbeFailure, }), ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); - HTTP/HTTPS candidate construction:
t3code/apps/server/src/preview/PortScanner.ts
Lines 350 to 390 in 9fd788b
const makeWebProbeGroups = ( servers: ReadonlyArray<DiscoveredLocalServer>, configuredUrls: ReadonlyArray<string>, ): ReadonlyArray<WebProbeGroup> => { const serversByKey = new Map( servers.map((server) => [localServerKey(server.host, server.port), server] as const), ); const groups: WebProbeGroup[] = []; const configuredResources = new Set<string>(); for (const raw of configuredUrls) { const url = new URL(raw); const port = urlPort(url); const key = localServerKey(url.hostname, port); const resourceKey = webProbeCacheKey(raw); if (configuredResources.has(resourceKey)) continue; configuredResources.add(resourceKey); groups.push({ server: serversByKey.get(key) ?? { host: url.hostname, port, url: raw, processName: null, pid: null, terminal: null, }, urls: [raw], configuredKey: resourceKey, }); } for (const server of servers) { groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], configuredKey: null, }); } return groups; }; - HTTP
GETclassifier:t3code/apps/server/src/preview/PortScanner.ts
Lines 329 to 348 in 9fd788b
const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { const location = response.headers.location?.trim(); if (NAVIGATION_REDIRECT_STATUSES.has(response.status) && location) return url; if (response.status < 200 || response.status >= 300) return null; if (response.status === 204 || response.status === 205) return null; const contentType = response.headers["content-type"] ?.split(";", 1)[0] ?.trim() .toLowerCase(); return contentType === "text/html" || contentType === "application/xhtml+xml" ? url : null; }), Effect.scoped, Effect.timeoutOption(WEB_PROBE_TIMEOUT), Effect.map(Option.getOrNull), Effect.orElseSucceed(() => null), Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), ), ); - Three-second polling:
t3code/apps/server/src/preview/PortScanner.ts
Lines 550 to 583 in 9fd788b
const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { if ((yield* Ref.get(stateRef)).retainCount <= 0) return; const configuredUrls = [ ...new Set( [...(yield* Ref.get(stateRef)).listeners.values()].flatMap( (subscription) => subscription.configuredUrls, ), ), ]; const snapshot = yield* scanSnapshot(configuredUrls); const notifications = yield* Ref.modify(stateRef, (state) => { const listeners = new Map(state.listeners); const changed: Array<readonly [Listener, ReadonlyArray<DiscoveredLocalServer>]> = []; for (const [listener, subscription] of listeners) { const next = projectWebProbeSnapshot(snapshot, subscription.configuredUrls); if (serversEqual(subscription.lastSnapshot, next)) continue; listeners.set(listener, { ...subscription, lastSnapshot: next }); changed.push([listener, next]); } return [changed, { ...state, listeners }]; }); yield* Effect.forEach(notifications, ([listener, servers]) => listener(servers), { discard: true, }); }, Effect.catchCause((cause: Cause.Cause<never>) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), ), ); // Single layer-scoped polling fiber. Ticks are no-ops when no client is // currently retained, so the cost is one Ref.get every POLL_INTERVAL. yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
Impact
Major degradation or frequent failure. The scanner can be a hidden background side effect of ordinary T3 use rather than only an effect of explicitly using Browser Preview.
Version or commit
T3 Code Alpha 0.0.34; behavior introduced by merged PR #6021 / commit 9fd788b5a92254a2afa72c2a53513ed2cb730f0d
Environment
- macOS 26.6.2, Apple Silicon
- T3 Code Alpha 0.0.34
- thinkorswim build 1993.0.48
- Java 21.0.5 aarch64
- JxBrowser 9.0.1 / Chromium 147.0.7727.138
Logs or stack traces
T3 server trace (EDT):
2026-08-27 09:32:07.068 PortDiscovery http.client GET http://localhost:63261/
2026-08-27 09:32:08.070 HTTP probe ends after 1001.755 ms; HTTPS probe begins
thinkorswim client.log (EDT):
2026-08-27 09:32:07.212 OAuth logged in
2026-08-27 09:32:08.079 Error in IPC Server Thread
java.lang.NullPointerException: Cannot invoke
"com.teamdev.jxbrowser.internal.rpc.ConnectionCreated.getConnectionData()"
because "connectionCreated" is null
at com.teamdev.jxbrowser.internal.rpc.transport.ConnectionServer$AcceptConnections.acceptConnection(ConnectionServer.java:191)
at com.teamdev.jxbrowser.internal.rpc.transport.ConnectionServer$AcceptConnections.run(ConnectionServer.java:170)
at java.base/java.lang.Thread.run(Unknown Source)
Runtime socket inspection confirmed that the T3 server process was connected to the same JxBrowser listener port. The synchronized timestamps establish T3's probe as the trigger; the null dereference itself is also a JxBrowser input-validation defect.
Workaround
Disable Settings → General → Legacy features → Sidebar (legacy), close any empty Browser Preview surfaces, and wait for discovery retainers to release before launching thinkorswim. Quitting T3 Code during thinkorswim startup is a fallback. A per-process outbound firewall rule preventing T3 from connecting to random high-numbered loopback ports is another containment option. These measures do not fix the root cause.
Related but non-fixing work: #4187, #5900, #6254, and #7789.
Activity
t3dotgg commentedon Sep 4, 2026
Note
🤖 GPT-6 Astra (preview) responding on behalf of Theo
This note is part of an automated cleanup pass.
Related report #9526 adds a Linux Steam reproduction on T3 Code 0.0.39-nightly.20260903.1270. Socket inspection identifies the T3 server as Steam's peer. Steam receives GET first, then a TLS ClientHello, and blocks in recv for about 10.45 seconds despite the one-second probe timeout. The report also observed this with Preview not visible. Keep a test with a TCP listener that reads the TLS prefix and waits for EOF. Cancellation must close the socket promptly, and discovery must not repeatedly contact an unrelated listener. The eligibility filter in #8561 does not by itself establish that TLS cancellation closes sockets.
Mnigos commentedon Sep 4, 2026
Looked at why the TLS probe lingers for ~10.45s despite the 1s budget, since that is the part #8561 does not touch. The Effect timeout does fire and
FetchHttpClientdoes pass anAbortSignalto fetch, so the request is aborted at 1s. The stall is inside undici: when a request is aborted while the client is still connecting,resume()just drops it from the queue and the in-flight connect, TLS handshake included, keeps going until the connector's own timeout, which defaults to 10s (connect.js,timeout == null ? 10e3). That matches the trace to within a few tens of ms. So the eligibility filter in #8561 stops the scanner from touching random listeners, but a non-HTTP service on a curated port or spawned from a T3 terminal still gets both probes and still holds the socket for 10s after we gave up. Closing that needs the probe to own its connection, either a dedicated undici Agent with a shortconnect.timeoutfor the scanner, or a raw socket probe we can destroy on cancel, plus the TLS-prefix listener test asked for above.andybergon commentedon Sep 5, 2026
This also appears to interrupt T3?s own Antigravity Google sign-in on Windows, using Nightly
0.0.39-nightly.20260904.1280and managed ACP runtime1.1.1.The sign-in URL appears, but authentication fails within seconds. In the server trace, PortDiscovery sends
GET /without OAuth parameters to the exact callback port. It returns HTTP 200 fromWSGIServer/0.2 CPython/3.10.4; approximately 10 ms after the request starts,RpcClient.authenticatefails withOnboarding failed: The authentication flow did not complete successfully.The authentication request lasted 7.5 seconds, while T3?s configured sign-in timeout is five minutes. The browser subsequently receives connection refused.
This strongly suggests the discovery probe consumes the callback before Google returns. I have not yet verified the flow with scanning disabled.
rippelz commentedon Sep 25, 2026
Confirmed still occurring in T3 Code
0.0.43-nightly.20260925.2251, commit1c1270663880, on Linux / Hyprland / Wayland (September 25, 2026).The user reports freezes across Steam games while T3 is running. During this investigation Rocket League was running under Proton. Inspection of the installed server bundle and T3's existing
server.trace.ndjson, matched against listener ownership fromss -ltnp, confirmed that preview discovery sends unsolicited HTTP and HTTPS probes to Steam, Discord, and the game process itself:The installed
PortDiscoveryimplementation still:lsof -iTCP -sTCP:LISTEN -P -n -F pcnwith a three-second polling delay while retained.http://localhost:<port>andhttps://localhost:<port>candidates for every discovered listener.Representative trace fields (only local service URLs and timings):
Evidence boundary: This independently confirms continued probing of unrelated services on the September 25 nightly. We did not capture game frame times or Steam syscalls, measure socket closure after cancellation, or complete a T3-off comparison. Request durations are not measurements of game freezes. The user's freeze report is consistent with #9526, but this investigation does not independently establish its blocking mechanism.
Please restrict active probes to T3-owned development-server processes or explicitly configured URLs, and provide a discovery off switch. Static Steam/Discord port exclusions alone would miss dynamically allocated Steam and game listeners. The timeout/socket-cleanup regression coverage described in #9526 also remains relevant.
Posted on behalf of the affected user from a local investigation; no full logs, credentials, or private project data are attached.
juliusmarminge commentedon Oct 10, 2026
Note
Grok responding on behalf of Julius.
Fixed by #16687, now merged to main. Automatic browser discovery only probes listeners owned by T3 terminals now, and it skips a port entirely if any listener on it isn't owned by one, so unrelated services like the JxBrowser, Steam, and OAuth callback listeners shouldn't get HTTP or TLS bytes anymore. Explicitly configured preview URLs still work as before. Closing this out. If you still see foreign listeners getting probed on a build that includes #16687, please open a new issue with the trace.
Squashed '.repos/effect-smol/' changes from a43c7e244c..e17ecdbe90