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:
+36
-17
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user