Files
clide/docs/audits/pty-ipc-error-handling-2026-05-05.md
T
jpmschweitzerandClaude b99548a900 add PTY + IPC error-handling audit (T-18)
29 issues across PTY (lib/src/pty/), IPC (lib/src/ipc/), and
daemon command handlers (lib/src/daemon/). 14 critical (silent
failures, resource leaks, races), 8 high (degraded UX/debug),
7 medium (cleanliness). Each item references the follow-up
ticket where the fix lands (T-75 through T-81).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-05 14:59:14 +02:00

7.3 KiB

PTY + IPC error-handling audit

Date: 2026-05-05 Ticket: T-18 Decision ref: D-5

Punch list of error-handling issues in lib/src/pty/, lib/src/ipc/, and lib/src/daemon/. Severity-ranked. Each item references the follow-up ticket where the fix lands.

Critical — silent failures, leaks, races

  1. lib/src/pty/native_pty.dart:155-158forkpty() failure throws StateError('forkpty() failed') with no errno. Caller can't distinguish ENOMEM/EAGAIN/ENOENT-of-/dev/ptmx. Capture errno before _freeAll (which may trample it) and surface via PtyException. → T-75

  2. lib/src/pty/native_pty.dart:160-165 — Child process: chdir and execve returns are ignored. If execve returns (i.e. fails), we fall through to _exit(1) with no diagnostic. Write a one-line error envelope to fd 1 before exiting so the parent's reader sees "exec failed: ENOENT" instead of immediate EOF. → T-75

  3. lib/src/pty/native_pty.dart:244-251write() ignores _nativeWrite return. Short writes silently drop bytes; -1/EPIPE reported as successful "wrote -1". Loop until full length is written or surface errno on negative returns. → T-75

  4. lib/src/pty/native_pty.dart:259-262resize() ignores _ioctl and _nativeKill return values. EBADF on a half-closed fd silently no-ops. Set _dead = true on EBADF. → T-75

  5. lib/src/pty/native_pty.dart:198-210 — Race: _spawnReader is async but NativePty.start returns immediately. close() racing with isolate spawn can leave the isolate orphaned. Make start await reader spawn or track the spawn-future. → T-76

  6. lib/src/pty/native_pty.dart:280-290close() sets _dead = true before _nativeClose(_fd), but the reader isolate continues polling on _fd. If a new fd reuses that number, the reader's poll may briefly target the wrong file. Send shutdown signal via SendPort or self-pipe before closing. → T-76

  7. lib/src/pty/session.dart:135-153 — Resource leak: if _recvFdAsync, setWinsize, proc.stdout.first.timeout, or _extractPid throws, the spawned ptyc Process and (in some cases) the received masterFd leak. Only line 151 closes masterFd. Wrap post-spawn block in try/catch that kills proc, closes masterFd, and rethrows. → T-76

  8. lib/src/pty/session.dart:240_recvFdAsync: if Isolate.spawn itself throws, port is leaked. Wrap in try/catch. → T-76

  9. lib/src/pty/session.dart:165-176write() returns raw libc.write result without checking < 0 / errno or looping for short writes. Same as #3. → T-75

  10. lib/src/pty/session.dart:271-275Isolate.spawn(...).then(...) is fire-and-forget. If spawn fails, the error is silently swallowed and _readerIsolate remains null forever. Add .catchError or await it. → T-76

  11. lib/src/ipc/server.dart:30-39broadcast() try/catch (_) swallows write errors with no logging. At least log the kind. → T-77

  12. lib/src/ipc/server.dart:107client.writeln(resp.encode()) is not awaited and not guarded. If client disconnected mid-dispatch, this throws asynchronously with no onError handler. Wrap in try/catch and remove the client from _clients. → T-77

  13. lib/src/ipc/server.dart:83-108_handleLine runs await dispatch(msg) with no per-request timeout. A misbehaving handler blocks the connection's read pipeline indefinitely. → T-77

  14. lib/src/ipc/server.dart:46-50 — Stale-socket retry deletes the socket file unconditionally on SocketException. If two daemon instances race to start, the second rips the first's live socket out from under it. Try connect() first; refuse if a live daemon answers. → T-77

High — degraded UX / debugging

  1. lib/src/daemon/pane_commands.dart:87-96_spawn catch-all flattens every failure into tool_error: pane.spawn failed: <toString>. "binary not found", "permission denied", "out of pty fds" all look the same. Map PtyException.errno (ENOENT/EACCES/EMFILE) to distinct hints/codes. → T-79

  2. lib/src/daemon/editor_commands.dart:67-76 — Same pattern; editor.open catch-all loses FileSystemException distinctions (ENOENT vs EACCES vs EISDIR). → T-79

  3. lib/src/daemon/files_commands.dart:78file.readAsStringSync() is unguarded; UTF-8 errors, permission errors, races with deletion turn into a 500-style dispatch error instead of a clean IpcResponse.err. Wrap in try/catch. → T-81

  4. lib/src/daemon/files_commands.dart:74 — Path is concatenated with / and never validated. path: "../../../etc/passwd" traverses out of files.root. Resolve and verify the resulting path stays under root.absolute.path. → T-78 (security)

  5. lib/src/pty/session.dart:201-234close() distinguishes EOF/EBADF/EIO only in comments. The 500ms timeout is silent (onTimeout: () {}). Log the timeout so we know when SIGKILL was actually needed. → T-81

  6. lib/src/pty/session.dart:390-394 — Reader isolate treats any negative read return that isn't EINTR as EOF — including transient EAGAIN or recoverable EIO. Inspect errno and log non-EBADF/EIO/0 cases. → T-81

  7. lib/src/pty/ffi/scm_rights.dart:115-116 — Returned cmsg-data fd is read without sanity-checking against msgControllen. A malformed peer that sends only a partial cmsg could let us read garbage as an fd. Verify dataOffset + 4 <= msgControllen before deref. → T-81

  8. lib/src/ipc/server.dart:41-56start() logs to stderr.writeln but the rest of the daemon uses no logger. In the Flutter-host process stderr is often consumed by the engine. Standardize on a logger. → T-80

Medium — cleanliness

  1. lib/src/pty/session.dart:390, native_pty.dart:262, 285 — Magic errno/signal numbers (4=EINTR, 9=SIGKILL, 28=SIGWINCH, _kSighup=1). Pull into named constants. → T-80

  2. lib/src/pty/ffi/libc.dart:232-245errno getter does a lookupFunction on every access (catching ArgumentError every call on macOS). Cache the resolved function pointer. → T-80

  3. lib/src/daemon/git_commands.dart:283_gitError always reports tool_error. A git push rejection or merge conflict is user-actionable, not a tool failure; could map to IpcExitCode.conflict when stderr matches known patterns. → T-81

  4. lib/src/ipc/server.dart:97 — Dispatch error shows dispatch failed: $e (full exception toString). Trim and add the request cmd for log correlation. → T-80

  5. lib/src/daemon/pane_commands.dart:136registry.write(id, bytes) return value n is shown to caller, but if n == -1 (write failed) we still respond ok. Distinguish. → T-81

  6. lib/src/ipc/envelope.dart:88-94IpcResponse.fromJson throws TypeError if ok=false but error is missing. No graceful degradation for a malformed peer response. → T-81

  7. lib/src/pty/native_pty.dart:111-119 — PATH resolution silently uses the first existing match without checking X_OK. A non-executable file shadows a valid binary further along PATH. → T-81