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:
@@ -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;
|
||||
|
||||
@@ -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/<sha256(workspace-root)[:16]>.sock` on Linux, `$HOME/Library/Caches/clide/<sha256(workspace-root)[:16]>.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);
|
||||
|
||||
@@ -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/<hash>.sock` on
|
||||
Linux, `~/Library/Caches/clide/<hash>.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
|
||||
|
||||
@@ -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/<sha256(workspace-root)[:16]>.sock` on Linux and `$HOME/Library/Caches/clide/<sha256(workspace-root)[:16]>.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/<hash(workspace-root)>.sock` on Linux and `$HOME/Library/Caches/clide/<hash(workspace-root)>.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.
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
+31
-1
@@ -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<void> 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<void> 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<void> 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));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+48
-15
@@ -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-<user>.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-<user>.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/<hash>.sock`
|
||||
/// macOS: `$HOME/Library/Caches/clide/<hash>.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)}';
|
||||
}
|
||||
|
||||
@@ -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<Socket> _clients = [];
|
||||
StreamSubscription<Socket>? _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<void> 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<void> 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<Socket>.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<List<int>> 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<void> _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<void> _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<void> _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<void> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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