fix(ipc): sweep dead orphan sockets from the runtime dir on startup (T-247)

The runtime socket dir accumulated orphaned *.sock nodes from crashed
instances — only the current workspace's own path was ever cleaned. Add a
best-effort startup sweep that probes every *.sock in the dir and unlinks
only the dead ones; live instances (something answers) and unresponsive
nodes (possibly hung) are left alone. Runs before bind, alongside the
existing per-workspace stale-unlink. Never blocks our own startup on a
sweep failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 08:15:05 +02:00
co-authored by Claude Opus 4.8
parent 6af980f31c
commit ceab497ba8
3 changed files with 64 additions and 0 deletions
+36
View File
@@ -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<void> 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<void> _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<void> _unlinkStale(String path) async {
final f = File(path);
if (!f.existsSync()) return;