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
+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.