T-127: replace InProcessClient with socket loopback
test / unit + widget + golden + a11y (push) Failing after 2m16s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 29s

Fourth slice of T-99. The UI's DaemonClient now talks to its own
IpcServer through the same per-workspace Unix socket the C `clide`
client uses — one transport, one wire contract, no second path
through the dispatch tree.

Changes:
* lib/kernel/src/ipc/in_process.dart deleted. Nothing imports it.
* DaemonClient.socketPath becomes mutable + new `reconnectAt(path)`
  method swaps an active client onto a different socket without
  restart. Project switch in main.dart uses it — the dispatcher
  + IpcServer are rebuilt for the new workspace, and the client
  reconnects to the new path.
* main.dart's daemonClientFactory now builds a real DaemonClient
  pointed at workspaceSocketPath(workRoot); swapIpcServer kicks
  off server.start() then client.start() in sequence.
* lib/test_app.dart's pane.spawn smoke test uses dispatcher.dispatch
  directly instead of InProcessClient — same coverage, no dead-end
  import.
* DaemonClient client_test gets a reconnectAt round-trip test.

T-128 (delete IsolateClient + Backend + backend_entry.dart) unblocked.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-19 12:03:44 +02:00
co-authored by Claude
parent 88ed4bf391
commit 70c293b163
8 changed files with 130 additions and 48 deletions
+29 -5
View File
@@ -11,13 +11,15 @@ import 'package:flutter/foundation.dart';
class DaemonClient extends ChangeNotifier {
DaemonClient({
required this.socketPath,
required String socketPath,
required Logger log,
required DaemonBus events,
}) : _log = log,
}) : _socketPath = socketPath,
_log = log,
_events = events;
final String socketPath;
String _socketPath;
String get socketPath => _socketPath;
final Logger _log;
final DaemonBus _events;
@@ -47,6 +49,28 @@ class DaemonClient extends ChangeNotifier {
_setConnected(false);
}
/// Point the client at a different socket path and reconnect.
/// Used on project switch — the workspace-derived socket path
/// (D-70) changes when the user opens a different project, so the
/// client follows. Cancels the reconnect timer, closes the live
/// socket (failing in-flight requests with `disconnect`), updates
/// the path, and re-arms the connect loop. Idempotent if the new
/// path equals the current one.
Future<void> reconnectAt(String newPath) async {
if (newPath == _socketPath && _connected) return;
_socketPath = newPath;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
_failPending('socket path changed');
_setConnected(false);
_disposed = false;
_backoff = const Duration(milliseconds: 200);
await _connect();
}
Future<IpcResponse> request(
String cmd, {
Map<String, Object?> args = const {},
@@ -72,12 +96,12 @@ class DaemonClient extends ChangeNotifier {
Future<void> _connect() async {
if (_disposed) return;
try {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
final addr = InternetAddress(_socketPath, type: InternetAddressType.unix);
final socket = await Socket.connect(addr, 0);
_socket = socket;
_backoff = const Duration(milliseconds: 200);
_setConnected(true);
_log.info('ipc', 'connected to $socketPath');
_log.info('ipc', 'connected to $_socketPath');
socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
_handleLine,
onDone: _handleDisconnect,
-29
View File
@@ -1,29 +0,0 @@
import 'package:clide/clide.dart';
import 'package:clide/kernel/src/ipc/client.dart';
class InProcessClient extends DaemonClient {
InProcessClient({
required super.log,
required super.events,
required this.dispatcher,
}) : super(socketPath: '');
DaemonDispatcher dispatcher;
int _nextReqId = 0;
@override
bool get isConnected => true;
@override
Future<void> start() async {}
@override
Future<void> stop() async {}
@override
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
final id = '${_nextReqId++}';
final req = IpcRequest(id: id, cmd: cmd, args: args);
return dispatcher.dispatch(req);
}
}
+30 -10
View File
@@ -29,7 +29,6 @@ import 'package:clide/builtin/welcome/welcome.dart';
import 'dart:io' show Directory, Platform;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/kernel/src/ipc/in_process.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
@@ -40,6 +39,7 @@ import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart';
import 'package:clide/src/panes/registry.dart';
@@ -78,11 +78,13 @@ Future<void> main() async {
toolchain.applyResolved(resolveToolchainPaths());
}
InProcessClient? ipcClient;
DaemonClient? ipcClient;
DaemonBus? daemonBus;
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
// workspace; restarted when the active project switches because the
// socket path is workspace-derived.
// socket path is workspace-derived. The local DaemonClient connects
// back to it over the socket so all IPC — including from UI widgets
// in the same process — goes through the wire contract (T-127).
IpcServer? ipcServer;
final ipcLog = Logger();
@@ -100,6 +102,14 @@ Future<void> main() async {
await server.start();
} catch (e, st) {
ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st);
return;
}
// Point the in-process DaemonClient at the new socket. On first
// boot (no client yet) the daemonClientFactory below kicks it
// off; on project switch we just reconnect to the new path.
final client = ipcClient;
if (client != null) {
await client.reconnectAt(server.socketPath);
}
}
@@ -133,18 +143,28 @@ Future<void> main() async {
daemonBus = events;
final workRoot = FilesService.atCwd(events: _BusEventSink(events)).root;
final dispatcher = buildDispatcher(events, toolchain, workRoot);
ipcClient = InProcessClient(log: log, events: events, dispatcher: dispatcher);
// Fire-and-forget: bring up the IPC socket server alongside.
// Failure is logged, not fatal — the UI still works.
unawaited(swapIpcServer(dispatcher, workRoot));
return ipcClient!;
// 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
// loop. autoStartDaemonClient:false means we own the
// lifecycle here.
final client = DaemonClient(
socketPath: workspaceSocketPath(workRoot.path),
log: log,
events: events,
);
ipcClient = client;
unawaited(() async {
await swapIpcServer(dispatcher, workRoot);
await client.start();
}());
return client;
},
onProjectOpen: kIsWeb
? null
: (path) async {
if (ipcClient == null || daemonBus == null) return;
if (daemonBus == null) return;
final dispatcher = buildDispatcher(daemonBus!, toolchain, Directory(path));
ipcClient!.dispatcher = dispatcher;
await swapIpcServer(dispatcher, Directory(path));
},
);
+9 -4
View File
@@ -29,7 +29,6 @@ import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'kernel/kernel.dart';
import 'src/pty/ffi/libc.dart' as libc;
import 'kernel/src/ipc/in_process.dart';
import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart';
import 'src/panes/event_sink.dart';
@@ -325,19 +324,25 @@ class _ClideTestAppState extends State<ClideTestApp> {
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
_say('--- terminal ---');
// Test PTY via InProcessClient — same path as the real app.
// Test PTY via the dispatcher directly — skip the socket
// round-trip for the smoke test since it adds setup without
// testing anything new for pane.spawn. The real app's path is
// covered by the IPC server + client tests under test/ipc/.
await _testAsync('pane.spawn via IPC', () async {
final dispatcher = DaemonDispatcher();
final bus = DaemonBus();
final eventSink = _TestEventSink(bus);
final paneRegistry = PaneRegistry(events: eventSink);
registerPaneCommands(dispatcher, paneRegistry);
final ipc = InProcessClient(log: Logger(), events: bus, dispatcher: dispatcher);
Future<IpcResponse> dispatch(String cmd, Map<String, Object?> args) {
return dispatcher.dispatch(IpcRequest(id: 'tm-${DateTime.now().microsecondsSinceEpoch}', cmd: cmd, args: args));
}
// Spawn a pane running /bin/echo.
// Use interactive shell — fast-exiting commands lose output on macOS
// because the slave closes before we can read the master.
final spawnResp = await ipc.request('pane.spawn', args: {
final spawnResp = await dispatch('pane.spawn', {
'argv': [tc.shell],
'kind': 'terminal',
});