replace forkpty() with posix_openpt() + posix_spawn() (T-96)

`forkpty` calls `fork()` underneath. `fork()` in a multithreaded
process is unsafe: only the calling thread survives in the child,
but libc locks held by other threads remain "locked forever." With
the multi-threaded Dart VM as parent, ~5% of spawns deadlocked in
the child before `execve` (forensic probe: child stuck in S state
with comm=`DartWorker`, master fd never sees POLLIN).

`posix_spawn` uses `vfork` on glibc/musl/macOS, keeping the parent
suspended until execve completes — no Dart code runs in the child.
Pty pair built via the POSIX-standard `posix_openpt` / `grantpt` /
`unlockpt` / `ptsname` sequence. Probed: zero hangs in 300
sequential spawns vs ~5% before.

Behavior change: missing executable / missing workingDirectory now
surface as a `PtyException` thrown by `NativePty.start` rather than
a diagnostic written from the child to the slave PTY. Cleaner error
path for callers.

Side benefit: drops the `libutil.so.1` dynamic-library dependency.
PTY now resolves entirely against libc via `DynamicLibrary.process()`.

Splits the library-level `@Tags(['forkpty'])` on session_test.dart
into a per-test tag, so the now-runnable-under-flutter-test cases
contribute to coverage. `dart_test.yaml` declares the tag so the
exclude-tags filters honor it. Drops the `retry: 2` workaround from
the formerly-flaky registry test.

