Skip to content

fix(cli): bound the loopback Studio probe so a dropped connection costs 1 s, not 10 - #6466

Merged
jrusso1020 merged 1 commit into
heygen-com:mainfrom
khaphanspace:fix/loopback-studio-probe-bounded
Nov 5, 2026
Merged

jrusso1020 merged 1 commit into
heygen-com:mainfrom
khaphanspace:fix/loopback-studio-probe-bounded

Conversation

@jason-easyai

Copy link
Copy Markdown
Contributor

What

The loopback Studio probe in findPreviewServerForProject connects with node:net under a 1 s bound and destroys the socket before it fetches /api/projects. A host that drops the connection now costs 1 s instead of 10 s.

Why

Some hosts drop a connection to a closed loopback port instead of refusing it: WSL 2 with networkingMode=mirrored, or a firewall in stealth mode. On those hosts fetch("http://127.0.0.1:5190/api/projects") waits for undici's 10 s connect timeout. Every render, check and snapshot looks for a Studio through the history owner, so each of them paid that 10 s.

Adding an AbortSignal.timeout to the fetch is not enough. The fetch rejects on time, but the connect attempt keeps its socket open, and that keeps the process alive for the same 10 s. A net socket that is destroyed when its timeout fires lets the process exit at once.

To reproduce, on WSL 2 with mirrored networking and nothing on port 5190:

curl -m 15 http://127.0.0.1:5190/      # hangs 15 s; http://[::1]:5190/ is refused in 5 ms
node -e 'const t=Date.now();fetch("http://127.0.0.1:5190/",{signal:AbortSignal.timeout(1000)}).catch(()=>{});process.on("exit",()=>console.log(Date.now()-t))'   # prints ~10500

Related work

Also WSL: #5560, #5574, #5591 (renders fall back to software GPU under WSL). Fixes #6465.

How

  • fetchLoopback(url): connects to the URL's host and port with node:net. It rejects and destroys the socket after 1 s, on an error, or once connected. After a successful connect it returns fetch(url).
  • It is now the default fetchImpl of findPreviewServerForProject. The parameter type narrows to (url: string) => Promise<Response>, which is the only way it is called. Callers and tests that inject their own fetch are unchanged.

Test plan

  • Unit tests added: fetchLoopback fetches from a listening loopback server, and rejects without fetching when the port refuses the connection. studioSelectionClient.test.ts: 15/15 pass.
  • Manual testing on WSL 2 (mirrored networking, 12 cores, idle), hyperframes@0.8.205 with this change applied to its dist:
    • render of a 19.6 s composition: 27.8 s → 20.7 s
    • check: 13.0 s → 4.2 s (warm)
    • each command spent 10.5 s in fetch http://127.0.0.1:5190/api/projects, traced through a --require hook
  • oxlint and oxfmt --check are clean on both files. tsc --noEmit in packages/cli is clean after bun run build.
  • Two tests in history.test.ts ("a CLI run waits while another process holds the history…" and "serializes concurrent begin --exclusive claims…") time out at 20 s on this machine with and without this change. They are unrelated to it.
  • Comments follow CONTRIBUTING.md "Comments".

…ts 1 s, not 10

Some hosts drop a connection to a closed loopback port instead of refusing it
(WSL 2 mirrored networking, a firewall in stealth mode). The Vite Studio probe
on 127.0.0.1:5190 then waits for fetch's 10 s connect timeout, and since
every render, check and snapshot looks for a Studio through the history owner,
each of them paid 10 s. Aborting the fetch does not help: the connect attempt
keeps its socket, and the process, alive for the full 10 s.

The default probe now connects with node:net under a 1 s bound and destroys
the socket, then fetches only from a host that accepted.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5a20b67ddd7445109569e65f0a436b3b847256a4.

I checked both IPv4 and IPv6 discovery callers. The preliminary TCP connect is bounded and destroyed on success, timeout or error before fetch starts. This narrows the blackholed-connect case; it does not impose a universal deadline on the later fetch.

vitest studioSelectionClient.test.ts: 15 passed. This bounds the preliminary TCP connect, not response-body or later socket races. Those were already unbounded; no firewall emulation performed.

Need: yes · Size: ok · Reuse: existing implementation seam · Simpler: already minimal · Structure: fine
Risk: medium - socket cleanup during loopback discovery.

Verdict: APPROVE
Reasoning: I checked both IPv4 and IPv6 discovery callers. The preliminary TCP connect is bounded and destroyed on success, timeout or error before fetch starts. This narrows the blackholed-connect case; it does not impose a universal deadline on the later fetch.

Reasoning: I checked both IPv4 and IPv6 discovery callers. The preliminary TCP connect is bounded and destroyed on success, timeout or error before fetch starts. This narrows the blackholed-connect case; it does not impose a universal deadline on the later fetch.

@jrusso1020
jrusso1020 enabled auto-merge November 5, 2026 02:16
@jrusso1020
jrusso1020 added this pull request to the merge queue Nov 5, 2026
Merged via the queue into heygen-com:main with commit 6e8454d Nov 5, 2026
69 of 70 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every render, check and snapshot waits 10 s on WSL 2: the loopback Studio probe has no connect bound

3 participants