route DaemonClient through a DaemonTransport seam (T-331)
The UI's backend client connected straight to the workspace unix socket, hard-coding the local shape. It now talks JSON-lines through a DaemonTransport (new lib/src/ipc/transport.dart, Flutter-free), with LocalSocketTransport reproducing today's connect byte-for-byte — zero behavior change, proven by the untouched client test suite plus new seam tests driving the client over an in-memory transport. This is the slot the SSH-remote backend (T-329/Q-23) plugs into: request correlation, reconnect/backoff, and event forwarding live above the seam and won't change when the endpoint is remote. main.dart's swapIpcServer becomes swapBackend per the same plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import 'package:clide/kernel/kernel.dart';
|
||||
/// A DaemonClient that doesn't actually open a socket. Use in tests
|
||||
/// that need a connected-state observable but not a real daemon.
|
||||
class FakeDaemonClient extends DaemonClient {
|
||||
FakeDaemonClient({required super.log, required super.events}) : super(socketPath: '/dev/null/fake-clide.sock');
|
||||
FakeDaemonClient({required super.log, required super.events}) : super.unixSocket(socketPath: '/dev/null/fake-clide.sock');
|
||||
|
||||
bool _fakeConnected = false;
|
||||
final Map<String, Future<IpcResponse> Function(Map<String, Object?>)> _stubs = {};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/// Tests for `lib/src/ipc/transport.dart` (T-331) — the DaemonTransport
|
||||
/// seam. Runs under plain `dart test` (core suite): no Flutter imports.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/ipc/transport.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('LocalSocketTransport', () {
|
||||
late Directory dir;
|
||||
late String path;
|
||||
late ServerSocket server;
|
||||
|
||||
setUp(() async {
|
||||
dir = await Directory.systemTemp.createTemp('clide-transport-');
|
||||
path = '${dir.path}/sock';
|
||||
server = await ServerSocket.bind(InternetAddress(path, type: InternetAddressType.unix), 0);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close();
|
||||
await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('endpoint reports the socket path', () {
|
||||
expect(LocalSocketTransport(path).endpoint, path);
|
||||
});
|
||||
|
||||
test('open connects; lines round-trip both directions', () async {
|
||||
final accepted = Completer<Socket>();
|
||||
server.listen((s) => accepted.complete(s));
|
||||
|
||||
final conn = await LocalSocketTransport(path).open();
|
||||
final serverSide = await accepted.future;
|
||||
|
||||
// client -> server
|
||||
final serverLines = serverSide.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
final firstLine = serverLines.first;
|
||||
conn.writeLine('{"hello":1}');
|
||||
expect(await firstLine.timeout(const Duration(seconds: 2)), '{"hello":1}');
|
||||
|
||||
// server -> client
|
||||
final clientLine = conn.lines.first;
|
||||
serverSide.writeln('{"world":2}');
|
||||
expect(await clientLine.timeout(const Duration(seconds: 2)), '{"world":2}');
|
||||
|
||||
await conn.close();
|
||||
await serverSide.close();
|
||||
});
|
||||
|
||||
test('open throws when nothing is bound (caller owns retry)', () async {
|
||||
final t = LocalSocketTransport('${dir.path}/no-such.sock');
|
||||
await expectLater(t.open(), throwsA(isA<SocketException>()));
|
||||
});
|
||||
|
||||
test('lines closes when the server drops the connection', () async {
|
||||
final accepted = Completer<Socket>();
|
||||
server.listen((s) => accepted.complete(s));
|
||||
|
||||
final conn = await LocalSocketTransport(path).open();
|
||||
final serverSide = await accepted.future;
|
||||
|
||||
final done = conn.lines.drain<void>();
|
||||
await serverSide.close();
|
||||
await done.timeout(const Duration(seconds: 2));
|
||||
await conn.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -56,13 +56,50 @@ Future<String> _tmpSocket() async {
|
||||
}
|
||||
|
||||
DaemonClient _build(String socketPath, DaemonBus bus) {
|
||||
return DaemonClient(
|
||||
return DaemonClient.unixSocket(
|
||||
socketPath: socketPath,
|
||||
log: Logger(minLevel: LogLevel.error, sinks: const []),
|
||||
events: bus,
|
||||
);
|
||||
}
|
||||
|
||||
/// In-memory transport (T-331): proves DaemonClient runs unmodified over
|
||||
/// any [DaemonTransport], not just the unix socket — the seam the remote
|
||||
/// backend (T-329) slots into.
|
||||
class _MemoryTransport implements DaemonTransport {
|
||||
final toClient = StreamController<String>.broadcast();
|
||||
final fromClient = StreamController<String>.broadcast();
|
||||
int opens = 0;
|
||||
|
||||
@override
|
||||
String get endpoint => 'memory://test';
|
||||
|
||||
@override
|
||||
Future<DaemonConnection> open() async {
|
||||
opens++;
|
||||
return _MemoryConnection(this);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await toClient.close();
|
||||
await fromClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryConnection implements DaemonConnection {
|
||||
_MemoryConnection(this._t);
|
||||
final _MemoryTransport _t;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _t.toClient.stream;
|
||||
|
||||
@override
|
||||
void writeLine(String line) => _t.fromClient.add(line);
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('DaemonClient — happy path', () {
|
||||
test('connect → request → matching response completes', () async {
|
||||
@@ -341,4 +378,63 @@ void main() {
|
||||
expect(resp.ok, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('DaemonClient — transport seam (T-331)', () {
|
||||
test('request/response round-trips over a non-socket transport', () async {
|
||||
final transport = _MemoryTransport();
|
||||
addTearDown(transport.close);
|
||||
final bus = DaemonBus();
|
||||
addTearDown(bus.dispose);
|
||||
final client = DaemonClient(
|
||||
transport: transport,
|
||||
log: Logger(minLevel: LogLevel.error, sinks: const []),
|
||||
events: bus,
|
||||
);
|
||||
addTearDown(client.dispose);
|
||||
|
||||
await client.start();
|
||||
expect(transport.opens, 1);
|
||||
expect(client.isConnected, isTrue);
|
||||
expect(client.socketPath, 'memory://test');
|
||||
|
||||
final lineFuture = transport.fromClient.stream.first;
|
||||
final respFuture = client.request('ping', args: {'n': 1});
|
||||
final line = await lineFuture;
|
||||
final req = IpcMessage.decode(line) as IpcRequest;
|
||||
expect(req.cmd, 'ping');
|
||||
transport.toClient.add(IpcResponse.ok(id: req.id, data: const {'pong': true}).encode());
|
||||
final resp = await respFuture.timeout(const Duration(seconds: 2));
|
||||
expect(resp.ok, isTrue);
|
||||
expect(resp.data['pong'], isTrue);
|
||||
});
|
||||
|
||||
test('reconnectWith swaps from a socket transport to another transport', () async {
|
||||
final path = await _tmpSocket();
|
||||
final daemon = _TestDaemon(path);
|
||||
await daemon.start();
|
||||
addTearDown(daemon.close);
|
||||
|
||||
final bus = DaemonBus();
|
||||
addTearDown(bus.dispose);
|
||||
final client = _build(path, bus);
|
||||
addTearDown(client.dispose);
|
||||
await client.start();
|
||||
await daemon.waitForClient();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(client.isConnected, isTrue);
|
||||
|
||||
final transport = _MemoryTransport();
|
||||
addTearDown(transport.close);
|
||||
await client.reconnectWith(transport);
|
||||
expect(client.isConnected, isTrue);
|
||||
expect(client.socketPath, 'memory://test');
|
||||
|
||||
// Requests now flow over the new transport, not the old socket.
|
||||
final lineFuture = transport.fromClient.stream.first;
|
||||
final respFuture = client.request('over-memory');
|
||||
final req = IpcMessage.decode(await lineFuture) as IpcRequest;
|
||||
transport.toClient.add(IpcResponse.ok(id: req.id, data: const {}).encode());
|
||||
expect((await respFuture).ok, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user