D-5 amended. Trims session-introduced CHANGELOG entries that were
over-verbose for the Keep-a-Changelog format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:21:41 +02:00
co-authored by Claude Opus 4.7
parent ab2e5e618b
commit 8074bf4201
10 changed files with 291 additions and 225 deletions
+12 -30
View File
@@ -46,26 +46,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
end target is 95% (D-66). `ci/test.sh` now writes end target is 95% (D-66). `ci/test.sh` now writes
`coverage/lcov.info` as a side effect of the unit/widget/golden `coverage/lcov.info` as a side effect of the unit/widget/golden
run so the gate adds no extra test invocation. run so the gate adds no extra test invocation.
- Test sweep covering `kernel/src/commands/keybindings.dart` (KeyEvent - Test sweep `keybindings`, `toolchain_paths`, and several
modifier mapping, parse-error edges, resolver entries view), `widgets/src/` primitives (tooltip, palette, multitab, markdown).
`kernel/src/toolchain_paths.dart` (the Flutter-free `ToolchainView.resolved` - `tree_sitter_service` sweep — fake-FFI + real-library smoke,
static view), `widgets/src/clide_tooltip.dart` (hover-delay overlay, 17% → 96%. Crosses the 95% global target (T-91).
flip-above placement, re-entry cycle), `widgets/src/clide_palette.dart`
(filter typing, submit-invokes-first, hover state),
`widgets/src/multitab_controller.dart` (`copyWith`, `length`/`isEmpty`
getters), and `widgets/src/clide_markdown.dart` (h3h6, tables,
strikethrough, default block fallback, record-link tap). Crosses
the 93% line-coverage threshold (T-91).
- `kernel/src/syntax/tree_sitter_service.dart` test sweep — every
`colorForRole` switch arm plus fake-FFI coverage of
`_init`/`_loadGrammar`/`highlight`/`dispose` branches, taking the
file from 17% → 96%. Real-library smoke test
(`test/kernel/src/syntax/tree_sitter_smoke_test.dart`) dlopen's the
vendored `native/linux-x64/libtree-sitter.so`, loads the bundled
`dart` grammar end-to-end, and verifies highlight emits sane spans —
catches FFI signature drift the fake-driven tests can't. Skips on
non-Linux and when the vendored library isn't present. Drives total
line coverage across the 95% target (T-91).
- Staged `dart doc` CI job — generates and uploads an HTML API - Staged `dart doc` CI job — generates and uploads an HTML API
reference for the public `lib/` surface. The step wraps reference for the public `lib/` surface. The step wraps
`dart doc --validate-links` and grep-fails the build on any warning, `dart doc --validate-links` and grep-fails the build on any warning,
@@ -119,16 +103,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed ### Changed
- Pre-push line-coverage floor ratcheted to 95% — D-66 target hit. - PTY spawning uses `posix_openpt` + `posix_spawn` instead of
- `TreeSitterService` accepts injectable `TreeSitterLib` and grammar / `forkpty` — closes a ~5% deadlock window in the multithreaded Dart
query loaders so tests can substitute a fake FFI surface without VM (T-96, D-5 amended). Missing exe/cwd now throw `PtyException` at
dlopen'ing `libtree-sitter.so`. `TreeSitterLib.testing(...)` exposes a spawn time. Drops the `libutil.so.1` dependency.
named-parameter constructor with safe no-op defaults for every native - Coverage floor ratcheted to 95% — D-66 target hit.
function; `TreeSitterLib.fromDynamicLibrary(...)` lets the smoke test - `TreeSitterService` and `TreeSitterLib` accept injectable FFI + asset
load the vendored `.so` directly. Production paths loaders for fake-driven tests; production paths unchanged.
(`TreeSitterService.shared`, `TreeSitterLib.instance`) unchanged. - Tidied test imports flagged by `unnecessary_import`.
- Tidied test imports — dropped redundant `dart:ui` / `dart:typed_data`
/ barrel-redundant package imports flagged by `unnecessary_import`.
- Terminal panes now render bold attributes with a real bold weight — - Terminal panes now render bold attributes with a real bold weight —
bundled JetBrainsMono Bold + BoldItalic are registered with the bundled JetBrainsMono Bold + BoldItalic are registered with the
`JetBrainsMono` family at `weight: 700`. The painter's bold `JetBrainsMono` family at `weight: 700`. The painter's bold
+2 -2
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
An IDE for Claude Code CLI. Single Flutter package at the repo root. An IDE for Claude Code CLI. Single Flutter package at the repo root.
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `forkpty()` directly. - **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it. - **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages. tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
@@ -20,7 +20,7 @@ These are load-bearing. Violating any means the design is wrong, not the rule.
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components. - **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components.
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it). - **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it).
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](governance/decisions/architecture.md#d-1-cli-first-not-mcp). - **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](governance/decisions/architecture.md#d-1-cli-first-not-mcp).
- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`forkpty`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](governance/decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56). - **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`posix_openpt` + `posix_spawn`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](governance/decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
- **Own the rendering stack.** PTY (via Dart FFI), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages. - **Own the rendering stack.** PTY (via Dart FFI), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages.
- **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](governance/decisions/architecture.md#d-6-cli-and-event-surface-contract). - **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](governance/decisions/architecture.md#d-6-cli-and-event-surface-contract).
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](governance/decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates). - **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](governance/decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
+1 -1
View File
@@ -12,7 +12,7 @@ echo "==> dart format (whole tree)"
dart format --set-exit-if-changed . dart format --set-exit-if-changed .
echo "==> dart test (forkpty — incompatible with flutter test runner)" echo "==> dart test (forkpty — incompatible with flutter test runner)"
dart test --tags forkpty test/pty/session_test.dart dart test --tags forkpty test/pty/session_test.dart test/panes/registry_test.dart
echo "==> flutter test --coverage (unit + widget + golden)" echo "==> flutter test --coverage (unit + widget + golden)"
flutter test --coverage --exclude-tags forkpty flutter test --coverage --exclude-tags forkpty
+10
View File
@@ -0,0 +1,10 @@
# Declares tags used by inline `test('...', tags: [...], ...)` calls so
# that `flutter test --exclude-tags <name>` and `dart test --tags <name>`
# both honor them. Undeclared tags are ignored by the runners, which
# silently breaks selective excludes.
tags:
# Tests that call `forkpty()` via Dart FFI. Must run under `dart test`,
# not `flutter test` — the latter's runner hosts a multi-threaded
# Flutter engine in which forkpty produces a master fd that never
# delivers output. See `test/pty/session_test.dart`.
forkpty:
+11
View File
@@ -73,6 +73,7 @@ Core, rendering, IPC, kernel, panel manager.
### D-1: CLI-first, not MCP ### D-1: CLI-first, not MCP
- **Date:** 2026-04-20 (was ADR 0001; ported from the claudian lineage) - **Date:** 2026-04-20 (was ADR 0001; ported from the claudian lineage)
- **Amendment (2026-05-15):** D-1's intent — the CLI is the *primary* agent-facing surface, with the same contract as pql — stands. An additional `/ide`-compatible MCP surface is added per [D-68](#d-68-dual-integration-surface-bash-cli-primary-mcp-secondary); both wrap the same in-process dispatcher. The escape-hatch line in this record's Cost ("nothing here precludes adding [MCP] later that shells out to the same CLI") is realised — the MCP server does not bypass the CLI's surface, it offers a second transport to it.
- **Decision:** Claude talks to clide exclusively via Bash (`clide …`). No MCP server. No protocol layer in Claude's face. The CLI uses the same exit-code + stderr-JSON contract as pql. - **Decision:** Claude talks to clide exclusively via Bash (`clide …`). No MCP server. No protocol layer in Claude's face. The CLI uses the same exit-code + stderr-JSON contract as pql.
- **Context:** The two mainstream options for the agent-facing surface were an MCP server or a plain Bash CLI matching pql's contract. - **Context:** The two mainstream options for the agent-facing surface were an MCP server or a plain Bash CLI matching pql's contract.
- **Rationale:** Same mental model as pql for the agent — one tool-use pattern covers both. No MCP runtime to host, authenticate, or keep in sync with client versions. User/Claude parity is easier to enforce: every CLI subcommand must have a UI affordance in the Flutter app and vice versa ([D-6](#d-6-cli-and-event-surface-contract)). Claude Code's `Bash(clide *)` allow rule is the only configuration clide needs on the agent side. - **Rationale:** Same mental model as pql for the agent — one tool-use pattern covers both. No MCP runtime to host, authenticate, or keep in sync with client versions. User/Claude parity is easier to enforce: every CLI subcommand must have a UI affordance in the Flutter app and vice versa ([D-6](#d-6-cli-and-event-surface-contract)). Claude Code's `Bash(clide *)` allow rule is the only configuration clide needs on the agent side.
@@ -99,6 +100,7 @@ Core, rendering, IPC, kernel, panel manager.
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar)) - **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar))
- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core principle survives; the daemon binary does not. - **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core principle survives; the daemon binary does not.
- **Amendment (2026-05-07):** `ptyc` retired. PTY spawning moved to Dart FFI `forkpty()` (`lib/src/pty/native_pty.dart`). The `ptyc/` source tree, `PtySession`, and `scm_rights.dart` are removed. `pql` remains the sole external supporter tool. - **Amendment (2026-05-07):** `ptyc` retired. PTY spawning moved to Dart FFI `forkpty()` (`lib/src/pty/native_pty.dart`). The `ptyc/` source tree, `PtySession`, and `scm_rights.dart` are removed. `pql` remains the sole external supporter tool.
- **Amendment (2026-05-17):** `forkpty()` replaced with `posix_openpt()` + `posix_spawn()` (T-96). `forkpty` calls `fork()` underneath, which is unsafe in the multithreaded Dart VM: ~5% of spawns deadlocked in the child before `execve` due to libc locks held by ghost-threads at fork time. `posix_spawn` uses `vfork` under glibc/musl/macOS, keeping the parent suspended until `execve` completes — no Dart code runs in the child. Side benefit: dropped the `libutil.so.1` dynamic dependency; PTY now resolves entirely against libc via `DynamicLibrary.process()`.
- **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide <subcommand>` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone. - **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide <subcommand>` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone.
- **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language. - **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language.
- **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received. - **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received.
@@ -244,4 +246,13 @@ Core, rendering, IPC, kernel, panel manager.
- **Cross-reference:** [D-60](tooling.md#d-60-no-network-on-default-launch-path), `POLICY.md`. - **Cross-reference:** [D-60](tooling.md#d-60-no-network-on-default-launch-path), `POLICY.md`.
- **Raised by:** 2026-05-03 policy-to-decision migration (T-28). - **Raised by:** 2026-05-03 policy-to-decision migration (T-28).
### D-68: Dual integration surface — Bash CLI primary, MCP secondary
- **Date:** 2026-05-15
- **Decision:** clide exposes two integration surfaces over the same in-process `DaemonDispatcher`. **(1) Bash CLI over Unix socket — primary.** Per [D-1](#d-1-cli-first-not-mcp) and [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), a thin C client (`clide …`) connects to a per-user Unix socket served in-process by the Flutter app, exchanges JSON-lines, and exits. This is the surface Claude-Code-in-a-pane uses; it is also the surface for human shell use, scripts, and external editor integrations. Full action surface — `pane.*`, `files.*`, `editor.*`, `git.*`, `pql.*`, …. **(2) `/ide`-compatible MCP server — secondary.** clide additionally serves an MCP endpoint compatible with Claude Code's `/ide` integration (the same protocol VS Code and JetBrains plugins serve). Minimum tools: `mcp__ide__getDiagnostics`, `mcp__ide__executeCode`. Optional `mcp__clide__*` namespace exposing high-leverage clide tools is deferred to [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools). Transport choice deferred to [Q-33](../questions/architecture.md#q-33-mcp-transport-sse-websocket-stdio-or-all). The MCP server wraps the *same* `DaemonDispatcher`; there is no second source of truth.
- **Context:** [D-1](#d-1-cli-first-not-mcp) chose CLI-first over MCP-only because MCP alone doesn't cover the action surface clide needs — Claude Code's `/ide` MCP exposes only two narrow tools (`getDiagnostics`, `executeCode`), enough for Claude to read diagnostics and run Jupyter cells but not enough to *drive* an IDE. The CLI surface gives full reach. But for users who run Claude Code *outside* clide and connect via `/ide`, MCP is the only path Claude Code knows; not serving it means clide is invisible to that workflow. The two surfaces are complementary, not alternatives. Reinforced by the [2026-05-14 consultant review](../../consultants.md): the architect flagged the absent socket server as the most critical drift; user confirmed the socket server (D-56 path a) plus an MCP companion.
- **Rationale:** Both surfaces wrap the same dispatcher, so neither becomes a second source of truth. CLI remains the contract user/Claude parity ([D-6](#d-6-cli-and-event-surface-contract)) is enforced against. MCP is added because the `/ide` ecosystem is real and growing — VS Code, JetBrains, Cursor, Windsurf all serve compatible MCP — and clide should be a peer there. The implementation cost is a protocol adapter + tool definitions, not duplicate business logic.
- **Cost:** Two transports to maintain. Mitigated by both wrapping the same dispatcher: the MCP adapter is the only thing that has to track `/ide` protocol evolution. If `mcp__clide__*` tools are added (pending Q-32), surface bloat is the obvious risk — every CLI verb invites an MCP twin; resist by default, justify on user need.
- **Cross-reference:** [D-1](#d-1-cli-first-not-mcp) (amended — see amendment line there), [D-6](#d-6-cli-and-event-surface-contract), [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools), [Q-33](../questions/architecture.md#q-33-mcp-transport-sse-websocket-stdio-or-all).
- **Raised by:** 2026-05-15 — consultant review (`consultants.md`) flagged the absent socket server (D-56 unimplemented) as the highest architectural drift; user chose option (a) "implement the server" and asked for MCP coverage alongside.
--- ---
+3 -3
View File
@@ -5,9 +5,9 @@
/// core library stays Flutter-free per D-005. /// core library stays Flutter-free per D-005.
library; library;
/// A PTY operation failed. [op] identifies the step (`forkpty`, /// A PTY operation failed. [op] identifies the step (`posix_openpt`,
/// `read`, `ioctl`, etc.); [errno] is POSIX errno when the failure /// `posix_spawn`, `read`, `ioctl`, etc.); [errno] is POSIX errno when
/// came from a syscall, otherwise `null`. /// the failure came from a syscall, otherwise `null`.
class PtyException implements Exception { class PtyException implements Exception {
const PtyException(this.op, this.message, {this.errno}); const PtyException(this.op, this.message, {this.errno});
+196 -124
View File
@@ -1,11 +1,19 @@
/// Native PTY via forkpty(). /// Native PTY via posix_openpt() + posix_spawn().
/// ///
/// Uses Dart FFI to call forkpty() directly. The master fd stays /// Originally used `forkpty()`, which calls `fork()` underneath. `fork()`
/// in-process. The reader isolate uses poll() for clean shutdown. /// in a multithreaded process is unsafe: only the calling thread survives
/// in the child, but libc locks (notably `malloc`) held by other threads
/// remain "locked forever." With the multi-threaded Dart VM as the
/// parent, ~5% of spawns deadlocked in the child before `execve` (see
/// T-96).
/// ///
/// Based on the pty-spike proof-of-concept. Platform-aware: /// `posix_spawn()` uses `vfork()` on glibc/musl/macOS, which keeps the
/// macOS: forkpty in libSystem (DynamicLibrary.process) /// parent suspended until `execve` completes — no Dart code runs in the
/// Linux: forkpty in libutil.so.1 /// child, so the lock-deadlock window is closed. The pty is created via
/// the POSIX-standard `posix_openpt` / `grantpt` / `unlockpt` /
/// `ptsname` sequence instead of the BSD `forkpty` wrapper.
///
/// All symbols live in libc (resolved via `DynamicLibrary.process()`).
library; library;
import 'dart:async'; import 'dart:async';
@@ -43,38 +51,62 @@ final class _Pollfd extends ffi.Struct {
// -- FFI bindings ----------------------------------------------------------- // -- FFI bindings -----------------------------------------------------------
final ffi.DynamicLibrary _dl = _openLib(); final ffi.DynamicLibrary _dl = ffi.DynamicLibrary.process();
ffi.DynamicLibrary _openLib() { // pty open/setup (POSIX).
if (Platform.isMacOS) return ffi.DynamicLibrary.process(); final _posixOpenpt = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('posix_openpt');
// Linux: forkpty lives in libutil final _grantpt = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('grantpt');
return ffi.DynamicLibrary.open('libutil.so.1'); final _unlockpt = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('unlockpt');
} final _ptsname = _dl.lookupFunction<ffi.Pointer<Utf8> Function(ffi.Int32), ffi.Pointer<Utf8> Function(int)>('ptsname');
final _forkpty = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>), // posix_spawn family. The attr + file_actions structs are opaque to us
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>)>('forkpty'); // and platform-sized — we allocate a generous fixed buffer (8 KiB, far
// larger than any documented platform layout) and pass it as Pointer<Void>.
// init() writes the real layout into our memory; destroy() releases any
// internal nested allocations.
final _posixSpawn = _dl.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<Utf8>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Pointer<Utf8>>,
ffi.Pointer<ffi.Pointer<Utf8>>),
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<Utf8>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Pointer<Utf8>>,
ffi.Pointer<ffi.Pointer<Utf8>>)>('posix_spawn');
final _execve = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>), final _spawnattrInit = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawnattr_init');
int Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>)>('execve'); final _spawnattrDestroy = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawnattr_destroy');
final _spawnattrSetflags =
_dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int16), int Function(ffi.Pointer<ffi.Void>, int)>('posix_spawnattr_setflags');
final _nativeWrite = ffi.DynamicLibrary.process() final _faInit = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawn_file_actions_init');
.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('write'); final _faDestroy = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawn_file_actions_destroy');
final _faAddopen = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int32, ffi.Pointer<Utf8>, ffi.Int32, ffi.Uint32),
int Function(ffi.Pointer<ffi.Void>, int, ffi.Pointer<Utf8>, int, int)>('posix_spawn_file_actions_addopen');
final _faAdddup2 = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int32, ffi.Int32), int Function(ffi.Pointer<ffi.Void>, int, int)>(
'posix_spawn_file_actions_adddup2');
final _faAddclose =
_dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int32), int Function(ffi.Pointer<ffi.Void>, int)>('posix_spawn_file_actions_addclose');
// glibc 2.29+ / macOS 10.15+. Both ship the `_np` suffix.
final _faAddchdir = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<Utf8>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<Utf8>)>(
'posix_spawn_file_actions_addchdir_np');
final _nativeClose = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close'); // libc primitives shared with the reader isolate / lifecycle.
final _nativeWrite =
final _ioctl = ffi.DynamicLibrary.process() _dl.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('write');
.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>), int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl'); final _nativeClose = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close');
final _ioctl =
final _nativeKill = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill'); _dl.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>), int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl');
final _nativeKill = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill');
final _waitpid = ffi.DynamicLibrary.process() final _waitpid =
.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32), int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid'); _dl.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32), int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid');
final _chdir = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Char>), int Function(ffi.Pointer<ffi.Char>)>('chdir');
final _exit_ = ffi.DynamicLibrary.process().lookupFunction<ffi.Void Function(ffi.Int32), void Function(int)>('_exit');
// Constants — all duplicated from <fcntl.h>, <sys/ioctl.h>, <spawn.h>.
final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414; final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
const int _kORdwr = 0x0002;
final int _kONoctty = Platform.isMacOS ? 0x20000 : 0x0100;
// POSIX_SPAWN_SETSID — glibc 2.26+ (0x80), macOS 10.15+ (0x400).
final int _kSpawnSetsid = Platform.isMacOS ? 0x400 : 0x80;
// Opaque struct buffer size: ample headroom above every documented
// platform layout (glibc posix_spawnattr_t is 336 B; macOS even smaller).
const int _kSpawnStructBytes = 8192;
const _kSighup = 1; const _kSighup = 1;
const _kWnohang = 1; const _kWnohang = 1;
@@ -104,7 +136,11 @@ class NativePty {
/// Spawn a new PTY running [executable] with [arguments]. /// Spawn a new PTY running [executable] with [arguments].
/// ///
/// [environment] must be the complete environment — it goes straight /// [environment] must be the complete environment — it goes straight
/// to execve's envp. Merge Platform.environment before calling. /// to the spawn's envp. Merge `Platform.environment` before calling.
///
/// Uses `posix_openpt` + `posix_spawn` (via libc's `vfork`-backed
/// implementation) so no Dart code runs between fork and execve —
/// see the library docstring and T-96.
static NativePty start({ static NativePty start({
required String executable, required String executable,
List<String> arguments = const ['-l'], List<String> arguments = const ['-l'],
@@ -113,7 +149,9 @@ class NativePty {
String? workingDirectory, String? workingDirectory,
Map<String, String> environment = const {}, Map<String, String> environment = const {},
}) { }) {
// Resolve bare command names via PATH (execve doesn't search PATH). // Resolve bare command names via PATH (posix_spawn requires an absolute
// or relative path — posix_spawnp would search PATH for us but we want
// resolution to be visible/debuggable from Dart).
if (!executable.contains('/')) { if (!executable.contains('/')) {
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? ''; final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
for (final dir in path.split(':')) { for (final dir in path.split(':')) {
@@ -126,106 +164,136 @@ class NativePty {
} }
} }
// Force-resolve FFI functions that run in the child process. // ---- Open the pty master ------------------------------------------
// Top-level finals are lazy; touching them here ensures the FFI final masterFd = _posixOpenpt(_kORdwr | _kONoctty);
// trampolines are compiled before fork() clones the process. if (masterFd < 0) {
final execve = _execve; throw PtyException('posix_openpt', 'posix_openpt failed', errno: libc.errno);
final chdir = _chdir; }
final exit = _exit_; if (_grantpt(masterFd) != 0) {
final writeFn = _nativeWrite; final err = libc.errno;
_nativeClose(masterFd);
// Pre-allocate error envelopes the child will write to its stdout throw PtyException('grantpt', 'grantpt failed', errno: err);
// (slave PTY → parent's master fd) before _exit, so the parent's }
// reader sees a real diagnostic instead of an indistinguishable EOF. if (_unlockpt(masterFd) != 0) {
final chdirErr = 'clide: chdir failed: $workingDirectory\n'.toNativeUtf8(allocator: malloc); final err = libc.errno;
final chdirErrLen = chdirErr.length; _nativeClose(masterFd);
final execveErr = 'clide: exec failed: $executable\n'.toNativeUtf8(allocator: malloc); throw PtyException('unlockpt', 'unlockpt failed', errno: err);
final execveErrLen = execveErr.length; }
final slavePtr = _ptsname(masterFd);
// Allocate ALL native memory before fork. if (slavePtr == ffi.nullptr) {
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>(); _nativeClose(masterFd);
throw PtyException('ptsname', 'ptsname returned null');
}
// ptsname returns a pointer into a static (or thread-local) libc
// buffer; copy to a Dart-owned native string before any other libc
// call that might overwrite it.
final slavePath = slavePtr.toDartString().toNativeUtf8(allocator: malloc);
// ---- Marshal argv + envp -----------------------------------------
final exeN = executable.toNativeUtf8(allocator: malloc);
final allArgs = [executable, ...arguments]; final allArgs = [executable, ...arguments];
final argvN = malloc<ffi.Pointer<ffi.Char>>(allArgs.length + 1); final argvN = malloc<ffi.Pointer<Utf8>>(allArgs.length + 1);
for (var i = 0; i < allArgs.length; i++) { for (var i = 0; i < allArgs.length; i++) {
argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc).cast(); argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc);
} }
argvN[allArgs.length] = ffi.nullptr; argvN[allArgs.length] = ffi.nullptr;
final envList = environment.entries.toList(); final envList = environment.entries.toList();
final envpN = malloc<ffi.Pointer<ffi.Char>>(envList.length + 1); final envpN = malloc<ffi.Pointer<Utf8>>(envList.length + 1);
for (var i = 0; i < envList.length; i++) { for (var i = 0; i < envList.length; i++) {
envpN[i] = '${envList[i].key}=${envList[i].value}'.toNativeUtf8(allocator: malloc).cast(); envpN[i] = '${envList[i].key}=${envList[i].value}'.toNativeUtf8(allocator: malloc);
} }
envpN[envList.length] = ffi.nullptr; envpN[envList.length] = ffi.nullptr;
final wdN = (workingDirectory ?? '/').toNativeUtf8(allocator: malloc).cast<ffi.Char>(); final wdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf8(allocator: malloc);
final fdOut = calloc<ffi.Int32>();
// ---- Build file_actions ------------------------------------------
// Allocate as Uint8 so calloc treats it as a byte buffer; cast to
// Pointer<Void> when handing off to the FFI calls.
final fa = calloc<ffi.Uint8>(_kSpawnStructBytes).cast<ffi.Void>();
final attr = calloc<ffi.Uint8>(_kSpawnStructBytes).cast<ffi.Void>();
final pidOut = calloc<ffi.Int32>();
void freeAllInputs() {
malloc.free(exeN);
for (var i = 0; i < allArgs.length; i++) {
malloc.free(argvN[i]);
}
malloc.free(argvN);
for (var i = 0; i < envList.length; i++) {
malloc.free(envpN[i]);
}
malloc.free(envpN);
if (wdN != ffi.nullptr) malloc.free(wdN);
malloc.free(slavePath);
calloc.free(fa);
calloc.free(attr);
calloc.free(pidOut);
}
if (_faInit(fa) != 0) {
final err = libc.errno;
_nativeClose(masterFd);
freeAllInputs();
throw PtyException('spawn_fa_init', 'posix_spawn_file_actions_init failed', errno: err);
}
if (_spawnattrInit(attr) != 0) {
final err = libc.errno;
_faDestroy(fa);
_nativeClose(masterFd);
freeAllInputs();
throw PtyException('spawnattr_init', 'posix_spawnattr_init failed', errno: err);
}
int rc = 0;
rc |= _spawnattrSetflags(attr, _kSpawnSetsid);
// Open the slave on fd 0 WITHOUT O_NOCTTY so it becomes the child's
// controlling tty (the child is a fresh session leader courtesy of
// POSIX_SPAWN_SETSID).
rc |= _faAddopen(fa, 0, slavePath, _kORdwr, 0);
rc |= _faAdddup2(fa, 0, 1);
rc |= _faAdddup2(fa, 0, 2);
// Don't leak the master fd into the child.
rc |= _faAddclose(fa, masterFd);
if (wdN != ffi.nullptr) {
rc |= _faAddchdir(fa, wdN);
}
if (rc != 0) {
_faDestroy(fa);
_spawnattrDestroy(attr);
_nativeClose(masterFd);
freeAllInputs();
throw PtyException('spawn_fa_setup', 'failed to compose posix_spawn actions', errno: libc.errno);
}
// ---- Spawn -------------------------------------------------------
final spawnRc = _posixSpawn(pidOut, exeN, fa, attr, argvN, envpN);
final pid = pidOut.value;
_faDestroy(fa);
_spawnattrDestroy(attr);
if (spawnRc != 0) {
// posix_spawn returns the errno directly (does NOT set errno).
_nativeClose(masterFd);
freeAllInputs();
throw PtyException('posix_spawn', 'posix_spawn failed', errno: spawnRc);
}
// ---- Set initial winsize on the master ---------------------------
final ws = calloc<_Winsize>() final ws = calloc<_Winsize>()
..ref.wsRow = rows ..ref.wsRow = rows
..ref.wsCol = columns; ..ref.wsCol = columns;
_ioctl(masterFd, _kTiocsWinsz, ws);
calloc.free(ws);
// Fork. freeAllInputs();
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
if (pid == -1) { final pty = NativePty._(masterFd, pid);
// Capture errno BEFORE _freeAll — free() can clobber errno.
final err = libc.errno;
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
malloc.free(chdirErr);
malloc.free(execveErr);
throw PtyException('forkpty', 'forkpty() failed', errno: err);
}
if (pid == 0) {
// CHILD — only pre-resolved FFI calls, no Dart heap.
// After forkpty(), fd 1 is the slave PTY connected back to the
// parent's master fd, so write(1, ...) lands as readable output.
if (chdir(wdN) != 0) {
writeFn(1, chdirErr.cast(), chdirErrLen);
exit(1);
}
execve(shellN, argvN, envpN);
// execve only returns on failure.
writeFn(1, execveErr.cast(), execveErrLen);
exit(1);
}
// PARENT
final fd = fdOut.value;
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
malloc.free(chdirErr);
malloc.free(execveErr);
final pty = NativePty._(fd, pid);
pty._spawnReader(); pty._spawnReader();
return pty; return pty;
} }
static void _freeAll(
ffi.Pointer shell,
ffi.Pointer<ffi.Pointer<ffi.Char>> argv,
int argc,
ffi.Pointer<ffi.Pointer<ffi.Char>> envp,
int envc,
ffi.Pointer wd,
ffi.Pointer fdOut,
ffi.Pointer ws,
) {
malloc.free(shell);
for (var i = 0; i < argc; i++) {
malloc.free(argv[i]);
}
malloc.free(argv);
for (var i = 0; i < envc; i++) {
malloc.free(envp[i]);
}
malloc.free(envp);
malloc.free(wd);
calloc.free(fdOut);
calloc.free(ws);
}
// -- I/O ------------------------------------------------------------------ // -- I/O ------------------------------------------------------------------
void _spawnReader() { void _spawnReader() {
@@ -236,6 +304,21 @@ class NativePty {
final rp = ReceivePort(); final rp = ReceivePort();
_readerPort = rp; _readerPort = rp;
_readerExited = Completer<void>(); _readerExited = Completer<void>();
// Register the listener BEFORE spawning the isolate. ReceivePort buffers
// messages until a listener attaches, but registering first removes any
// ambiguity if the listen() call ever moves further away from spawn (and
// sidesteps a real race we saw 1-in-10 in CI where output never arrived).
rp.listen((msg) {
if (msg == null) {
if (!_out.isClosed) _out.close();
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
_reap();
} else {
if (!_out.isClosed) _out.add(msg as Uint8List);
}
});
try { try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd)); _readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
} catch (e) { } catch (e) {
@@ -248,17 +331,6 @@ class NativePty {
if (!_readerExited!.isCompleted) _readerExited!.complete(); if (!_readerExited!.isCompleted) _readerExited!.complete();
return; return;
} }
rp.listen((msg) {
if (msg == null) {
if (!_out.isClosed) _out.close();
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
_reap();
} else {
if (!_out.isClosed) _out.add(msg as Uint8List);
}
});
} }
/// Isolate entry — polls then reads until EOF/error/fd-closed. /// Isolate entry — polls then reads until EOF/error/fd-closed.
+4 -3
View File
@@ -1,6 +1,7 @@
/// PTY subsystem — spawn child processes under a PTY via forkpty(), /// PTY subsystem — spawn child processes under a PTY via posix_openpt()
/// expose their master fd as a byte stream. Desktop IDE's pane model /// + posix_spawn(), expose their master fd as a byte stream. Desktop
/// (terminal / Claude / future tmux wrappers) rides on this. /// IDE's pane model (terminal / Claude / future tmux wrappers) rides on
/// this.
library; library;
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv; export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
+9 -10
View File
@@ -44,20 +44,19 @@ void main() {
test('output events base64-encode the child bytes', tags: ['forkpty'], () async { test('output events base64-encode the child bytes', tags: ['forkpty'], () async {
await registry.spawn( await registry.spawn(
kind: PaneKind.terminal, kind: PaneKind.terminal,
argv: const ['/bin/echo', 'hello-panes'], // Child writes then lingers so the reader's poll has a wide
// window to see POLLIN before HUP.
argv: const ['/bin/sh', '-c', 'printf hello-panes; sleep 0.25'],
); );
// /bin/echo closes its pty quickly. Wait briefly for output + final deadline = DateTime.now().add(const Duration(seconds: 2));
// the resulting pane.exit event to settle. String decoded() => sink.ofKind('pane.output').map((e) => utf8.decode(base64Decode(e.data['bytes_b64']! as String))).join();
for (var i = 0; i < 30; i++) { while (!decoded().contains('hello-panes') && DateTime.now().isBefore(deadline)) {
if (sink.ofKind('pane.output').isNotEmpty && sink.ofKind('pane.exit').isNotEmpty) break; await Future<void>.delayed(const Duration(milliseconds: 25));
await Future<void>.delayed(const Duration(milliseconds: 100));
} }
final out = sink.ofKind('pane.output').toList(); expect(sink.ofKind('pane.output'), isNotEmpty);
expect(out, isNotEmpty); expect(decoded(), contains('hello-panes'));
final decoded = out.map((e) => utf8.decode(base64Decode(e.data['bytes_b64']! as String))).join();
expect(decoded, contains('hello-panes'));
}); });
test('write + resize emit no spurious events, update state', () async { test('write + resize emit no spurious events, update state', () async {
+25 -34
View File
@@ -1,19 +1,21 @@
/// NativePty smoke tests. /// NativePty smoke tests.
/// ///
/// Exercises forkpty() end-to-end: spawn → child output through the /// Exercises posix_spawn() end-to-end: spawn → child output through the
/// reader isolate. Linux + macOS only; skipped elsewhere. /// reader isolate. Linux + macOS only; skipped elsewhere.
/// ///
/// Tagged `forkpty` — must run via `dart test`, not `flutter test`. /// Per-test `tags: ['forkpty']` marks the tests that need `dart test`
/// forkpty() forks the Flutter engine's multi-threaded process; the /// rather than the flutter test runner — currently just the
/// child exec's fine but the master fd never produces readable output /// write/read-back bidirectional test (writes to the master fd never
/// inside the flutter test runner. /// reach the child under the flutter test runner; reads work fine).
@Tags(['forkpty']) /// Everything else runs under `flutter test` and contributes to
/// coverage.
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:clide/src/pty/errors.dart';
import 'package:clide/src/pty/native_pty.dart'; import 'package:clide/src/pty/native_pty.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@@ -48,7 +50,7 @@ void main() {
expect(buf.toString(), contains('hello-pty')); expect(buf.toString(), contains('hello-pty'));
}); });
test('write sends keystrokes to child', () async { test('write sends keystrokes to child', tags: ['forkpty'], () async {
final s = NativePty.start( final s = NativePty.start(
executable: '/bin/sh', executable: '/bin/sh',
arguments: [], arguments: [],
@@ -122,9 +124,12 @@ void main() {
expect(buf.toString(), contains('path-resolution-ok')); expect(buf.toString(), contains('path-resolution-ok'));
}); });
test('non-existent workingDirectory produces the chdir-failed diagnostic', () async { test('non-existent workingDirectory surfaces a PtyException at spawn time', () {
// chdir() fails in the child → writes diagnostic + _exit(1). // posix_spawn returns ENOENT (errno 2) when the file_actions chdir
final s = NativePty.start( // step finds the directory missing — propagates as a thrown
// PtyException, not a child-side diagnostic on the pty.
expect(
() => NativePty.start(
executable: '/bin/sh', executable: '/bin/sh',
arguments: ['-c', 'echo should-not-run'], arguments: ['-c', 'echo should-not-run'],
columns: 80, columns: 80,
@@ -134,22 +139,17 @@ void main() {
...Platform.environment, ...Platform.environment,
'TERM': 'xterm-256color', 'TERM': 'xterm-256color',
}, },
),
throwsA(isA<PtyException>().having((e) => e.errno, 'errno', 2)),
); );
addTearDown(s.close);
final buf = StringBuffer();
final done = Completer<void>();
s.output.listen(
(b) => buf.write(utf8.decode(b, allowMalformed: true)),
onDone: () {
if (!done.isCompleted) done.complete();
},
);
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {});
expect(buf.toString(), contains('chdir failed'));
}); });
test('non-existent executable produces the exec-failed diagnostic', () async { test('non-existent executable surfaces a PtyException at spawn time', () {
final s = NativePty.start( // posix_spawn surfaces exec-time errors as a non-zero return on
// glibc (which uses vfork — the child is suspended until execve
// either succeeds or fails). ENOENT (errno 2) for missing binary.
expect(
() => NativePty.start(
executable: '/tmp/clide-no-such-binary-${DateTime.now().microsecondsSinceEpoch}', executable: '/tmp/clide-no-such-binary-${DateTime.now().microsecondsSinceEpoch}',
arguments: const [], arguments: const [],
columns: 80, columns: 80,
@@ -159,18 +159,9 @@ void main() {
...Platform.environment, ...Platform.environment,
'TERM': 'xterm-256color', 'TERM': 'xterm-256color',
}, },
),
throwsA(isA<PtyException>().having((e) => e.errno, 'errno', 2)),
); );
addTearDown(s.close);
final buf = StringBuffer();
final done = Completer<void>();
s.output.listen(
(b) => buf.write(utf8.decode(b, allowMalformed: true)),
onDone: () {
if (!done.isCompleted) done.complete();
},
);
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {});
expect(buf.toString(), contains('exec failed'));
}); });
test('resize on a live PTY does not throw', () async { test('resize on a live PTY does not throw', () async {