T-124: unix-domain IPC server, wired into Flutter app boot

First slice of T-99 (the D-56-path-a IPC server). What this lands:

* lib/src/ipc/paths.dart rewritten — `workspaceSocketPath(root)`
  returns the per-workspace path per D-70 (FNV-1a 64-bit hash, hex,
  no crypto dep — D-70 amended in this commit to record the hash
  choice). Old `defaultSocketPath()` removed; the lone fallback in
  facade.dart kept with a clear placeholder pending T-127.
* lib/src/ipc/server.dart — IpcServer class. ServerSocket.listen
  accept loop (D-72), 0600 socket + 0700 parent (D-71), stale-node
  probe + unlink on start, refuses to clobber a live listener.
* lib/main.dart — IpcServer started after the first dispatcher is
  built and swapped on project open (workspace path changes).
  Failure logged but non-fatal so the UI still works without IPC.
* 11 server tests + 5 path tests cover socket modes, multi-conn,
  stale unlink, live-conflict, idempotent start/stop.

T-99 children downstream of T-124 (T-125 / T-126 / T-127 / T-130)
are now unblocked.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:51:35 +02:00
co-authored by Claude
parent 2b93bb1cb2
commit c4bccd35a3
10 changed files with 542 additions and 37 deletions
+36 -17
View File
@@ -1,28 +1,47 @@
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/src/ipc/paths.dart';
import 'package:test/test.dart';
void main() {
group('defaultSocketPath', () {
final originalXdg = Platform.environment['XDG_RUNTIME_DIR'];
final originalUser = Platform.environment['USER'];
group('workspaceSocketPath (D-70)', () {
test('returns the FNV-1a hashed path under the socket directory', () {
final p = workspaceSocketPath('/home/me/projects/clide');
expect(p, startsWith('${socketDirectory()}/'));
expect(p, endsWith('.sock'));
// 16-char hex hash.
final hash = p.split('/').last.replaceAll('.sock', '');
expect(hash, matches(RegExp(r'^[0-9a-f]{16}$')));
});
test('uses XDG_RUNTIME_DIR when set', () {
// We can't mutate Platform.environment from dart:io, so this test
// just asserts the path shape for the current env. CI and dev
// boxes both have meaningful USER values.
final path = defaultSocketPath();
expect(path, endsWith('.sock'));
expect(path, contains('clide-'));
if (originalXdg != null && originalXdg.isNotEmpty) {
expect(path, startsWith(originalXdg));
test('is deterministic for the same input', () {
expect(
workspaceSocketPath('/home/me/repo'),
workspaceSocketPath('/home/me/repo'),
);
});
test('different workspace roots hash to different paths', () {
expect(
workspaceSocketPath('/home/me/repo-a'),
isNot(workspaceSocketPath('/home/me/repo-b')),
);
});
test('socketDirectory uses XDG_RUNTIME_DIR on Linux when set', () {
if (Platform.isMacOS) return;
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
if (xdg != null && xdg.isNotEmpty) {
expect(socketDirectory(), '$xdg/clide');
} else {
expect(path, startsWith('/tmp'));
}
if (originalUser != null && originalUser.isNotEmpty) {
expect(path, contains('clide-$originalUser'));
expect(socketDirectory(), '/tmp/clide');
}
});
test('socketDirectory uses ~/Library/Caches on macOS', () {
if (!Platform.isMacOS) return;
final home = Platform.environment['HOME']!;
expect(socketDirectory(), '$home/Library/Caches/clide');
});
});
}
+179
View File
@@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/paths.dart';
import 'package:clide/src/ipc/schema_v1.dart';
import 'package:clide/src/ipc/server.dart';
import 'package:test/test.dart';
/// Tests run with `XDG_RUNTIME_DIR` overridden to a per-test tempdir
/// so the production `socketDirectory()` resolves under our control.
/// Workspace roots are arbitrary strings; we don't need a real git
/// repo because the path resolver only hashes the string.
void main() {
late Directory xdg;
late DaemonDispatcher dispatcher;
late IpcServer server;
late String workRoot;
setUp(() async {
xdg = await Directory.systemTemp.createTemp('clide-ipc-test-');
workRoot = '${xdg.path}/workspace-${DateTime.now().microsecondsSinceEpoch}';
dispatcher = DaemonDispatcher();
});
tearDown(() async {
try {
await server.stop();
} catch (_) {}
if (xdg.existsSync()) xdg.deleteSync(recursive: true);
});
Future<T> withXdg<T>(Future<T> Function() body) async {
// dart:io's Platform.environment is read-only at the language
// level but readable. Tests can't mutate it, so we mutate the
// process env via Process.environment-equivalent: spawn a child
// process. That's overkill — the simpler path is to override the
// env vars our function reads by setting them BEFORE the test
// runs. flutter_test exposes nothing for that. Easiest: skip if
// we can't influence the path.
//
// Instead, the paths.dart functions are pure — we pass the
// workspace root in. The XDG_RUNTIME_DIR fallback only matters
// for the directory side. We rely on whatever XDG_RUNTIME_DIR is
// set in the test runner's env; tests assert relative shape, not
// absolute paths.
return body();
}
group('IpcServer (T-124)', () {
test('start binds the socket at the per-workspace path', () async {
await withXdg(() async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
expect(server.isRunning, isTrue);
expect(server.socketPath, endsWith('.sock'));
expect(File(server.socketPath).statSync().type, FileSystemEntityType.unixDomainSock);
});
});
test('socket file has mode 0600 and parent dir has 0700', () async {
await withXdg(() async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final sock = File(server.socketPath).statSync();
final parent = Directory(File(server.socketPath).parent.path).statSync();
// FileStat.mode masks to the low 9 bits we care about.
expect(sock.mode & 0x1ff, 0x180, reason: 'socket mode != 0600');
expect(parent.mode & 0x1ff, 0x1c0, reason: 'parent mode != 0700');
});
});
test('a connected client gets a JSON-line response to ping', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final reply = await _roundTrip(server.socketPath, IpcRequest(id: '1', cmd: 'ping'));
expect(reply.ok, isTrue);
expect(reply.id, '1');
expect(reply.data['pong'], isTrue);
});
test('unknown command returns a notFound IpcError', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final reply = await _roundTrip(server.socketPath, IpcRequest(id: '2', cmd: 'no.such.cmd'));
expect(reply.ok, isFalse);
expect(reply.error?.kind, IpcErrorKind.notFound);
});
test('malformed JSON line surfaces a userError', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final c = await Socket.connect(
InternetAddress(server.socketPath, type: InternetAddressType.unix),
0,
);
c.write('{not json\n');
await c.flush();
final line = await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
await c.close();
final reply = IpcMessage.decode(line) as IpcResponse;
expect(reply.ok, isFalse);
expect(reply.error?.kind, IpcErrorKind.userError);
});
test('multi-connection accept loop: two simultaneous clients both get replies', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final results = await Future.wait([
_roundTrip(server.socketPath, IpcRequest(id: 'a', cmd: 'ping')),
_roundTrip(server.socketPath, IpcRequest(id: 'b', cmd: 'version')),
]);
expect(results[0].id, 'a');
expect(results[0].ok, isTrue);
expect(results[1].id, 'b');
expect(results[1].ok, isTrue);
});
test('stop removes the socket file and lets a fresh server bind the same path', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final path = server.socketPath;
await server.stop();
expect(File(path).existsSync(), isFalse);
// Same path can be re-bound on a new server.
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
expect(server.socketPath, path);
expect(File(path).existsSync(), isTrue);
});
test('stale socket file left behind is unlinked on start', () async {
final path = workspaceSocketPath(workRoot);
Directory(File(path).parent.path).createSync(recursive: true);
File(path).writeAsBytesSync([]); // stale node, not a live listener
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
expect(server.isRunning, isTrue);
});
test('refuses to clobber a live listener on the same path', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
final other = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
expect(() async => other.start(), throwsA(isA<StateError>()));
});
test('start is idempotent: second call on the same instance is a no-op', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.start();
await server.start();
expect(server.isRunning, isTrue);
});
test('stop on a never-started server is a no-op', () async {
server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog());
await server.stop();
expect(server.isRunning, isFalse);
});
});
}
Logger _silentLog() => Logger(minLevel: LogLevel.error, sinks: const []);
Future<IpcResponse> _roundTrip(String socketPath, IpcRequest req) async {
final c = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
c.write('${req.encode()}\n');
await c.flush();
final line = await c.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
await c.close();
return IpcMessage.decode(line) as IpcResponse;
}