map POSIX errno to actionable IPC error kinds (T-79)
pane.spawn (via PtyException.errno) and editor.open (via FileSystemException.osError.errorCode) now route ENOENT to not_found, EACCES/EPERM to user_error with a permissions hint, EISDIR/ENOTDIR/EEXIST to distinct user-error/conflict, and EMFILE/ENFILE to tool_error with a "fd limit hit" hint. The mapping lives in lib/src/ipc/errno_mapping.dart so other handlers can adopt the same surface as they pick up errno-bearing failures. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+19
-5
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"exported_at": "2026-05-05T13:12:14Z",
|
"exported_at": "2026-05-06T07:56:04Z",
|
||||||
"decisions": [
|
"decisions": [
|
||||||
{
|
{
|
||||||
"id": "D-1",
|
"id": "D-1",
|
||||||
@@ -2624,10 +2624,10 @@
|
|||||||
"id": "T-77",
|
"id": "T-77",
|
||||||
"type": "task",
|
"type": "task",
|
||||||
"title": "IPC server: per-request timeout, broadcast logging, stale-socket race",
|
"title": "IPC server: per-request timeout, broadcast logging, stale-socket race",
|
||||||
"status": "in_progress",
|
"status": "done",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"created_at": "2026-05-05 12:58:59",
|
"created_at": "2026-05-05 12:58:59",
|
||||||
"updated_at": "2026-05-05 13:10:57"
|
"updated_at": "2026-05-05 13:12:20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T-78",
|
"id": "T-78",
|
||||||
@@ -2642,10 +2642,10 @@
|
|||||||
"id": "T-79",
|
"id": "T-79",
|
||||||
"type": "task",
|
"type": "task",
|
||||||
"title": "pane.spawn / editor.open: map errno to actionable error kinds",
|
"title": "pane.spawn / editor.open: map errno to actionable error kinds",
|
||||||
"status": "backlog",
|
"status": "in_progress",
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"created_at": "2026-05-05 12:58:59",
|
"created_at": "2026-05-05 12:58:59",
|
||||||
"updated_at": "2026-05-05 12:58:59"
|
"updated_at": "2026-05-06 07:45:02"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T-80",
|
"id": "T-80",
|
||||||
@@ -3975,6 +3975,20 @@
|
|||||||
"old_value": "backlog",
|
"old_value": "backlog",
|
||||||
"new_value": "in_progress",
|
"new_value": "in_progress",
|
||||||
"changed_at": "2026-05-05 13:10:57"
|
"changed_at": "2026-05-05 13:10:57"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-77",
|
||||||
|
"field": "status",
|
||||||
|
"old_value": "in_progress",
|
||||||
|
"new_value": "done",
|
||||||
|
"changed_at": "2026-05-05 13:12:20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-79",
|
||||||
|
"field": "status",
|
||||||
|
"old_value": "backlog",
|
||||||
|
"new_value": "in_progress",
|
||||||
|
"changed_at": "2026-05-06 07:45:02"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
client dropped on response-write failure, and the stale-socket
|
client dropped on response-write failure, and the stale-socket
|
||||||
retry now probes for a live daemon before unlinking the socket
|
retry now probes for a live daemon before unlinking the socket
|
||||||
(refusing to start if one answers).
|
(refusing to start if one answers).
|
||||||
|
- `pane.spawn` and `editor.open` now map POSIX errno values to
|
||||||
|
actionable IPC error kinds. ENOENT → `not_found`, EACCES/EPERM
|
||||||
|
→ `user_error` with a permissions hint, EISDIR/ENOTDIR/EEXIST
|
||||||
|
→ distinct user-error/conflict, EMFILE/ENFILE → `tool_error`
|
||||||
|
with a "fd limit hit" hint. Previously every spawn/open failure
|
||||||
|
was an indistinguishable `tool_error`.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
/// one-to-one onto these in `bin/clide.dart`.
|
/// one-to-one onto these in `bin/clide.dart`.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:io' show FileSystemException;
|
||||||
|
|
||||||
import '../editor/registry.dart';
|
import '../editor/registry.dart';
|
||||||
import '../ipc/envelope.dart';
|
import '../ipc/envelope.dart';
|
||||||
|
import '../ipc/errno_mapping.dart';
|
||||||
import '../ipc/schema_v1.dart';
|
import '../ipc/schema_v1.dart';
|
||||||
import 'dispatcher.dart';
|
import 'dispatcher.dart';
|
||||||
|
|
||||||
@@ -64,6 +67,22 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
|||||||
try {
|
try {
|
||||||
final buf = await r.open(path);
|
final buf = await r.open(path);
|
||||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||||
|
} on FileSystemException catch (e) {
|
||||||
|
final errno = e.osError?.errorCode;
|
||||||
|
if (errno != null) {
|
||||||
|
return IpcResponse.err(
|
||||||
|
id: req.id,
|
||||||
|
error: errnoToIpcError(errno: errno, op: 'editor.open', target: path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IpcResponse.err(
|
||||||
|
id: req.id,
|
||||||
|
error: IpcError(
|
||||||
|
code: IpcExitCode.toolError,
|
||||||
|
kind: IpcErrorKind.toolError,
|
||||||
|
message: 'editor.open failed: ${e.message}',
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return IpcResponse.err(
|
return IpcResponse.err(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ library;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../ipc/envelope.dart';
|
import '../ipc/envelope.dart';
|
||||||
|
import '../ipc/errno_mapping.dart';
|
||||||
import '../ipc/schema_v1.dart';
|
import '../ipc/schema_v1.dart';
|
||||||
import '../panes/pane.dart';
|
import '../panes/pane.dart';
|
||||||
import '../panes/registry.dart';
|
import '../panes/registry.dart';
|
||||||
|
import '../pty/errors.dart';
|
||||||
import 'dispatcher.dart';
|
import 'dispatcher.dart';
|
||||||
|
|
||||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
|
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
|
||||||
@@ -84,6 +86,26 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
|
|||||||
title: args['title'] as String?,
|
title: args['title'] as String?,
|
||||||
);
|
);
|
||||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||||
|
} on PtyException catch (e) {
|
||||||
|
final errno = e.errno;
|
||||||
|
if (errno != null) {
|
||||||
|
return IpcResponse.err(
|
||||||
|
id: req.id,
|
||||||
|
error: errnoToIpcError(
|
||||||
|
errno: errno,
|
||||||
|
op: 'pane.spawn',
|
||||||
|
target: argv.isNotEmpty ? argv.first : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IpcResponse.err(
|
||||||
|
id: req.id,
|
||||||
|
error: IpcError(
|
||||||
|
code: IpcExitCode.toolError,
|
||||||
|
kind: IpcErrorKind.toolError,
|
||||||
|
message: 'pane.spawn failed: ${e.message}',
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return IpcResponse.err(
|
return IpcResponse.err(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/// Map POSIX errno values to IPC error envelopes with actionable
|
||||||
|
/// messages. Used by command handlers that wrap syscall-backed work
|
||||||
|
/// (PTY spawn, file open) so the client can distinguish "binary not
|
||||||
|
/// found" from "permission denied" from "system limit hit" instead
|
||||||
|
/// of seeing the same generic `tool_error: foo failed`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'envelope.dart';
|
||||||
|
import 'schema_v1.dart';
|
||||||
|
|
||||||
|
/// Selected POSIX errno values we map specially. Others fall through
|
||||||
|
/// to a generic toolError. Values match Linux glibc and macOS Darwin
|
||||||
|
/// (the two platforms that share the same numbers for these entries).
|
||||||
|
abstract class PosixErrno {
|
||||||
|
static const int eperm = 1;
|
||||||
|
static const int enoent = 2;
|
||||||
|
static const int esrch = 3;
|
||||||
|
static const int eio = 5;
|
||||||
|
static const int ebadf = 9;
|
||||||
|
static const int eagain = 11;
|
||||||
|
static const int enomem = 12;
|
||||||
|
static const int eacces = 13;
|
||||||
|
static const int eexist = 17;
|
||||||
|
static const int enotdir = 20;
|
||||||
|
static const int eisdir = 21;
|
||||||
|
static const int emfile = 24;
|
||||||
|
static const int enfile = 23;
|
||||||
|
static const int epipe = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an [IpcError] from a POSIX [errno] for an operation [op]
|
||||||
|
/// (e.g. `pane.spawn`, `editor.open`) on optional [target] (a path,
|
||||||
|
/// command name, etc.). The returned error uses `notFound`,
|
||||||
|
/// `userError`, or `toolError` based on what's actionable.
|
||||||
|
IpcError errnoToIpcError({
|
||||||
|
required int errno,
|
||||||
|
required String op,
|
||||||
|
String? target,
|
||||||
|
String? raw,
|
||||||
|
}) {
|
||||||
|
final what = target != null ? ' ($target)' : '';
|
||||||
|
switch (errno) {
|
||||||
|
case PosixErrno.enoent:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.notFound,
|
||||||
|
kind: IpcErrorKind.notFound,
|
||||||
|
message: '$op: not found$what',
|
||||||
|
);
|
||||||
|
case PosixErrno.eacces:
|
||||||
|
case PosixErrno.eperm:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.userError,
|
||||||
|
kind: IpcErrorKind.userError,
|
||||||
|
message: '$op: permission denied$what',
|
||||||
|
hint: 'check file permissions or run with appropriate access',
|
||||||
|
);
|
||||||
|
case PosixErrno.eisdir:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.userError,
|
||||||
|
kind: IpcErrorKind.userError,
|
||||||
|
message: '$op: is a directory$what',
|
||||||
|
);
|
||||||
|
case PosixErrno.enotdir:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.userError,
|
||||||
|
kind: IpcErrorKind.userError,
|
||||||
|
message: '$op: not a directory$what',
|
||||||
|
);
|
||||||
|
case PosixErrno.eexist:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.conflict,
|
||||||
|
kind: IpcErrorKind.conflict,
|
||||||
|
message: '$op: already exists$what',
|
||||||
|
);
|
||||||
|
case PosixErrno.emfile:
|
||||||
|
case PosixErrno.enfile:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.toolError,
|
||||||
|
kind: IpcErrorKind.toolError,
|
||||||
|
message: '$op: too many open files',
|
||||||
|
hint: 'system or per-process file descriptor limit reached',
|
||||||
|
);
|
||||||
|
case PosixErrno.enomem:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.toolError,
|
||||||
|
kind: IpcErrorKind.toolError,
|
||||||
|
message: '$op: out of memory',
|
||||||
|
);
|
||||||
|
case PosixErrno.eagain:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.toolError,
|
||||||
|
kind: IpcErrorKind.toolError,
|
||||||
|
message: '$op: resource temporarily unavailable',
|
||||||
|
hint: 'retry may succeed',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return IpcError(
|
||||||
|
code: IpcExitCode.toolError,
|
||||||
|
kind: IpcErrorKind.toolError,
|
||||||
|
message: '$op failed${raw != null ? ': $raw' : ' (errno=$errno)'}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:clide/src/ipc/errno_mapping.dart';
|
||||||
|
import 'package:clide/src/ipc/schema_v1.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('errnoToIpcError', () {
|
||||||
|
test('ENOENT → notFound with target', () {
|
||||||
|
final err = errnoToIpcError(errno: PosixErrno.enoent, op: 'pane.spawn', target: 'claude');
|
||||||
|
expect(err.kind, IpcErrorKind.notFound);
|
||||||
|
expect(err.code, IpcExitCode.notFound);
|
||||||
|
expect(err.message, contains('claude'));
|
||||||
|
expect(err.message, contains('not found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EACCES → userError with hint', () {
|
||||||
|
final err = errnoToIpcError(errno: PosixErrno.eacces, op: 'editor.open', target: '/etc/shadow');
|
||||||
|
expect(err.kind, IpcErrorKind.userError);
|
||||||
|
expect(err.code, IpcExitCode.userError);
|
||||||
|
expect(err.message, contains('permission denied'));
|
||||||
|
expect(err.hint, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EISDIR → userError', () {
|
||||||
|
final err = errnoToIpcError(errno: PosixErrno.eisdir, op: 'editor.open', target: 'src/');
|
||||||
|
expect(err.kind, IpcErrorKind.userError);
|
||||||
|
expect(err.message, contains('is a directory'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EEXIST → conflict', () {
|
||||||
|
final err = errnoToIpcError(errno: PosixErrno.eexist, op: 'files.create', target: 'README.md');
|
||||||
|
expect(err.kind, IpcErrorKind.conflict);
|
||||||
|
expect(err.code, IpcExitCode.conflict);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EMFILE → toolError with hint', () {
|
||||||
|
final err = errnoToIpcError(errno: PosixErrno.emfile, op: 'pane.spawn');
|
||||||
|
expect(err.kind, IpcErrorKind.toolError);
|
||||||
|
expect(err.message, contains('too many open files'));
|
||||||
|
expect(err.hint, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown errno falls through to toolError', () {
|
||||||
|
final err = errnoToIpcError(errno: 999, op: 'pane.spawn');
|
||||||
|
expect(err.kind, IpcErrorKind.toolError);
|
||||||
|
expect(err.code, IpcExitCode.toolError);
|
||||||
|
expect(err.message, contains('errno=999'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raw message overrides errno suffix in fallback', () {
|
||||||
|
final err = errnoToIpcError(errno: 999, op: 'pane.spawn', raw: 'kernel exploded');
|
||||||
|
expect(err.message, contains('kernel exploded'));
|
||||||
|
expect(err.message, isNot(contains('errno=999')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user