Skip to content

[Bug]: Preview PortScanner sends HTTP(S) to unrelated listeners and can crash other apps #8407

Closed

Description

@seanpianka

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

  1. Run T3 Code Alpha 0.0.34 on macOS.
  2. In Settings → General → Legacy features, enable Sidebar (legacy). Browser Preview does not need to be open.
  3. Leave T3 Code running with the legacy sidebar mounted. PortDiscovery.pollTick continues approximately every three seconds because the sidebar subscribes to environment-wide discovered servers.
  4. Start a local non-HTTP service that expects a binary handshake. The reproduced service is the private JxBrowser RPC listener created during thinkorswim login.
  5. T3 Code's PortScanner discovers the listener through the system-wide lsof scan and sends GET / first over HTTP and then HTTPS.
  6. 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:
    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:
    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:

    t3code/apps/server/src/ws.ts

    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:
    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:
    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 GET classifier:
    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:
    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

  1. t3dotgg commented on Sep 4, 2026

    @t3dotgg
    Member

    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.

  2. Mnigos commented on Sep 4, 2026

    @Mnigos
    Contributor

    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 FetchHttpClient does pass an AbortSignal to 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 short connect.timeout for the scanner, or a raw socket probe we can destroy on cancel, plus the TLS-prefix listener test asked for above.

  3. andybergon commented on Sep 5, 2026

    @andybergon

    This also appears to interrupt T3?s own Antigravity Google sign-in on Windows, using Nightly 0.0.39-nightly.20260904.1280 and managed ACP runtime 1.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 from WSGIServer/0.2 CPython/3.10.4; approximately 10 ms after the request starts, RpcClient.authenticate fails with Onboarding 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.

  4. rippelz commented on Sep 25, 2026

    @rippelz

    Confirmed still occurring in T3 Code 0.0.43-nightly.20260925.2251, commit 1c1270663880, 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 from ss -ltnp, confirmed that preview discovery sends unsolicited HTTP and HTTPS probes to Steam, Discord, and the game process itself:

    Listener owner Local port Observed requests in the inspected trace Median interval
    Discord 6463 56 HTTP + 56 HTTPS ~17.2 s
    Steam 27036, 38379, 41659, 57343 56 HTTP + 56 HTTPS per port ~17.2 s
    RocketLeague.exe 37207 14 HTTP + 14 HTTPS ~17.4 s

    The installed PortDiscovery implementation still:

    • Runs lsof -iTCP -sTCP:LISTEN -P -n -F pcn with a three-second polling delay while retained.
    • Records terminal ownership as metadata without using it to exclude unrelated listeners.
    • Constructs http://localhost:<port> and https://localhost:<port> candidates for every discovered listener.
    • Uses a 15-second probe cache, one-second per-probe timeout, and concurrency 16.

    Representative trace fields (only local service URLs and timings):

    http.client GET http://localhost:6463/   status=404 durationMs=5.321274
    http.client GET http://localhost:37207/  status=404 durationMs=63.571139
    http.client GET https://localhost:37207/            durationMs=97.918716
    http.client GET http://localhost:27036/             durationMs=999.700283
    http.client GET http://localhost:57343/             durationMs=1000.669686
    http.client GET https://localhost:57343/            durationMs=999.920081
    

    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.

  5. juliusmarminge commented on Oct 10, 2026

    @juliusmarminge
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions