tear down the previous workspace's services on project switch (T-367)

buildDispatcher composed a fresh PaneRegistry, FilesService,
SearchService, and EditorRegistry per workspace, but their shutdown()
methods had zero callers — every project switch left the old set's
file watcher emitting into the new workspace's bus and its PTYs
alive. The dispatcher now pairs with a teardown closure that the
serialized swap invokes after the old server stops; the same-path
reuse fast-path drops the unused new set without teardown since its
services are inert until a command starts them. SearchService gains
the shutdown() it was missing (cancels in-flight searches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:44:24 +02:00
co-authored by Claude Fable 5
parent ba6ab51118
commit 664a8da72e
7 changed files with 97 additions and 11 deletions
@@ -3951,3 +3951,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:34:31', '2026-06-11 22:34:31', '2026-06-11 22:34:31', NULL, '254ae09f42b4da439a04d59d675d8f42', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:34:51', '2026-06-11 22:34:51', '2026-06-11 22:34:51', NULL, '14a7d3d6d6c71cae423ce12c71b6c540', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:39:04', '2026-06-11 22:39:04', '2026-06-11 22:39:04', NULL, 'ad1c483fe26d5e417b2e1cd8986114f3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:39:32', '2026-06-11 22:39:32', '2026-06-11 22:39:32', NULL, '9983d6ca4c8b8b49724fb5fe42b541cd', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:44:05', '2026-06-11 22:44:05', '2026-06-11 22:44:05', NULL, '7846c244fccd1cd444f6715ed7472549', 2) ON CONFLICT(hash) DO NOTHING;
+6
View File
@@ -4019,3 +4019,9 @@ Fix: route editor.open/editor.save through path_safety like files.*. Tests: trav
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'editor.open/editor.save skip path confinement entirely', 'lib/src/editor/registry.dart:215-219 returns absolute paths verbatim — no .. normalization, no path_safety call — an unconfined read AND write primitive over IPC while files.read is carefully guarded.
Fix: route editor.open/editor.save through path_safety like files.*. Tests: traversal and absolute-escape attempts rejected for both verbs. Longer-term the confinement should move to the dispatch layer (see the systemic ticket filed with this epic).', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:26', '2026-06-11 22:39:04', NULL, '50c42cb7af7715e9ea6417212be0ebdc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:39:32', NULL, 'c0fdac5acbf4332f8d2a632edbc94085', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus.
Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:44:05', NULL, 'ab0fad47ab342a3578b9fec64dd886e7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+5
View File
@@ -68,6 +68,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
the scope the user typed; replace now uses the same glob filtering as
search. (T-364)
- **Switching projects releases the previous workspace's services.** The old
file watcher, pane PTYs, in-flight searches, and editor buffers were left
alive on every project switch, with stale watcher events leaking into the
new workspace. (T-367)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow,
+37 -9
View File
@@ -136,7 +136,13 @@ Future<void> main() async {
// below doSwapIpcServer for why. (T-352)
Future<void> swapChain = Future<void>.value();
Future<void> doSwapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
// Teardown of the service set behind the currently-served dispatcher
// (pane PTYs, file watcher, in-flight searches, editor buffers). Swapped
// alongside the IPC server so a project switch can't leak the previous
// workspace's watchers into the new one's bus (T-367).
Future<void> Function()? activeSubsystemTeardown;
Future<void> doSwapIpcServer(DaemonDispatcher dispatcher, Future<void> Function() teardown, 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
@@ -148,6 +154,9 @@ Future<void> main() async {
final live = ipcServer;
if (live != null && live.isRunning && live.workspaceRoot == workRoot.path) {
ipcLog.info('ipc', 'already serving ${workRoot.path}; reusing the live server');
// The freshly built dispatcher is dropped unused — its services are
// inert (watchers/PTYs only start via dispatched commands), so there
// is nothing to tear down. The live server keeps its own set.
// Idempotent — a no-op when the client is already connected here.
await ipcClient?.reconnectAt(live.socketPath);
return;
@@ -163,6 +172,16 @@ Future<void> main() async {
} catch (e) {
ipcLog.warn('mcp', 'stop failed during swap: $e');
}
// The old server is down — release the previous workspace's services
// before the new set takes over (T-367). The shutdown() methods are
// idempotent, so a failed swap retried later is safe.
try {
await activeSubsystemTeardown?.call();
} catch (e, st) {
ipcLog.warn('ipc', 'subsystem teardown failed during swap: $e');
ipcLog.debug('ipc', '$st');
}
activeSubsystemTeardown = teardown;
final server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot.path, log: ipcLog, events: daemonBus);
ipcServer = server;
try {
@@ -197,14 +216,14 @@ Future<void> main() async {
// load (stale/global pql.db) yet working after a manual refresh. Chaining
// every swap makes them apply in call order; the repo swap is issued last
// and therefore wins. (T-352)
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) {
final next = swapChain.then((_) => doSwapIpcServer(dispatcher, workRoot));
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Future<void> Function() teardown, Directory workRoot) {
final next = swapChain.then((_) => doSwapIpcServer(dispatcher, teardown, workRoot));
// A failed swap must not break the chain for the next one.
swapChain = next.catchError((Object _) {});
return next;
}
DaemonDispatcher buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot, LayoutArrangement arrangement, PanelRegistry panels) {
(DaemonDispatcher, Future<void> Function()) buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot, LayoutArrangement arrangement, PanelRegistry panels) {
final dispatcher = DaemonDispatcher();
final eventSink = _BusEventSink(events);
final paneRegistry = PaneRegistry(events: eventSink);
@@ -297,7 +316,16 @@ Future<void> main() async {
};
});
registerArgvUnwrap(dispatcher);
return dispatcher;
// Paired teardown for this workspace's stateful services — the swap
// calls it when this dispatcher stops being served (T-367).
Future<void> teardown() async {
await paneRegistry.shutdown();
await filesService.shutdown();
await searchService.shutdown();
await editorRegistry.shutdown();
}
return (dispatcher, teardown);
}
final services = await KernelServices.boot(
@@ -314,7 +342,7 @@ Future<void> main() async {
kernelArrangement = arrangement;
kernelPanels = panels;
final workRoot = startupWorkRoot;
final dispatcher = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
// Build the client at the workspace's socket path. The
// server is started below (swapIpcServer) which the
// client will then auto-connect to via its reconnect
@@ -329,7 +357,7 @@ Future<void> main() async {
// the connect immediate. _connect's already-connected guard
// keeps these two paths from opening a second socket.
unawaited(client.start());
unawaited(swapIpcServer(dispatcher, workRoot));
unawaited(swapIpcServer(dispatcher, teardown, workRoot));
return client;
},
onProjectOpen: kIsWeb
@@ -339,8 +367,8 @@ Future<void> main() async {
final arrangement = kernelArrangement;
final panels = kernelPanels;
if (bus == null || arrangement == null || panels == null) return;
final dispatcher = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
await swapIpcServer(dispatcher, Directory(path));
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
await swapIpcServer(dispatcher, teardown, Directory(path));
},
);
// Expose the reader nav to the `clide status` snapshot (T-221). Boot
+9
View File
@@ -52,6 +52,15 @@ class SearchService {
_active.remove(id)?.cancel();
}
/// Cancel every in-flight search. Called when the workspace service
/// set is torn down on project switch (T-367).
Future<void> shutdown() async {
for (final c in _active.values) {
c.cancel();
}
_active.clear();
}
/// Compute (preview) or perform (apply) a search-and-replace.
///
/// Preview returns per-file before/after edits without touching disk.
+23 -1
View File
@@ -11,6 +11,7 @@ void main() {
late Directory sandbox;
late DaemonDispatcher dispatcher;
late FilesService files;
late RecordingEventSink sink;
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-files-test-');
@@ -22,7 +23,7 @@ void main() {
Directory('${sandbox.path}/.dart_tool').createSync();
File('${sandbox.path}/.dart_tool/hidden').writeAsStringSync('x');
final sink = RecordingEventSink();
sink = RecordingEventSink();
files = FilesService(
root: sandbox,
events: sink,
@@ -229,6 +230,27 @@ void main() {
// assertion is covered in test/files/watcher_test.dart.
});
test('shutdown stops the watcher delivering into the bus (T-367)', () async {
// Project switch tears the old workspace's services down; a leaked
// watcher would keep emitting the OLD workspace's events into the
// new one's bus.
final ack = await call('files.watch', const {});
expect(ack.ok, isTrue);
await Future<void>.delayed(const Duration(milliseconds: 50));
await files.shutdown();
final before = sink.ofKind('files.changed').length;
await File('${sandbox.path}/after-shutdown.txt').writeAsString('x');
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(sink.ofKind('files.changed').length, before, reason: 'no events after shutdown');
});
test('shutdown is idempotent and re-watch works after it', () async {
await files.shutdown();
await files.shutdown();
final r = await call('files.watch', const {});
expect(r.ok, isTrue);
});
test('FilesService.atCwd walks parent dirs looking for .git, falls back to CWD if none', () async {
final deepNoGit = await Directory.systemTemp.createTemp('clide-no-git-');
addTearDown(() => deepNoGit.deleteSync(recursive: true));
+15 -1
View File
@@ -11,13 +11,14 @@ void main() {
late Directory dir;
late RecordingEventSink sink;
late DaemonDispatcher d;
late SearchService service;
setUp(() async {
dir = await Directory.systemTemp.createTemp('clide-search-cmd-');
File('${dir.path}/a.dart').writeAsStringSync('final answer = 42;\n');
File('${dir.path}/b.dart').writeAsStringSync('// no hits here\n');
sink = RecordingEventSink();
final service = SearchService(root: dir, ignore: IgnoreSet([]), events: sink, useIsolates: false);
service = SearchService(root: dir, ignore: IgnoreSet([]), events: sink, useIsolates: false);
d = DaemonDispatcher();
registerSearchCommands(d, service);
});
@@ -78,6 +79,19 @@ void main() {
expect(r.data['cancelled'], 'search-0');
});
test('shutdown cancels in-flight searches and is idempotent (T-367)', () async {
// Subscribe before dispatching — the done event is broadcast.
final doneFuture = sink.stream.firstWhere((e) => e.kind == 'search.done');
final r = await call('search.grep', const {'pattern': 'answer'});
expect(r.ok, isTrue);
await service.shutdown();
await service.shutdown();
// The search still terminates (cancelled or already complete,
// depending on timing) — shutdown must not wedge the stream.
final done = await doneFuture;
expect(done.data['searchId'], r.data['searchId']);
});
test('search.replace preview reports edits without touching disk', () async {
final r = await call('search.replace', const {'pattern': 'answer', 'replacement': 'result'});
expect(r.ok, isTrue);