fix daemon-not-connected race on startup

The socket-loopback DaemonClient (T-127) raced the UI on first launch:
panels queried before the socket finished connecting and cached a
"daemon not connected" error, and the Claude pane's spawn gate tripped,
leaving an empty terminal. Three fixes in the startup/connection path:

- DaemonClient.request() now waits briefly (5s) for an in-flight
  connection instead of failing instantly, gated on _started so a
  never-started client still fails fast. start() sets _started
  synchronously so the gate is armed before the UI builds.
- swapIpcServer reuses the live server when the opened project matches
  the workspace it already serves, instead of tearing it down — the
  project-open flow fired right as the Claude pane spawned, dropping
  the connection mid-spawn.
- _connect bails if already connected, so start() arming the reconnect
  loop and swapIpcServer's reconnectAt can't open a second socket
  (which had been double-delivering events).

This whole orchestration had no automated coverage — integration tests
stub a FakeDaemonClient. Adds a real wait-then-connect client test.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-22 15:39:59 +02:00
co-authored by Claude
parent 203f8e7681
commit d7c935977e
6 changed files with 363 additions and 16 deletions
+22 -4
View File
@@ -99,6 +99,20 @@ Future<void> main() async {
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
if (kIsWeb) return;
// Already serving this exact workspace? Reuse the live server.
// The startup factory binds the launch CWD, then the project-open
// flow fires for (usually) that same path — tearing the server
// down and rebinding would drop every live connection (the UI's
// DaemonClient, the Claude pane's spawn gate fires right on
// ProjectOpened) for no gain, leaving panes stranded. A genuine
// project switch (different path) falls through and rebinds.
final live = ipcServer;
if (live != null && live.isRunning && live.workspaceRoot == workRoot.path) {
ipcLog.info('ipc', 'already serving ${workRoot.path}; reusing the live server');
// Idempotent — a no-op when the client is already connected here.
await ipcClient?.reconnectAt(live.socketPath);
return;
}
try {
await ipcServer?.stop();
} catch (e, st) {
@@ -188,10 +202,14 @@ Future<void> main() async {
events: events,
);
ipcClient = client;
unawaited(() async {
await swapIpcServer(dispatcher, workRoot);
await client.start();
}());
// start() synchronously marks the client "connecting" (so
// requests issued during the startup window park for the
// socket instead of failing) and arms the reconnect loop.
// swapIpcServer then binds the server and reconnectAt makes
// the connect immediate. _connect's already-connected guard
// keeps these two paths from opening a second socket.
unawaited(client.start());
unawaited(swapIpcServer(dispatcher, workRoot));
return client;
},
onProjectOpen: kIsWeb