Skip to content

[Bug]: Preview discovery probes Unity test ports and causes unrelated tests to fail on macOS #26279

Closed

Description

@CreepyGnome

Area

apps/server

Impact

Major degradation or frequent failure. High severity: background preview discovery interferes with another application's protocol and makes automated test results unreliable.

Version or commit

T3 Code Nightly 0.1.4-nightly.20261027.3796
Commit: b88da91

Environment

  • macOS 27.0.1
  • Unity 6000.6.6f1
  • Unity tests launched through a Claude agent in T3 Code

Steps to reproduce

  1. Open a Unity project in T3 Code Nightly.
  2. Have a Claude agent launch Unity tests.
  3. Unity opens local ports for its test/editor connection. Ports observed in these runs included 55504 and 34999.
  4. T3's preview scanner discovers the Unity process through its inherited T3CODE_THREAD_ID and sends HTTP and HTTPS probes to its listeners.
  5. Unity logs a protocol error. The test framework fails whichever test is running when the error occurs.

The failure is timing dependent. Three fast check runs each failed a different Play Mode test.

Expected behavior

Preview discovery must not send unsolicited HTTP or TLS traffic to Unity's test/editor connections or other services that use a different protocol.

Starting a process through T3 does not establish that its listening ports serve HTTP.

Actual behavior

Across three fast check runs:

  • Edit Mode: 678 of 678 passed.
  • Play Mode: 88 of 89 passed.
  • A different Play Mode test failed each time because Unity logged an error after receiving a web probe.

This makes unrelated tests appear broken and causes repeated verification failures.

The scanner runs every three seconds and caches web probe results for 15 seconds. There is no scanner disable setting or environment flag that we could find in this build.

Supporting evidence

A controlled check against the running installed app used two temporary TCP listeners outside T3's integrated terminal. During a 24 second observation:

  • Listener with T3CODE_THREAD_ID: received two HTTP requests and two TLS probes.
  • Listener without T3CODE_THREAD_ID: received zero connections.

This confirms the ownership tag controls whether these listeners are probed. It does not by itself prove the Unity workaround is reliable over longer test runs.

Relevant source:
https://github.com/pingdotgg/t3code/blob/b88da91397c2/apps/server/src/preview/PortScanner.ts#L692

Likely regression

PR #22542, merged October 27, adds T3CODE_THREAD_ID to Claude processes and expands discovery to their children:

#22542

Unity inherits that variable when launched by the agent, making its non-HTTP listeners eligible for web probes.

PR #16687 previously restricted probing to T3 terminal processes and configured URLs:
#16687

Related issue #9526 describes earlier interference with Steam's local IPC protocol:
#9526

Workaround

Prefix the command that starts Unity with env -u T3CODE_THREAD_ID.

Use the agent's command tool or an external terminal. T3's integrated terminal also tracks child processes separately, so removing the variable there is insufficient. An already running tagged Unity process must be relaunched.

The failures have not recurred since applying this workaround. They were intermittent, so more observation is needed before calling the workaround fully verified.

Requested fix

Prevent automatic web probes from reaching non-HTTP services, including processes launched by agents. Provide a persistent switch to disable automatic port discovery so affected users can keep working without changing their test commands.

