You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Host service opens two pty-daemon connections when terminals open at the same time #9452
Two concurrent first calls to getDaemonClient() each open their own connection. One client is never disposed, and onDaemonDisconnect runs once per client.
Expected: concurrent callers share one connection.
Steps to reproduce
Start the host service.
Open two terminals at the same moment before any daemon connection exists.
See two daemon connections; disposeDaemonClient() leaves one connected.
Logs, screenshots, or recordings
Found by model checking every order of events, then reproduced as a failing test against the real code. A fix with that regression test is ready.
Implementation notes
Files
packages/host-service/src/terminal/daemon-client-singleton.tsgetDaemonClient(): connecting is assigned only after await ptyDaemonSocketPath(), so both callers pass the if (connecting) check.
Approach
Assign the in-flight promise before the first await (connecting ??= ...).
The reported race is real. In getDaemonClient(), the in-flight promise is stored only after an await, so two calls made at the same time can each open their own pty-daemon connection.
Summary
When two callers ask for the daemon client at the same time and no connection exists yet, each one opens its own DaemonClient. Only the last one to finish is stored, so the other client is never disposed. It also keeps its own disconnect handler, which means onDaemonDisconnect listeners run once per client. Because disposeDaemonClient() only knows about the stored client, the other connection stays open after shutdown or test teardown. The expected behaviour is that every concurrent caller shares one in-flight connection.
packages/host-service/src/terminal/daemon-client-singleton.ts:105-115: disposeDaemonClient() only sees cached and connecting
Callers that can run at the same time: packages/host-service/src/terminal/terminal.ts:959, :3125 (terminal create/attach), :3570, and packages/host-service/src/terminal/reaper/reaper.ts
if(cached?.isConnected)returncached;if(connecting)returnconnecting;// :67constsockPath=awaitptyDaemonSocketPath();// :68 <- yields here
...
connecting=client.connect()...// :87 <- assigned too late
connecting is assigned only after await ptyDaemonSocketPath(). In production that call waits on waitForDaemonReady(...), which takes real time. Even with the SUPERSET_PTY_DAEMON_SOCKET test override, the await still gives up control for a microtask. So two callers that start in the same tick both pass the if (connecting) check and both build a client.
What follows:
Both clients register client.onDisconnect (:70). The cached === client guard (:75) protects only cached; the listener loop (:76-85) runs for both clients. If the orphaned connection drops, the terminal.ts:734 handler closes every terminal WS socket and starts sweepAgentBindingsAfterDaemonLoss, even though the live client is still healthy.
cached is overwritten by whichever connect() finishes last, and the other client leaks.
A smaller, related problem: .finally(() => { connecting = null; }) (:98-100) clears the field without checking that it still holds the same promise. If disposeDaemonClient() runs during a connect and a new connect starts, the old finally can clear the new in-flight promise.
Reproduction
As a node:test / bun:test test, using the existing test override so the supervisor is not needed:
Start a stub pty-daemon socket server (or reuse the setup from terminal.adoption.node-test.ts) and set process.env.SUPERSET_PTY_DAEMON_SOCKET to its path.
Assert a === b. This fails on main today. You can also count accepted connections on the stub server: it sees 2.
Optionally register onDaemonDisconnect(spy), close the server-side sockets, and assert that spy was called once. Today it is called twice.
await disposeDaemonClient(), then assert that the stub server has no open connections. Today one is left open.
Manual check: start the host service and open two terminals at the same moment before any daemon connection exists. The daemon sees two client connections.
Fix direction
Assign the in-flight promise synchronously, before the first await, so the socket-path lookup is inside the shared promise:
Here connectDaemonClient() holds the current body: ptyDaemonSocketPath(), new DaemonClient, onDisconnect, connect(), and dispose-on-failure. The connecting === attempt check also fixes the smaller problem above.
With this change, a failed ptyDaemonSocketPath() still resets connecting through finally. The next caller then retries, as it does today. One regression test, the Promise.all identity and connection-count check above, should be enough.
Activity
github-actions commentedon Nov 7, 2026
The reported race is real. In
getDaemonClient(), the in-flight promise is stored only after anawait, so two calls made at the same time can each open their own pty-daemon connection.Summary
When two callers ask for the daemon client at the same time and no connection exists yet, each one opens its own
DaemonClient. Only the last one to finish is stored, so the other client is never disposed. It also keeps its own disconnect handler, which meansonDaemonDisconnectlisteners run once per client. BecausedisposeDaemonClient()only knows about the stored client, the other connection stays open after shutdown or test teardown. The expected behaviour is that every concurrent caller shares one in-flight connection.Affected code
packages/host-service/src/terminal/daemon-client-singleton.ts:65-102:getDaemonClient()packages/host-service/src/terminal/daemon-client-singleton.ts:105-115:disposeDaemonClient()only seescachedandconnectingpackages/host-service/src/terminal/terminal.ts:959,:3125(terminal create/attach),:3570, andpackages/host-service/src/terminal/reaper/reaper.tspackages/host-service/src/terminal/terminal.ts:734Root cause
connectingis assigned only afterawait ptyDaemonSocketPath(). In production that call waits onwaitForDaemonReady(...), which takes real time. Even with theSUPERSET_PTY_DAEMON_SOCKETtest override, theawaitstill gives up control for a microtask. So two callers that start in the same tick both pass theif (connecting)check and both build a client.What follows:
client.onDisconnect(:70). Thecached === clientguard (:75) protects onlycached; the listener loop (:76-85) runs for both clients. If the orphaned connection drops, theterminal.ts:734handler closes every terminal WS socket and startssweepAgentBindingsAfterDaemonLoss, even though the live client is still healthy.cachedis overwritten by whicheverconnect()finishes last, and the other client leaks..finally(() => { connecting = null; })(:98-100) clears the field without checking that it still holds the same promise. IfdisposeDaemonClient()runs during a connect and a new connect starts, the oldfinallycan clear the new in-flight promise.Reproduction
As a
node:test/bun:testtest, using the existing test override so the supervisor is not needed:terminal.adoption.node-test.ts) and setprocess.env.SUPERSET_PTY_DAEMON_SOCKETto its path.const [a, b] = await Promise.all([getDaemonClient(), getDaemonClient()]);a === b. This fails onmaintoday. You can also count accepted connections on the stub server: it sees 2.onDaemonDisconnect(spy), close the server-side sockets, and assert thatspywas called once. Today it is called twice.await disposeDaemonClient(), then assert that the stub server has no open connections. Today one is left open.Manual check: start the host service and open two terminals at the same moment before any daemon connection exists. The daemon sees two client connections.
Fix direction
Assign the in-flight promise synchronously, before the first
await, so the socket-path lookup is inside the shared promise:Here
connectDaemonClient()holds the current body:ptyDaemonSocketPath(),new DaemonClient,onDisconnect,connect(), and dispose-on-failure. Theconnecting === attemptcheck also fixes the smaller problem above.With this change, a failed
ptyDaemonSocketPath()still resetsconnectingthroughfinally. The next caller then retries, as it does today. One regression test, thePromise.allidentity and connection-count check above, should be enough.