diff --git a/CHANGELOG.md b/CHANGELOG.md index c722e5c5..031165a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. agent pinning its parent instance) that beats workspace auto-discovery — and fails loudly if that socket is dead instead of silently driving a different instance. (T-247) +- **Orphaned IPC sockets are swept on startup.** The app probes the runtime + socket dir on launch and unlinks dead `*.sock` nodes left by crashed + instances (live instances are left untouched), so the dir no longer + accumulates stale sockets. (T-247) - **Default window opens larger (1600×900) on Linux.** 720p was short enough that the welcome screen's version/theme footer overlapped the tips card; the taller default clears it, matching the macOS default. (T-477) diff --git a/lib/src/ipc/server.dart b/lib/src/ipc/server.dart index c8c862b2..7d659752 100644 --- a/lib/src/ipc/server.dart +++ b/lib/src/ipc/server.dart @@ -82,10 +82,14 @@ class IpcServer { /// 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). + /// Orphaned sockets from crashed instances of OTHER workspaces are + /// also swept from the runtime dir on startup (T-247), so the dir + /// doesn't accumulate dead nodes. Future start() async { if (isRunning) return; final path = workspaceSocketPath(workspaceRoot); await _prepareParentDir(path); + await _sweepStaleSockets(path); await _unlinkStale(path); final socket = await ServerSocket.bind(InternetAddress(path, type: InternetAddressType.unix), 0); try { @@ -249,6 +253,38 @@ class IpcServer { } } + /// Sweep the runtime socket dir for orphaned `*.sock` nodes left by crashed + /// instances of OTHER workspaces (T-247): probe each, unlink only the dead + /// ones. A live instance (something answers) or an unresponsive node (could + /// be a hung instance) is left untouched; the current workspace's own path is + /// handled by [_unlinkStale]. Best-effort — a sweep failure never blocks our + /// own startup. + Future _sweepStaleSockets(String selfPath) async { + try { + final dir = Directory(File(selfPath).parent.path); + if (!dir.existsSync()) return; + for (final entry in dir.listSync()) { + if (entry is! File || !entry.path.endsWith('.sock') || entry.path == selfPath) continue; + try { + final probe = await Socket.connect(InternetAddress(entry.path, type: InternetAddressType.unix), 0).timeout(const Duration(milliseconds: 200)); + await probe.close(); // live instance — leave it alone + } on SocketException { + // No listener — an orphan from a crashed instance. Unlink it. + try { + entry.deleteSync(); + log.info('ipc', 'swept orphaned socket ${entry.path}'); + } catch (e) { + log.warn('ipc', 'failed to sweep ${entry.path}: $e'); + } + } on TimeoutException { + // Exists but unresponsive — possibly a hung instance; don't clobber. + } + } + } catch (e) { + log.warn('ipc', 'socket sweep failed: $e'); + } + } + Future _unlinkStale(String path) async { final f = File(path); if (!f.existsSync()) return; diff --git a/test/ipc/server_test.dart b/test/ipc/server_test.dart index 74e451be..89adbb08 100644 --- a/test/ipc/server_test.dart +++ b/test/ipc/server_test.dart @@ -146,6 +146,30 @@ void main() { expect(() async => other.start(), throwsA(isA())); }); + test('startup sweeps dead orphan sockets from the runtime dir, keeps live ones (T-247)', () async { + final socketDir = Directory(File(workspaceSocketPath(workRoot)).parent.path); + socketDir.createSync(recursive: true); + final uniq = DateTime.now().microsecondsSinceEpoch; + // A dead orphan (a socket node with no listener) and a live orphan + // (a real listener for some other "workspace"). Unique names so the + // assertions don't depend on whatever else is in the shared runtime dir. + final dead = File('${socketDir.path}/clide-sweep-dead-$uniq.sock')..writeAsBytesSync([]); + final livePath = '${socketDir.path}/clide-sweep-live-$uniq.sock'; + final live = await ServerSocket.bind(InternetAddress(livePath, type: InternetAddressType.unix), 0); + addTearDown(() async { + await live.close(); + for (final p in [livePath, dead.path]) { + if (File(p).existsSync()) File(p).deleteSync(); + } + }); + + server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); + await server.start(); + + expect(dead.existsSync(), isFalse, reason: 'a dead orphan should be swept on startup'); + expect(File(livePath).existsSync(), isTrue, reason: 'a live instance must be left untouched'); + }); + 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();