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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user