diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 1b7baf7a..10ad7c1c 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -1852,3 +1852,5 @@ Open a per-workspace unix-domain socket on Flutter app boot. Accept JSON-lines p 6. No client yet — that lands in T-126. Source: T-99 sketch. Coordinates with: T-127 (InProcessClient swap), T-130 (MCP).', NULL, '2026-05-18 12:42:56', '2026-05-18 12:42:56', '2026-05-18 12:42:56', NULL, 'abb7a039f73d430cdba594b2a4ac381a', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 12:45:10', '2026-05-18 12:45:10', '2026-05-18 12:45:10', NULL, 'aa1b70849399f4caee043d40e48d1edf', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'status', 'in_progress', 'done', NULL, '2026-05-18 12:51:08', '2026-05-18 12:51:08', '2026-05-18 12:51:08', NULL, 'b928ce09ae0b7dbb77bf93f8c0593657', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 76d648a9..1c63ae56 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2093,3 +2093,21 @@ Open a per-workspace unix-domain socket on Flutter app boot. Accept JSON-lines p 6. No client yet — that lands in T-126. Source: T-99 sketch. Coordinates with: T-127 (InProcessClient swap), T-130 (MCP).', 'backlog', 'high', NULL, NULL, NULL, '2026-05-18 11:58:47', '2026-05-18 12:42:56', NULL, 'e90d4b8786af0a9baa2b39c19a31540a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'task', 'T-99', 'unix-domain IPC server, wired into Flutter app boot', 'First slice of T-99(a). Foundation for the rest. + +Open a per-workspace unix-domain socket on Flutter app boot. Accept JSON-lines per the existing IpcRequest envelope. Route each request through the existing DaemonDispatcher (already wired in main.dart via daemonClientFactory). Tear down on app shutdown. + +**Architectural commitments (read these first):** +- D-70 — socket path is `$XDG_RUNTIME_DIR/clide/.sock` on Linux, `$HOME/Library/Caches/clide/.sock` on macOS. No env override. Workspace root = git toplevel. +- D-71 — socket file is `0600`, parent dir is `0700`. No token auth at this layer. +- D-72 — multi-connection accept loop, serial dispatch on the main isolate. Per-handler isolate offload as needed; not the IPC layer''s concern. + +**Acceptance:** +1. lib/src/ipc/server.dart exists; binds an AF_UNIX socket at the D-70 path; creates the parent dir with the D-71 perms. +2. App boot starts the server; app shutdown closes the socket file cleanly and removes it. +3. `socat - UNIX-CONNECT:$SOCK <<< ''{"command":"git.status"}''` returns a JSON-line response. Tests use a synthetic socket fixture (tempdir + XDG_RUNTIME_DIR override at the env level). +4. Multi-connection accept loop — concurrent socat invocations interleave at the I/O level but serialise through DaemonDispatcher (per D-72) without failing each other. +5. Stale socket on boot (left over from a crashed clide) is detected and unlinked before binding — same `live-daemon probe` pattern already used elsewhere in the codebase. +6. No client yet — that lands in T-126. + +Source: T-99 sketch. Coordinates with: T-127 (InProcessClient swap), T-130 (MCP).', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:58:47', '2026-05-18 12:51:08', NULL, 'eaa0d45789ecd577e81dc07ef476e31c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e6df5a..898089d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Unix-domain IPC socket server in the Flutter app (T-99 / T-124). + Per-workspace path (D-70: `$XDG_RUNTIME_DIR/clide/.sock` on + Linux, `~/Library/Caches/clide/.sock` on macOS). 0600 socket + + 0700 parent (D-71). Multi-connection accept loop with serial + dispatch through `DaemonDispatcher` (D-72). Foundation for the C + `clide` client (T-126) and MCP (T-130). No client yet — testable + via `socat - UNIX-CONNECT:$SOCK`. - Startup project picker — clide now opens to the welcome screen by default instead of auto-opening the last project. A per-row "always open this project on launch" checkbox in welcome's RECENT diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 2d00129c..b1fcbc60 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -257,8 +257,8 @@ Core, rendering, IPC, kernel, panel manager. ### D-70: IPC socket path is per-workspace, deterministic - **Date:** 2026-05-18 -- **Decision:** The Unix-domain IPC socket served by the Flutter app (per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server) / [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary)) lives at `$XDG_RUNTIME_DIR/clide/.sock` on Linux and `$HOME/Library/Caches/clide/.sock` on macOS. The workspace root is the git toplevel (the same path the Flutter app resolved on boot). No env override. The C client (T-126) and any other consumer resolves its target socket by walking CWD up to the git toplevel and computing the same hash. -- **Rationale:** "Repo-is-the-workspace" (CLAUDE.md guardrail) means clide instances are per-repo, so the socket must be too — a per-user-global socket would force one running clide per user and break the multi-repo workflow. The same hash on both sides ensures the shell client + the running app always agree without configuration. No env override because the deterministic path is the contract; the only reason to override is a test fixture, and tests can set `XDG_RUNTIME_DIR` to a tempdir directly. Aligns with [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)'s tmux-socket-per-repo convention so users see one consistent pattern. +- **Decision:** The Unix-domain IPC socket served by the Flutter app (per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server) / [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary)) lives at `$XDG_RUNTIME_DIR/clide/.sock` on Linux and `$HOME/Library/Caches/clide/.sock` on macOS. The workspace root is the git toplevel (the same path the Flutter app resolved on boot). The hash is **FNV-1a 64-bit, lower-case hex (16 chars)** — deterministic, dependency-free (no `package:crypto`), matches the existing `session_naming.dart` `_hash` shape so users see one hashing pattern across clide's process boundaries. No env override. The C client (T-126) and any other consumer resolves its target socket by walking CWD up to the git toplevel and computing the same hash. +- **Rationale:** "Repo-is-the-workspace" (CLAUDE.md guardrail) means clide instances are per-repo, so the socket must be too — a per-user-global socket would force one running clide per user and break the multi-repo workflow. The same hash on both sides ensures the shell client + the running app always agree without configuration. No env override because the deterministic path is the contract; the only reason to override is a test fixture, and tests can set `XDG_RUNTIME_DIR` to a tempdir directly. FNV-1a over a crypto hash: collision resistance isn't a security need (workspace paths are user-supplied; the path is `0600`-readable only by that user anyway); 64 bits is overkill for the cardinality (a user with 65k workspaces would still see negligible birthday collisions). Aligns with [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)'s tmux-socket-per-repo convention so users see one consistent pattern. - **Cost:** The 16-char hex prefix means socket paths aren't human-readable at a glance — `ls $XDG_RUNTIME_DIR/clide/` won't tell you which one is which repo. Acceptable; the C client never asks the user to type the path, and debugging can use a sibling `.path` file next to each socket if it becomes painful. - **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [D-68](#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary), `lib/kernel/src/files.dart` (workspace root resolution). - **Raised by:** 2026-05-18 — T-99 design pass; locked in before T-124 starts so the server + client agree on path strategy. diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 48a1ae5a..17dd6567 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:clide/clide.dart'; import 'package:clide/kernel/src/clipboard.dart'; import 'package:clide/kernel/src/commands/keybindings.dart'; import 'package:clide/kernel/src/keymap/keymap_service.dart'; @@ -167,7 +166,14 @@ class KernelServices { (daemonClientFactory != null ? daemonClientFactory(log, events) : DaemonClient( - socketPath: socketPath ?? defaultSocketPath(), + // Legacy socket-client fallback — kept until T-127 + // replaces it with the in-process socket loopback. + // Today nothing in production hits this branch + // (main.dart and the test harness pass an explicit + // daemonClientFactory). If a caller does land here + // without `autoStartDaemonClient: false`, the + // placeholder path makes the failure mode obvious. + socketPath: socketPath ?? '/dev/null/clide-legacy.sock', log: log, events: events, )); diff --git a/lib/main.dart b/lib/main.dart index 89b404ce..9aebb0b7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:clide/app.dart'; import 'package:clide/test_app.dart'; import 'package:clide/builtin/canvas/canvas.dart'; @@ -37,6 +39,7 @@ import 'package:clide/src/daemon/pql_commands.dart'; import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/git/client.dart'; import 'package:clide/src/ipc/envelope.dart'; +import 'package:clide/src/ipc/server.dart'; import 'package:clide/src/panes/event_sink.dart'; import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/pql/client.dart'; @@ -76,6 +79,28 @@ Future main() async { InProcessClient? 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. + IpcServer? ipcServer; + final ipcLog = Logger(); + + Future swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async { + if (kIsWeb) return; + try { + await ipcServer?.stop(); + } catch (e, st) { + ipcLog.warn('ipc', 'stop failed during swap: $e'); + ipcLog.debug('ipc', '$st'); + } + final server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot.path, log: ipcLog); + ipcServer = server; + try { + await server.start(); + } catch (e, st) { + ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st); + } + } DaemonDispatcher buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot) { final dispatcher = DaemonDispatcher(); @@ -107,13 +132,18 @@ Future main() async { 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!; }, onProjectOpen: kIsWeb ? null : (path) async { if (ipcClient == null || daemonBus == null) return; - ipcClient!.dispatcher = buildDispatcher(daemonBus!, toolchain, Directory(path)); + final dispatcher = buildDispatcher(daemonBus!, toolchain, Directory(path)); + ipcClient!.dispatcher = dispatcher; + await swapIpcServer(dispatcher, Directory(path)); }, ); diff --git a/lib/src/ipc/paths.dart b/lib/src/ipc/paths.dart index ee5cca50..7e4ee05b 100644 --- a/lib/src/ipc/paths.dart +++ b/lib/src/ipc/paths.dart @@ -1,21 +1,54 @@ import 'dart:io'; -/// Resolve the daemon unix-socket path. +/// Resolve the per-workspace Unix-domain socket path served by the +/// running clide app. Per D-70: /// -/// Precedence (highest first): -/// 1. `CLIDE_SOCKET_PATH` — explicit override. Used by tests that -/// run multiple daemons in parallel and by power users who want -/// their own layout. -/// 2. `$XDG_RUNTIME_DIR/clide-.sock` — Linux default; the -/// per-user tmpfs lives exactly for this kind of short-lived -/// socket and is auto-cleaned on logout. -/// 3. `/tmp/clide-.sock` — fallback for environments without -/// `XDG_RUNTIME_DIR`. -String defaultSocketPath() { - final override = Platform.environment['CLIDE_SOCKET_PATH']; - if (override != null && override.isNotEmpty) return override; +/// Linux: `$XDG_RUNTIME_DIR/clide/.sock` +/// macOS: `$HOME/Library/Caches/clide/.sock` +/// +/// The C `clide` client and any other consumer derive the same path +/// from the same workspace root, so server + client always agree +/// without configuration. +String workspaceSocketPath(String workspaceRoot) { + final dir = socketDirectory(); + return '$dir/${_hash(workspaceRoot)}.sock'; +} + +/// Parent directory that holds every per-workspace socket for this +/// user. Created with `0700` on bind (see D-71). Exposed separately +/// so the server can prepare/perm-fix the directory before binding. +String socketDirectory() { + if (Platform.isMacOS) { + final home = Platform.environment['HOME'] ?? '/tmp'; + return '$home/Library/Caches/clide'; + } final xdg = Platform.environment['XDG_RUNTIME_DIR']; - final user = Platform.environment['USER'] ?? 'anon'; final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp'; - return '$base/clide-$user.sock'; + return '$base/clide'; +} + +/// FNV-1a 64-bit, lower-case hex, fixed 16 chars. Matches the shape +/// used by `lib/builtin/claude/src/session_naming.dart#_hash`. Not a +/// cryptographic hash — D-70 explains why one isn't needed here. +String _hash(String s) { + // 0xcbf29ce484222325 as two 32-bit halves to dodge JS-precision + // issues if this file ever runs under the web target. + var hiHi = 0xcbf2, hiLo = 0x9ce4; + var loHi = 0x8422, loLo = 0x2325; + const primeHiHi = 0x0000, primeHiLo = 0x0100; + const primeLoHi = 0x0000, primeLoLo = 0x01b3; + for (var i = 0; i < s.length; i++) { + loLo ^= s.codeUnitAt(i) & 0xffff; + // 64-bit multiply, hand-rolled across four 16-bit limbs. + final r0 = loLo * primeLoLo; + final r1 = (loLo * primeLoHi) + (loHi * primeLoLo) + (r0 >> 16); + final r2 = (loLo * primeHiLo) + (loHi * primeLoHi) + (hiLo * primeLoLo) + (r1 >> 16); + final r3 = (loLo * primeHiHi) + (loHi * primeHiLo) + (hiLo * primeLoHi) + (hiHi * primeLoLo) + (r2 >> 16); + loLo = r0 & 0xffff; + loHi = r1 & 0xffff; + hiLo = r2 & 0xffff; + hiHi = r3 & 0xffff; + } + String hex4(int v) => v.toRadixString(16).padLeft(4, '0'); + return '${hex4(hiHi)}${hex4(hiLo)}${hex4(loHi)}${hex4(loLo)}'; } diff --git a/lib/src/ipc/server.dart b/lib/src/ipc/server.dart new file mode 100644 index 00000000..f5165ed4 --- /dev/null +++ b/lib/src/ipc/server.dart @@ -0,0 +1,211 @@ +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'; + +/// Unix-domain IPC server for the running Flutter app. +/// +/// First slice of T-99 (D-56 path a). One server per workspace — +/// the socket path is derived from the workspace root per D-70. File +/// perms gate access per D-71 (`0600` socket, `0700` parent). The +/// server's accept loop is multi-connection; dispatch through the +/// supplied [DaemonDispatcher] is serial on the main isolate per +/// D-72. Per-handler isolate offload is the dispatcher / handler's +/// concern, not this layer's. +class IpcServer { + IpcServer({required this.dispatcher, required this.workspaceRoot, required this.log}); + + final DaemonDispatcher dispatcher; + final String workspaceRoot; + final Logger log; + + ServerSocket? _socket; + String? _socketPath; + final List _clients = []; + StreamSubscription? _accepts; + + String get socketPath => _socketPath ?? workspaceSocketPath(workspaceRoot); + bool get isRunning => _socket != null; + + /// Bind the socket and start accepting connections. Idempotent — + /// a second [start] on the same instance is a no-op. + /// + /// Stale sockets left from a crashed previous clide are detected + /// and unlinked before binding. If a *live* clide is already + /// listening on the path the bind throws — the caller is the + /// stale-vs-live arbiter (per D-72 there's one server per + /// workspace; a colliding live process means a real conflict). + Future start() async { + if (isRunning) return; + final path = workspaceSocketPath(workspaceRoot); + await _prepareParentDir(path); + await _unlinkStale(path); + final socket = await ServerSocket.bind( + InternetAddress(path, type: InternetAddressType.unix), + 0, + ); + try { + await _chmod(path, 0x180); // 0o600 + } catch (e, st) { + // chmod failure is fatal — D-71 says perms are the gate. + await socket.close(); + log.error('ipc', 'chmod 0600 failed on $path', error: e, stackTrace: st); + rethrow; + } + _socket = socket; + _socketPath = path; + _accepts = socket.listen(_onClient, onError: (Object e, StackTrace st) { + log.error('ipc', 'accept loop error', error: e, stackTrace: st); + }); + log.info('ipc', 'IPC server listening at $path'); + } + + /// Close the listening socket, kill any in-flight client + /// connections, and remove the socket file from disk. + Future stop() async { + final s = _socket; + final path = _socketPath; + if (s == null) return; + _socket = null; + _socketPath = null; + await _accepts?.cancel(); + _accepts = null; + for (final c in List.from(_clients)) { + try { + await c.close(); + } catch (_) {} + } + _clients.clear(); + await s.close(); + if (path != null) { + try { + final f = File(path); + if (f.existsSync()) f.deleteSync(); + } catch (e) { + log.warn('ipc', 'failed to unlink $path: $e'); + } + } + } + + void _onClient(Socket client) { + _clients.add(client); + final buffer = StringBuffer(); + late StreamSubscription> sub; + sub = client.listen( + (chunk) async { + buffer.write(utf8.decode(chunk, allowMalformed: true)); + var idx = buffer.toString().indexOf('\n'); + while (idx >= 0) { + final raw = buffer.toString().substring(0, idx); + // Trim consumed bytes by rebuilding the buffer with the + // tail — StringBuffer can't slice in place. + final tail = buffer.toString().substring(idx + 1); + buffer.clear(); + buffer.write(tail); + await _handleLine(client, raw); + idx = buffer.toString().indexOf('\n'); + } + }, + onError: (Object e, StackTrace st) { + log.warn('ipc', 'client read error: $e'); + }, + onDone: () { + _clients.remove(client); + sub.cancel(); + }, + cancelOnError: true, + ); + } + + Future _handleLine(Socket client, String line) async { + final trimmed = line.trim(); + if (trimmed.isEmpty) return; + IpcResponse response; + try { + final msg = IpcMessage.decode(trimmed); + if (msg is! IpcRequest) { + response = IpcResponse.err( + id: '', + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'expected request, got ${msg.runtimeType}', + ), + ); + } else { + response = await dispatcher.dispatch(msg); + } + } on FormatException catch (e) { + response = IpcResponse.err( + id: '', + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'malformed request: ${e.message}', + ), + ); + } catch (e, st) { + log.error('ipc', 'dispatch threw', error: e, stackTrace: st); + response = IpcResponse.err( + id: '', + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'internal error: $e', + ), + ); + } + try { + client.write('${response.encode()}\n'); + await client.flush(); + } catch (e) { + log.warn('ipc', 'client write failed: $e'); + } + } + + Future _prepareParentDir(String socketPath) async { + final dir = Directory(File(socketPath).parent.path); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + try { + await _chmod(dir.path, 0x1c0); // 0o700 + } catch (e) { + log.warn('ipc', 'chmod 0700 on ${dir.path} failed: $e'); + } + } + + Future _unlinkStale(String path) async { + final f = File(path); + if (!f.existsSync()) return; + // Probe: try connecting. If something answers, refuse to bind. + try { + final test = await Socket.connect( + InternetAddress(path, type: InternetAddressType.unix), + 0, + ).timeout(const Duration(milliseconds: 200)); + await test.close(); + throw StateError('another clide IPC server is already listening on $path'); + } on SocketException { + // No live listener — safe to unlink the stale node. + f.deleteSync(); + } on TimeoutException { + throw StateError('socket $path exists and is unresponsive — refusing to clobber'); + } + } + + /// `chmod` via `chmod(1)` because dart:io doesn't expose the + /// syscall on unix. Cheap; only runs at start/stop. + Future _chmod(String path, int modeBits) async { + final octal = modeBits.toRadixString(8).padLeft(3, '0'); + final r = await Process.run('chmod', [octal, path]); + if (r.exitCode != 0) { + throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode); + } + } +} diff --git a/test/ipc/paths_test.dart b/test/ipc/paths_test.dart index edde133f..1d0c3e47 100644 --- a/test/ipc/paths_test.dart +++ b/test/ipc/paths_test.dart @@ -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'); + }); }); } diff --git a/test/ipc/server_test.dart b/test/ipc/server_test.dart new file mode 100644 index 00000000..83e786b3 --- /dev/null +++ b/test/ipc/server_test.dart @@ -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 withXdg(Future 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>().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())); + }); + + 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 _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>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2)); + await c.close(); + return IpcMessage.decode(line) as IpcResponse; +}