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
+33 -2
View File
@@ -131,8 +131,8 @@ void main() {
});
group('DaemonClient — error + lifecycle paths', () {
test('request while disconnected returns a not-connected error', () async {
// Don't connect — point at a non-existent socket path.
test('request while disconnected (never started) returns a not-connected error fast', () async {
// Never started → no connect attempt in flight → fail fast (no wait).
final bus = DaemonBus();
addTearDown(bus.dispose);
final client = _build('/tmp/does-not-exist.sock', bus);
@@ -142,6 +142,37 @@ void main() {
expect(resp.error?.message, contains('not connected'));
});
test('a request issued before connect waits, then sends once connected', () async {
// Reproduces the startup race: the UI queries before the socket
// finishes connecting. Started (so a connect is in flight) but no
// server yet — the request must park, not fail, and send once the
// server comes up.
final path = await _tmpSocket();
final bus = DaemonBus();
addTearDown(bus.dispose);
final client = _build(path, bus);
addTearDown(client.dispose);
await client.start(); // no server yet → connect fails, reconnect armed
expect(client.isConnected, isFalse);
final respFuture = client.request('ping'); // parks (does not fail)
// Bring the server up; the reconnect loop connects, waking the request.
final daemon = _TestDaemon(path);
await daemon.start();
addTearDown(daemon.close);
final line = await daemon.lines.first;
expect(line, contains('ping'));
final req = IpcMessage.decode(line) as IpcRequest;
daemon.send(IpcResponse.ok(id: req.id, data: const {'pong': true}).encode());
final resp = await respFuture;
expect(resp.ok, isTrue);
expect(resp.data['pong'], isTrue);
});
test('malformed line is logged and skipped, real lines still work', () async {
final path = await _tmpSocket();
final daemon = _TestDaemon(path);