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:
@@ -73,6 +73,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-1: CLI-first, not MCP
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -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))
|
||||
- **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-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.
|
||||
- **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.
|
||||
@@ -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`.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user