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
+64 -10
View File
@@ -26,26 +26,39 @@ class DaemonClient extends ChangeNotifier {
Socket? _socket;
bool _connected = false;
bool _disposed = false;
bool _started = false;
Timer? _reconnectTimer;
Duration _backoff = const Duration(milliseconds: 200);
int _nextId = 0;
final Map<String, Completer<IpcResponse>> _pending = {};
/// Requests that arrived before the socket was connected park here
/// until the connection comes up (or the wait times out).
final List<Completer<void>> _connectWaiters = [];
/// How long a request will wait for an in-progress connection before
/// giving up with a not-connected error. Covers the startup window
/// where the UI queries before the socket has finished connecting.
static const Duration _connectWait = Duration(seconds: 5);
bool get isConnected => _connected;
Future<void> start() async {
_disposed = false;
_started = true;
await _connect();
}
Future<void> stop() async {
_disposed = true;
_started = false;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
_failPending('client stopped');
_wakeConnectWaiters();
_setConnected(false);
}
@@ -67,6 +80,7 @@ class DaemonClient extends ChangeNotifier {
_failPending('socket path changed');
_setConnected(false);
_disposed = false;
_started = true;
_backoff = const Duration(milliseconds: 200);
await _connect();
}
@@ -74,16 +88,25 @@ class DaemonClient extends ChangeNotifier {
Future<IpcResponse> request(
String cmd, {
Map<String, Object?> args = const {},
}) {
}) async {
if (!_connected || _socket == null) {
return Future.value(IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not connected',
),
));
// A connection attempt is in flight (startup or reconnect) — wait
// for it rather than failing instantly, so queries issued during
// the startup window don't get a spurious not-connected error.
// If the client was never started (or is disposed), fail fast.
if (_started && !_disposed) {
await _awaitConnected(_connectWait);
}
if (!_connected || _socket == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not connected',
),
);
}
}
final id = '${_nextId++}';
final completer = Completer<IpcResponse>();
@@ -93,8 +116,33 @@ class DaemonClient extends ChangeNotifier {
return completer.future;
}
/// Complete when the socket connects, or after [timeout] (whichever
/// first). Returns immediately if already connected.
Future<void> _awaitConnected(Duration timeout) async {
if (_connected) return;
final c = Completer<void>();
_connectWaiters.add(c);
try {
await c.future.timeout(timeout);
} on TimeoutException {
_connectWaiters.remove(c);
}
}
void _wakeConnectWaiters() {
if (_connectWaiters.isEmpty) return;
final waiters = List<Completer<void>>.from(_connectWaiters);
_connectWaiters.clear();
for (final c in waiters) {
if (!c.isCompleted) c.complete();
}
}
Future<void> _connect() async {
if (_disposed) return;
// Already connected? Don't open a second socket. Guards against
// racing connect attempts (e.g. start() arming the reconnect loop
// while swapIpcServer's reconnectAt connects on first boot).
if (_disposed || _connected) return;
try {
final addr = InternetAddress(_socketPath, type: InternetAddressType.unix);
final socket = await Socket.connect(addr, 0);
@@ -173,6 +221,10 @@ class DaemonClient extends ChangeNotifier {
void _setConnected(bool v) {
if (_connected == v) return;
_connected = v;
// Release any requests parked waiting for the connection — on a
// successful connect they proceed to send; this runs before the
// dispose guard so a connect always wakes them.
if (v) _wakeConnectWaiters();
// Skip side-effects (event emit + notifyListeners) after dispose —
// the socket stream's onDone can fire post-dispose and would
// otherwise hit ChangeNotifier's "used after disposed" assert.
@@ -184,10 +236,12 @@ class DaemonClient extends ChangeNotifier {
@override
void dispose() {
_disposed = true;
_started = false;
_reconnectTimer?.cancel();
unawaited(_socket?.close());
_socket = null;
_failPending('client disposed');
_wakeConnectWaiters();
super.dispose();
}
}