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
+119
View File
@@ -1954,3 +1954,122 @@ Next steps:
ProjectManager sticky-startup logic is already covered by test/kernel/src/project_test.dart (14 cases).', NULL, '2026-05-22 06:55:02', '2026-05-22 06:55:02', '2026-05-22 06:55:02', NULL, 'b7c13548cef5d962c7b5b4500ea39b9e', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-122', 'priority', 'low', 'medium', NULL, '2026-05-22 06:55:02', '2026-05-22 06:55:02', '2026-05-22 06:55:02', NULL, 'e7d5dd1b89eded661de7f6d64edea04c', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-89', 'description', 'Today: total line coverage **34.9%** (2984/8549 lines). Per area:
| area | hit / total | % |
|---|---|---|
| `lib/src/terminal/` | 12 / 3167 | **0.4%** |
| `lib/widgets/` | 537 / 1184 | 45.4% |
| `lib/src/pql/` | 45 / 94 | 47.9% |
| `lib/builtin/` | 269 / 550 | 48.9% |
| `lib/kernel/` | 889 / 1691 | 52.6% |
| `lib/src/daemon/` | 320 / 584 | 54.8% |
| `lib/src/pty/` | 130 / 216 | 60.2% |
| `lib/src/git/` | 356 / 518 | 68.7% |
| `lib/src/ipc/` | 110 / 156 | 70.5% |
| `lib/src/files/` | 98 / 133 | 73.7% |
| `lib/extension/` | 41 / 54 | 75.9% |
| `lib/src/editor/` | 107 / 129 | 82.9% |
| `lib/src/panes/` | 70 / 73 | 95.9% |
The dominant hole is `lib/src/terminal/` the terminal emulator code. Header still credits xterm.dart upstream, but per CLAUDE.md ("Own the rendering stack") and the explicit guidance attached to this epic: in-tree code is owned, no carve-outs, same coverage bar.
**End state:**
- Total line coverage 95% (no per-area carve-outs).
- Pre-push gate hard-fails on coverage drops via a committed floor (`coverage/floor.txt`); floor ratchets up only.
- Per D-66 the gate lands at the *current* floor in the first child ticket; subsequent floor bumps come with the test-writing children.
**Acceptance criteria for closing this epic:**
1. `coverage/floor.txt` reads `95` (or higher).
2. `flutter test --coverage --exclude-tags forkpty` produces ≥95% line coverage.
3. The pre-push gate computes coverage and rejects pushes below the floor.
4. No new `// ignore:` / `// ignore_for_file:` / analysis-options excludes were added to dodge the gate. Any suppression added during this campaign needs explicit approval and an inline reason at the suppression site.
**Strategy / child shape:**
The gate plumbing lands first so future test additions are visible as floor bumps. Then per-area sweeps biggest holes first because they move the global number fastest, but each area is its own child ticket with its own scope.
1. **Gate plumbing** (first child) install the ratchet, lock in current floor, expose `make coverage-gate` and wire it into `make push-check`.
2. **`lib/src/terminal/` sweep** (second child, by far the largest) likely splits into sub-children once the area''s structure is mapped (parser, buffer, painter, gesture handling, mouse reporting, escape sequences, cell-grid measurement, etc.).
3. **Per-area sweeps** claim a child ticket when starting an area; close when the area hits 95%. Order by impact: terminal widgets pql builtin kernel daemon pty git ipc files extension editor.
4. **Final ratchet** once total 95%, set floor to 95 and close the epic.
**Out of scope:**
- Branch coverage (lcov data is weak for branch on Dart; line coverage is the contract).
- Integration / E2E coverage (only widget + unit feed lcov).
- `lib/test_app.dart` exclusion: verify in the gate child whether Dart''s coverage tooling already excludes it; if not, decide explicitly rather than carve it out silently.
**Cross-references:** D-66 (this epic''s decision record), D-29 (pre-push gate scope), "Own the rendering stack" guardrail (CLAUDE.md).
', '## OUTCOME (closed 2026-05-18)
Epic met. Final measured line coverage **95.27%** (9551 / 10025), floor `coverage_floor: 95` in `pubspec.yaml`, gate wired into `make push-check`.
Final per-area (vs. opening 34.9% total):
| area | hit / total | % |
|---|---|---|
| `lib/src/terminal/` | 3185 / 3188 | **99.9%** (was 0.4%) |
| `lib/extension/` | 54 / 54 | 100.0% |
| `lib/src/editor/` | 129 / 129 | 100.0% |
| `lib/src/files/` | 139 / 140 | 99.3% |
| `lib/src/pql/` | 91 / 92 | 98.9% |
| `lib/src/panes/` | 75 / 76 | 98.7% |
| `lib/src/git/` | 517 / 531 | 97.4% |
| `lib/src/daemon/` | 616 / 644 | 95.7% |
| `lib/kernel/` | 2122 / 2233 | 95.0% |
| `lib/src/ipc/` | 429 / 452 | 94.9% |
| `lib/widgets/` | 1178 / 1261 | 93.4% |
| `lib/builtin/` | 754 / 875 | 86.2% |
| `lib/src/pty/` | 177 / 262 | 67.6% (forkpty paths excluded from gate) |
Acceptance: (1) floor reads 95 done (2) total >=95% done (3) gate hard-fails below floor done (4) no coverage-dodging suppressions done. The strategy''s tail per-area sweeps (widgets/builtin/pty) didn''t all individually reach 95%, but the contract is *total* line coverage, which is satisfied; remaining per-area headroom can be its own tickets if desired.
---
Today: total line coverage **34.9%** (2984/8549 lines). Per area:
| area | hit / total | % |
|---|---|---|
| `lib/src/terminal/` | 12 / 3167 | **0.4%** |
| `lib/widgets/` | 537 / 1184 | 45.4% |
| `lib/src/pql/` | 45 / 94 | 47.9% |
| `lib/builtin/` | 269 / 550 | 48.9% |
| `lib/kernel/` | 889 / 1691 | 52.6% |
| `lib/src/daemon/` | 320 / 584 | 54.8% |
| `lib/src/pty/` | 130 / 216 | 60.2% |
| `lib/src/git/` | 356 / 518 | 68.7% |
| `lib/src/ipc/` | 110 / 156 | 70.5% |
| `lib/src/files/` | 98 / 133 | 73.7% |
| `lib/extension/` | 41 / 54 | 75.9% |
| `lib/src/editor/` | 107 / 129 | 82.9% |
| `lib/src/panes/` | 70 / 73 | 95.9% |
The dominant hole is `lib/src/terminal/` the terminal emulator code. Header still credits xterm.dart upstream, but per CLAUDE.md ("Own the rendering stack") and the explicit guidance attached to this epic: in-tree code is owned, no carve-outs, same coverage bar.
**End state:**
- Total line coverage 95% (no per-area carve-outs).
- Pre-push gate hard-fails on coverage drops via a committed floor (`coverage/floor.txt`); floor ratchets up only.
- Per D-66 the gate lands at the *current* floor in the first child ticket; subsequent floor bumps come with the test-writing children.
**Acceptance criteria for closing this epic:**
1. `coverage/floor.txt` reads `95` (or higher).
2. `flutter test --coverage --exclude-tags forkpty` produces ≥95% line coverage.
3. The pre-push gate computes coverage and rejects pushes below the floor.
4. No new `// ignore:` / `// ignore_for_file:` / analysis-options excludes were added to dodge the gate. Any suppression added during this campaign needs explicit approval and an inline reason at the suppression site.
**Strategy / child shape:**
The gate plumbing lands first so future test additions are visible as floor bumps. Then per-area sweeps biggest holes first because they move the global number fastest, but each area is its own child ticket with its own scope.
1. **Gate plumbing** (first child) install the ratchet, lock in current floor, expose `make coverage-gate` and wire it into `make push-check`.
2. **`lib/src/terminal/` sweep** (second child, by far the largest) likely splits into sub-children once the area''s structure is mapped (parser, buffer, painter, gesture handling, mouse reporting, escape sequences, cell-grid measurement, etc.).
3. **Per-area sweeps** claim a child ticket when starting an area; close when the area hits 95%. Order by impact: terminal widgets pql builtin kernel daemon pty git ipc files extension editor.
4. **Final ratchet** once total 95%, set floor to 95 and close the epic.
**Out of scope:**
- Branch coverage (lcov data is weak for branch on Dart; line coverage is the contract).
- Integration / E2E coverage (only widget + unit feed lcov).
- `lib/test_app.dart` exclusion: verify in the gate child whether Dart''s coverage tooling already excludes it; if not, decide explicitly rather than carve it out silently.
**Cross-references:** D-66 (this epic''s decision record), D-29 (pre-push gate scope), "Own the rendering stack" guardrail (CLAUDE.md).
', NULL, '2026-05-22 13:37:43', '2026-05-22 13:37:43', '2026-05-22 13:37:43', NULL, 'b83aa45596e35a95e689b922c66c27ef', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-89', 'status', 'in_progress', 'done', NULL, '2026-05-22 13:37:46', '2026-05-22 13:37:46', '2026-05-22 13:37:46', NULL, '5698b8e8cbd5113a84791ae2dad26c6d', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-91', 'status', 'in_progress', 'done', NULL, '2026-05-22 13:37:46', '2026-05-22 13:37:46', '2026-05-22 13:37:46', NULL, 'dad314c05e04d8f17357ac2ea054a9bd', 1) ON CONFLICT(hash) DO NOTHING;
+117
View File
@@ -2279,3 +2279,120 @@ Next steps:
- Once unblocked, this also unblocks lib/builtin/welcome/src/welcome_view.dart coverage (74 uncovered lines, the dominant lib/builtin/ gap toward the T-89 95% target).
ProjectManager sticky-startup logic is already covered by test/kernel/src/project_test.dart (14 cases).', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 09:06:59', '2026-05-22 06:55:02', NULL, 'bcb79420d9e359813ad5f75dfb89cc05', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-91', 'task', 'T-89', 'test sweep: drive lib/src/terminal/ to 95%', 'Second child of T-89. The dominant coverage hole — `lib/src/terminal/` is 12 / 3167 lines (0.4%), about 56% of the global gap.
**Today''s distribution within `lib/src/terminal/src/`:**
| sub-area | hit / total | % | shape |
|---|---|---|---|
| `core/buffer/` | 0 / 581 | 0.0% | pure data structures |
| `core/escape/` | 1 / 473 | 0.2% | pure parser logic |
| `core/input/` | 4 / 292 | 1.4% | key + keytab tables |
| `core/(root)` | 0 / 188 | 0.0% | callback wiring |
| `core/mouse/` | 3 / 51 | 5.9% | encoder logic |
| `ui/` | 3 / 823 | 0.4% | painters + render objects |
| `(root)` | 0 / 471 | 0.0% | `Terminal` + `TerminalView` |
| `utils/` | 1 / 251 | 0.4% | circular buffer etc. |
**Strategy pure logic first, then UI:**
1. **`core/buffer/`** (581 lines, biggest single chunk, all pure Dart) line/segment/cell models, scrollback math, range queries. Unit tests exercise the data structure directly.
2. **`core/escape/`** (473 lines, pure state machine) feed ANSI/VT byte streams in, assert the resulting buffer/cursor state. Reusable corpus from xterm.js / xterm.dart upstream is fair game.
3. **`core/input/`** (292 lines) key + keytab tables; assert mapping from logical key to escape bytes per terminal mode.
4. **`core/mouse/`** (51 lines) encoder for SGR-1006 / X11 mouse protocols. Small, fast.
5. **`utils/`** (251 lines) circular buffer + helpers; trivial unit tests.
6. **`(root)`** (`terminal.dart`, `terminal_view.dart`, 471 lines) orchestration; some unit-testable, some needs widget tests.
7. **`ui/`** (823 lines) painters and render objects; widget + golden tests. Hardest, most pixel-sensitive.
**Acceptance:**
- `lib/src/terminal/` line coverage 95%.
- All tests fast (<5 s for the area''s test files combined; no real PTY shell-out).
- Helper fixtures live in `test/terminal/_helpers/` if they get reused across files.
- Per D-66, no `// ignore:` directives or analysis_options excludes added to dodge coverage.
- `coverage_floor:` in `pubspec.yaml` is bumped in lockstep every commit that adds covering tests bumps the floor by the measured delta (rounded down to the integer below the new percentage).
**Strategy on commit cadence:**
Land tests in batches per sub-area (one commit per `core/buffer/`, `core/escape/`, etc.). Each batch:
- Adds tests under `test/terminal/<sub-area>/`.
- Re-measures coverage, bumps `coverage_floor:` to the new integer (or as high as the gate currently allows).
- Documents in CHANGELOG under "Changed" (test-only changes can also fold into one CHANGELOG entry per area).
**Out of scope:**
- Refactoring `lib/src/terminal/` for testability if a piece is genuinely untestable, surface it (don''t silence it). Code under `lib/` is owned regardless of upstream attribution; refactor to make it testable rather than carving it out.
- Branch coverage (D-66 explicitly excludes).
- Mouse / keyboard *protocol* round-trips against real TUIs (that''s T-74 and integration work).
**Cross-references:** D-66 (coverage gate), T-89 (epic), T-90 (gate plumbing).
', 'done', 'high', NULL, NULL, 'D-66', '2026-05-06 20:49:47', '2026-05-22 13:37:46', NULL, '076081a76e2301e97262f230c5cd5516', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-89', 'epic', NULL, 'drive line coverage from 35% to 95%', '## OUTCOME (closed 2026-05-18)
Epic met. Final measured line coverage **95.27%** (9551 / 10025), floor `coverage_floor: 95` in `pubspec.yaml`, gate wired into `make push-check`.
Final per-area (vs. opening 34.9% total):
| area | hit / total | % |
|---|---|---|
| `lib/src/terminal/` | 3185 / 3188 | **99.9%** (was 0.4%) |
| `lib/extension/` | 54 / 54 | 100.0% |
| `lib/src/editor/` | 129 / 129 | 100.0% |
| `lib/src/files/` | 139 / 140 | 99.3% |
| `lib/src/pql/` | 91 / 92 | 98.9% |
| `lib/src/panes/` | 75 / 76 | 98.7% |
| `lib/src/git/` | 517 / 531 | 97.4% |
| `lib/src/daemon/` | 616 / 644 | 95.7% |
| `lib/kernel/` | 2122 / 2233 | 95.0% |
| `lib/src/ipc/` | 429 / 452 | 94.9% |
| `lib/widgets/` | 1178 / 1261 | 93.4% |
| `lib/builtin/` | 754 / 875 | 86.2% |
| `lib/src/pty/` | 177 / 262 | 67.6% (forkpty paths excluded from gate) |
Acceptance: (1) floor reads 95 done (2) total >=95% done (3) gate hard-fails below floor done (4) no coverage-dodging suppressions done. The strategy''s tail per-area sweeps (widgets/builtin/pty) didn''t all individually reach 95%, but the contract is *total* line coverage, which is satisfied; remaining per-area headroom can be its own tickets if desired.
---
Today: total line coverage **34.9%** (2984/8549 lines). Per area:
| area | hit / total | % |
|---|---|---|
| `lib/src/terminal/` | 12 / 3167 | **0.4%** |
| `lib/widgets/` | 537 / 1184 | 45.4% |
| `lib/src/pql/` | 45 / 94 | 47.9% |
| `lib/builtin/` | 269 / 550 | 48.9% |
| `lib/kernel/` | 889 / 1691 | 52.6% |
| `lib/src/daemon/` | 320 / 584 | 54.8% |
| `lib/src/pty/` | 130 / 216 | 60.2% |
| `lib/src/git/` | 356 / 518 | 68.7% |
| `lib/src/ipc/` | 110 / 156 | 70.5% |
| `lib/src/files/` | 98 / 133 | 73.7% |
| `lib/extension/` | 41 / 54 | 75.9% |
| `lib/src/editor/` | 107 / 129 | 82.9% |
| `lib/src/panes/` | 70 / 73 | 95.9% |
The dominant hole is `lib/src/terminal/` the terminal emulator code. Header still credits xterm.dart upstream, but per CLAUDE.md ("Own the rendering stack") and the explicit guidance attached to this epic: in-tree code is owned, no carve-outs, same coverage bar.
**End state:**
- Total line coverage 95% (no per-area carve-outs).
- Pre-push gate hard-fails on coverage drops via a committed floor (`coverage/floor.txt`); floor ratchets up only.
- Per D-66 the gate lands at the *current* floor in the first child ticket; subsequent floor bumps come with the test-writing children.
**Acceptance criteria for closing this epic:**
1. `coverage/floor.txt` reads `95` (or higher).
2. `flutter test --coverage --exclude-tags forkpty` produces ≥95% line coverage.
3. The pre-push gate computes coverage and rejects pushes below the floor.
4. No new `// ignore:` / `// ignore_for_file:` / analysis-options excludes were added to dodge the gate. Any suppression added during this campaign needs explicit approval and an inline reason at the suppression site.
**Strategy / child shape:**
The gate plumbing lands first so future test additions are visible as floor bumps. Then per-area sweeps biggest holes first because they move the global number fastest, but each area is its own child ticket with its own scope.
1. **Gate plumbing** (first child) install the ratchet, lock in current floor, expose `make coverage-gate` and wire it into `make push-check`.
2. **`lib/src/terminal/` sweep** (second child, by far the largest) likely splits into sub-children once the area''s structure is mapped (parser, buffer, painter, gesture handling, mouse reporting, escape sequences, cell-grid measurement, etc.).
3. **Per-area sweeps** claim a child ticket when starting an area; close when the area hits 95%. Order by impact: terminal widgets pql builtin kernel daemon pty git ipc files extension editor.
4. **Final ratchet** once total 95%, set floor to 95 and close the epic.
**Out of scope:**
- Branch coverage (lcov data is weak for branch on Dart; line coverage is the contract).
- Integration / E2E coverage (only widget + unit feed lcov).
- `lib/test_app.dart` exclusion: verify in the gate child whether Dart''s coverage tooling already excludes it; if not, decide explicitly rather than carve it out silently.
**Cross-references:** D-66 (this epic''s decision record), D-29 (pre-push gate scope), "Own the rendering stack" guardrail (CLAUDE.md).
', 'done', 'high', NULL, NULL, 'D-66', '2026-05-06 20:36:42', '2026-05-22 13:37:46', NULL, '294d10b6817279f2c76e147f3066db50', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+8
View File
@@ -92,6 +92,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- Daemon not connected on startup — the socket-loopback `DaemonClient`
raced the UI: panels queried before the socket finished connecting
and cached a "daemon not connected" error, and the Claude pane's
spawn gate tripped. Requests now wait briefly for the in-flight
connection instead of failing instantly, the server is no longer
needlessly restarted when the opened project matches the launch
workspace, and the client connects once instead of twice.
### Security
## [2.1.0] — 2026-05-18
+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();
}
}
+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
+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);