Activity

  1. matheustimbo commented on Oct 29, 2026

    @matheustimbo
    Contributor

    Same root cause hits Flutter: flutter test fails to load test files while T3 Code is open (macOS, nightly 0.1.4-nightly.20261027.3796 and 20261028.3824/3841).

    What happens. flutter_tools binds a loopback HttpServer per test file and treats the first incoming request as the flutter_tester WebSocket upgrade (flutter_tester_device.dart, remoteChannel: await _server!.first then WebSocketTransformer.upgrade). When the preview scanner's GET / (user-agent node, from t3 serve) arrives first, the load fails with:

    Failed to load "…_test.dart": Unable to connect to flutter_tester process: WebSocketException: Invalid WebSocket upgrade request
    

    On a ~1,000-file suite this failed 10–104 files per run, on an untouched main too, and retrying a single file failed 3 times in a row. It stops as soon as no T3 window is open. Flutter 3.44.2 and 3.47.0 have the same code.

    Repro without Flutter. Bind a loopback HTTP server on a random port while the T3 app is open; within ~3 s it gets GET / from ::1, UA node, and lsof shows the client socket owned by the t3 serve PID:

    final s = await HttpServer.bind(InternetAddress.loopbackIPv6, 0);
    s.listen((r) { print('${r.method} ${r.uri} ua=${r.headers.value('user-agent')}'); r.response.close(); });

    Here the listener is not even started from a T3 terminal: PortDiscovery probes every loopback listener lsof -sTCP:LISTEN returns while any client subscribes to subscribeDiscoveredLocalServers (the sidebar thread rows do).

    Ask. Only send HTTP to ports T3 started for a dev server or that the user configured; for anything else use a plain TCP connect check, or let users turn discovery off. #18377 asked for the off switch too.

  2. MAXOUXAX commented on Oct 29, 2026

    @MAXOUXAX

    Another case, with a heavier failure mode than a failed test: a Next.js dev server on Cloudflare (OpenNext / wrangler).

    Under next dev, workerd opens an internal port on 127.0.0.1 that isn't meant for HTTP traffic. It inherits T3CODE_THREAD_ID, so the preview scanner sends it a GET /. Because of a Miniflare bug (cloudflare/workers-sdk#16558), that request loops back into the same port forever. Within seconds the Mac runs out of local ports, and everything else (gh, test runners, agents) fails with EADDRNOTAVAIL. Since the scan repeats every few seconds, it keeps happening as long as the dev server runs.

    The real bug is on Miniflare's side, but the probe is what keeps setting it off. Ideas that would have avoided it:

    • only probe ports that the process printed as a URL (http://localhost:3000), not every listener;
    • or a setting to turn discovery off, or to exclude processes or ports.

    My workaround: start the dev server with env -u T3CODE_THREAD_ID, and add http://localhost:3000 as a configured URL so the preview still works.

  3. notgiorgi commented on Oct 30, 2026

    @notgiorgi

    Another case: gcloud auth login fails when run from a T3 terminal or by a T3 agent.

    ERROR: There was a problem with web authentication. Try running again with --no-browser.
    ERROR: (gcloud.auth.login) (missing_code) Missing code parameter in response.
    

    Why: gcloud starts a one-shot callback server on localhost:8085 (googlecloudsdk/core/credentials/flow.py). It calls handle_request() once, takes the first connection that sends bytes as the OAuth redirect, then shuts down. The preview scanner's GET / (user-agent node) arrives within about 1s with no code=, so gcloud fails. Google's real redirect then hits a closed port.

    Repro (macOS, Google Cloud SDK 589.0.0): I put a logger on :8085 and opened a gcloud-style auth URL.

    11:19:22  GET /  user-agent: node, b3/traceparent headers   <- T3 Code (Beta), lsof: T3\x20Cod 127.0.0.1:54753->127.0.0.1:8085
    11:19:32  GET /?state=...&code=4/0A...                      <- real browser redirect, 10s later
    

    The same listener started with env -u T3CODE_THREAD_ID got no probes in 15s.

    A HEAD probe would not fix this. gcloud, Flutter and Unity all treat the first request of any kind as the real one, and the TLS ClientHello probe breaks them too.

    Workaround: run gcloud auth login in a terminal outside T3 Code, such as Ghostty or iTerm, so the listener isn't attributed to T3. Or use gcloud auth login --no-launch-browser, which skips the local callback server.

    This comment was written by Claude Opus 5.5.

  4. jamipouchi commented on Oct 30, 2026

    @jamipouchi

    Also seen on the stable 0.1.5 release (macOS), with a Rust workspace whose agent-launched test runs bind loopback fakes (a raw TCP 9100 printer, HTTP service fakes). Each new listener got GET / HTTP/1.1 plus a TLS ClientHello every 15–30 s, which the fakes counted as clients: printer tests read the request line as a print job, and property tests counting what a fake received failed. A listener detached from T3's process tree is probed too, as long as it inherited T3CODE_THREAD_ID.

    Workaround that holds for us: blank the variable for everything cargo starts, in .cargo/config.toml:

    [env]
    T3CODE_THREAD_ID = { value = "", force = true }

    A way to turn discovery off, or limiting it to ports the user opens a preview for, would avoid this.

  5. mwolson commented on Oct 30, 2026

    @mwolson
    Contributor

    Another case: Gradle test workers on Linux, T3 Code Nightly 0.1.4 (Oct 30), started by a Claude agent.

    Gradle opens a loopback port and waits for its test worker JVM to connect. The port lives about 0.15 seconds. If the scanner's GET / lands first, Gradle takes it as the worker and the real worker is refused:

    org.gradle.internal.remote.internal.MessageIOException: Could not read message from '/127.0.0.1:37540'.
    org.gradle.internal.remote.internal.ConnectException: Could not connect to server [... port:46205, addresses:[/127.0.0.1]]
    > Process 'Gradle Test Executor 7' finished with non-zero exit value 1
    

    No test fails, so it reads as a flaky build. It hit 1 of 13 builds here. Probing the port as soon as it opens failed 6 of 6.

    env -u T3CODE_THREAD_ID can miss this one. The daemon opens the port, and it keeps the launch environment of whichever thread started it, so its ports stay attributed to that thread for its whole life, including for later builds from a clean client. Removing the variable from the client still left the daemon's command port probed in 6 of 6 builds. With a daemon that was started without the variable, 9 of 9 builds had no errors at all. A different -Xmx is enough to force a new daemon:

    env -u T3CODE_THREAD_ID ./gradlew test "-Dorg.gradle.jvmargs=-Xmx4097m"

    Waiting for a port to survive two scans would fix Gradle but not this issue. A stand-in probe that waited 3 seconds passed 16 of 16 builds, because the worker port is gone by then. gcloud's callback, Unity and Redis all stay open longer than that. dotnet test has the same shape as Gradle: a deliberate GET on its 0.3 second port aborted the run after 90 seconds, though it passed 30 of 30 under the real scanner.

    So #26289 (stop preview probes) looks like the right direction to me, since nothing else covers these. One thing to weigh there: with no probe, the list shows everything the agent left running. When I checked, every listener attributed to a thread on this machine was a Gradle or Kotlin daemon (5 java rows, each with Stop), under a card that says it lists web servers. Hiding very young listeners would at least keep short-lived worker ports from appearing, and a wrong guess now only costs a row.


    Sent by Mike's agent (Claude Opus 5.5 in T3 Code, Claude Code harness)

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