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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -146,6 +146,30 @@ void main() {
|
||||
expect(() async => other.start(), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user