remove dissolved daemon, retire ptyc, fix golden cross-platform
Complete three overdue cleanups discovered during macOS health check: D-56 daemon dissolution: delete bin/clide.dart, DaemonServer, and orphaned tests (test/cli/, subprocess_test, in_process_test). Update stale "clide --daemon" references in i18n catalogs, error messages, editor_commands, CI scripts, and decision records. ptyc retirement: delete ptyc/ source tree, PtySession, scm_rights. Remove from Toolchain resolution, ToolCheck gate, backend serialization, testmode harness, Makefile, CI, and sandbox entitlements. PTY spawning uses NativePty (Dart FFI forkpty) since the terminal was absorbed in-tree. D-5 amended. Golden tests: wire the existing but never-applied clideGoldenConfig via flutter_test_config.dart. Disable CI goldens (Skia anti-aliasing differs between macOS/Linux even with Ahem). Keep platform-keyed goldens only — goldens/linux/ and goldens/macos/ each run on their own OS. Test suite: 826 pass, 0 fail on macOS (was 829 pass, 11 fail). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6b7290dc42
commit
a6eca2561b
@@ -26,10 +26,6 @@ tools/ui/test-results/
|
|||||||
tools/ui/playwright-report/
|
tools/ui/playwright-report/
|
||||||
tools/ui/.serve.pid
|
tools/ui/.serve.pid
|
||||||
|
|
||||||
# -- ptyc (C supporter tool) --------------------------------------------
|
|
||||||
/ptyc/bin/
|
|
||||||
/ptyc/*.o
|
|
||||||
|
|
||||||
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
||||||
/native/dugite/
|
/native/dugite/
|
||||||
|
|
||||||
|
|||||||
+175
-175
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,26 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56 dissolution.
|
||||||
|
The separate daemon process was dissolved on 2026-04-23 but the entry
|
||||||
|
point and socket server class were never actually deleted. Gone now,
|
||||||
|
along with orphaned tests (`test/cli/`, `test/daemon/subprocess_test`,
|
||||||
|
`test/daemon/in_process_test`), stale i18n strings, and "start
|
||||||
|
`clide --daemon`" error messages.
|
||||||
|
- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY
|
||||||
|
spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old
|
||||||
|
C helper and its Dart wiring were never cleaned up. Removed from
|
||||||
|
toolchain resolution, `ToolCheck` gate, backend serialization,
|
||||||
|
testmode harness, CI scripts, Makefile, and sandbox entitlements.
|
||||||
|
D-5 amended to record the retirement.
|
||||||
|
- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing
|
||||||
|
of geometric shapes differs between macOS and Linux even with the
|
||||||
|
Ahem font, so a single set of CI goldens can't serve both platforms.
|
||||||
|
Replaced with platform-keyed goldens (`goldens/linux/`,
|
||||||
|
`goldens/macos/`), each only compared on its own OS.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Pre-push coverage gate — `make push-check` (and the
|
- Pre-push coverage gate — `make push-check` (and the
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## What clide is
|
## What clide is
|
||||||
|
|
||||||
An IDE for Claude Code CLI. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach.
|
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).
|
- **`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.
|
||||||
- **[`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.
|
||||||
- **`ptyc/`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter).
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -19,10 +18,10 @@ Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisio
|
|||||||
These are load-bearing. Violating any means the design is wrong, not the rule.
|
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). The CLI surface for Claude is a thin C client (ptyc peer).
|
- **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`](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`](decisions/architecture.md#d-1-cli-first-not-mcp).
|
||||||
- **Dart is the core; native supporter tools fill specific gaps.** `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-5`](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 (`forkpty`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
|
||||||
- **Own the rendering stack.** PTY (via `ptyc`), 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`](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`](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`](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`](decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||||
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
|
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
|
||||||
@@ -46,8 +45,7 @@ lib/
|
|||||||
test/ # All tests (core subsystems + widgets + goldens + a11y)
|
test/ # All tests (core subsystems + widgets + goldens + a11y)
|
||||||
assets/ # Fonts, themes, grammars, licenses, logo
|
assets/ # Fonts, themes, grammars, licenses, logo
|
||||||
linux/, macos/, web/ # Flutter platform directories
|
linux/, macos/, web/ # Flutter platform directories
|
||||||
ptyc/ # C PTY helper
|
native/ # Vendored native libs (libtree-sitter.so, dugite)
|
||||||
native/ # Vendored native libs (libtree-sitter.so)
|
|
||||||
decisions/ # D/Q/R records
|
decisions/ # D/Q/R records
|
||||||
docs/ # Design docs, wireframes
|
docs/ # Design docs, wireframes
|
||||||
legacy/ # Python Textual clide v1.2 (frozen)
|
legacy/ # Python Textual clide v1.2 (frozen)
|
||||||
@@ -57,7 +55,7 @@ legacy/ # Python Textual clide v1.2 (frozen)
|
|||||||
|
|
||||||
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
||||||
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml).
|
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml).
|
||||||
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
|
- **Native deps (dugite, libtree-sitter):** vendored in `native/`, pinned by SHA. Bumps follow the same advisory-review + `licenses.yaml` rule.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -71,7 +69,6 @@ make test-a11y # accessibility contract tests
|
|||||||
make test-integration# real app boot integration tests
|
make test-integration# real app boot integration tests
|
||||||
make build-linux # flutter build linux
|
make build-linux # flutter build linux
|
||||||
make build-macos # flutter build macos
|
make build-macos # flutter build macos
|
||||||
make ptyc-build # build the ptyc PTY-spawn helper
|
|
||||||
make push-check # pre-push gate: decisions + core + fast tests + a11y
|
make push-check # pre-push gate: decisions + core + fast tests + a11y
|
||||||
make hooks # install the repo's git hooks (one-time setup)
|
make hooks # install the repo's git hooks (one-time setup)
|
||||||
make clean # remove build artefacts
|
make clean # remove build artefacts
|
||||||
|
|||||||
@@ -234,40 +234,11 @@ dugite-fetch: ## Download and extract the dugite-native git distribution.
|
|||||||
dugite-clean: ## Remove the dugite-native directory.
|
dugite-clean: ## Remove the dugite-native directory.
|
||||||
rm -rf $(DUGITE_DIR)
|
rm -rf $(DUGITE_DIR)
|
||||||
|
|
||||||
# -- ptyc (C supporter tool) ---------------------------------------------
|
|
||||||
|
|
||||||
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
|
||||||
|
|
||||||
.PHONY: ptyc-build
|
|
||||||
ptyc-build: ## Build the ptyc PTY-spawn helper.
|
|
||||||
ifeq ($(PTYC_PRESENT),yes)
|
|
||||||
$(MAKE) -C ptyc
|
|
||||||
else
|
|
||||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: ptyc-test
|
|
||||||
ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip).
|
|
||||||
ifeq ($(PTYC_PRESENT),yes)
|
|
||||||
$(MAKE) -C ptyc test
|
|
||||||
else
|
|
||||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: ptyc-clean
|
|
||||||
ptyc-clean: ## Clean ptyc build artefacts.
|
|
||||||
ifeq ($(PTYC_PRESENT),yes)
|
|
||||||
$(MAKE) -C ptyc clean
|
|
||||||
else
|
|
||||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
|
||||||
endif
|
|
||||||
|
|
||||||
# -- security -------------------------------------------------------------
|
# -- security -------------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: security
|
.PHONY: security
|
||||||
security: ## Dart advisory review + ptyc source review.
|
security: ## Dart advisory review.
|
||||||
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps;"
|
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
|
||||||
@echo " ptyc is reviewed by reading it (tiny libc-only C)."
|
|
||||||
|
|
||||||
# -- pre-push gate --------------------------------------------------------
|
# -- pre-push gate --------------------------------------------------------
|
||||||
|
|
||||||
@@ -288,6 +259,5 @@ hooks: ## Install the repo's git hooks.
|
|||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## Remove build artefacts.
|
clean: ## Remove build artefacts.
|
||||||
rm -rf build .dart_tool
|
rm -rf build .dart_tool
|
||||||
$(MAKE) ptyc-clean
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|||||||
-434
@@ -1,434 +0,0 @@
|
|||||||
// clide — CLI + daemon entry point.
|
|
||||||
//
|
|
||||||
// One binary, two modes (per D-005):
|
|
||||||
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
|
|
||||||
// sends a request, prints the response, exits with the D-006
|
|
||||||
// exit code.
|
|
||||||
// * `clide --daemon` — long-running; owns the socket, dispatches
|
|
||||||
// requests. Subsystems (pane, files, editor, …) register handlers
|
|
||||||
// at boot.
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
|
||||||
import 'package:clide/kernel/src/toolchain.dart';
|
|
||||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
|
||||||
// daemon-subsystem wiring that the Flutter app doesn't need and
|
|
||||||
// can't compile for web. See lib/clide.dart for the barrel split.
|
|
||||||
import 'package:clide/src/daemon/editor_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/files_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/git_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/pane_commands.dart';
|
|
||||||
import 'package:clide/src/daemon/pql_commands.dart';
|
|
||||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
|
||||||
import 'package:clide/src/panes/registry.dart';
|
|
||||||
|
|
||||||
Future<void> main(List<String> argv) async {
|
|
||||||
if (argv.isEmpty) {
|
|
||||||
_printHelp(stdout);
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (argv.first == '--daemon') {
|
|
||||||
await _runDaemon(argv.sublist(1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier-2 single-word shortcuts (per CLAUDE.md). Each maps a flat
|
|
||||||
// positional argv into the structured IPC shape of the canonical
|
|
||||||
// editor.* / pane.* verb. Keeps Claude's tool-use pattern short.
|
|
||||||
final rest = argv.sublist(1);
|
|
||||||
switch (argv.first) {
|
|
||||||
case '--version':
|
|
||||||
case 'version':
|
|
||||||
await _runCliArgs('version', const {}, exitOnOk: true);
|
|
||||||
case '--help':
|
|
||||||
case '-h':
|
|
||||||
case 'help':
|
|
||||||
_printHelp(stdout);
|
|
||||||
exit(0);
|
|
||||||
case 'ping':
|
|
||||||
await _runCliArgs('ping', const {}, exitOnOk: true);
|
|
||||||
case 'open':
|
|
||||||
if (rest.isEmpty) _die('usage: clide open <path>');
|
|
||||||
await _runCliArgs('editor.open', {'path': rest.first}, exitOnOk: true);
|
|
||||||
case 'active':
|
|
||||||
await _runCliArgs('editor.active', const {}, exitOnOk: true);
|
|
||||||
case 'insert':
|
|
||||||
final text = await _readTextArg(rest);
|
|
||||||
await _runCliArgs('editor.insert', {'text': text}, exitOnOk: true);
|
|
||||||
case 'replace-selection':
|
|
||||||
final text = await _readTextArg(rest);
|
|
||||||
await _runCliArgs(
|
|
||||||
'editor.replace-selection',
|
|
||||||
{'text': text},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
case 'save':
|
|
||||||
await _runCliArgs('editor.save', const {}, exitOnOk: true);
|
|
||||||
case 'git':
|
|
||||||
await _runGit(rest);
|
|
||||||
case 'tail':
|
|
||||||
await _runTail(rest);
|
|
||||||
default:
|
|
||||||
// Unknown-to-the-CLI commands still go over IPC — the daemon is
|
|
||||||
// authoritative about what's registered. Lets extensions add
|
|
||||||
// subcommands without the CLI caring. Args forward as-is under
|
|
||||||
// {argv: [...]} so daemon-side can parse whatever shape it wants.
|
|
||||||
await _runCliArgs(
|
|
||||||
argv.first,
|
|
||||||
{'argv': rest},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _printHelp(IOSink sink) {
|
|
||||||
sink.writeln('''
|
|
||||||
clide $clideVersion — IDE for Claude Code CLI.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
clide --daemon Run the long-running daemon process.
|
|
||||||
clide <subcommand> Run a one-shot subcommand against the daemon.
|
|
||||||
|
|
||||||
Built-in subcommands:
|
|
||||||
ping Round-trip a ping to the daemon.
|
|
||||||
version Print the clide version.
|
|
||||||
help Print this help.
|
|
||||||
|
|
||||||
Editor (tier 2):
|
|
||||||
open <path> Open a file in the editor (editor.open).
|
|
||||||
active Print the active buffer (editor.active).
|
|
||||||
insert <text|-> Insert text at the cursor in the active buffer.
|
|
||||||
`-` reads text from stdin.
|
|
||||||
replace-selection <…> Replace the selected text in the active buffer.
|
|
||||||
`-` reads text from stdin.
|
|
||||||
save Save the active buffer (editor.save).
|
|
||||||
|
|
||||||
Git (tier 3):
|
|
||||||
git status Working tree status (staged/unstaged/conflicts).
|
|
||||||
git diff [--staged] [P] Diff for file(s) P, or all if omitted.
|
|
||||||
git stage <paths…> Stage files (git add).
|
|
||||||
git stage-all Stage everything (git add -A).
|
|
||||||
git unstage [paths…] Unstage files (git reset HEAD).
|
|
||||||
git discard <paths…> Discard unstaged changes in files.
|
|
||||||
git commit "<msg>" Commit staged changes.
|
|
||||||
git log [--count N] Recent commit log (default 20).
|
|
||||||
git stash [--message M] Stash working changes.
|
|
||||||
git stash-pop Pop the top stash entry.
|
|
||||||
git pull Pull from remote.
|
|
||||||
git push [remote] [br] Push to remote.
|
|
||||||
|
|
||||||
Event subscription:
|
|
||||||
tail --events [--filter SUBSYSTEM[:ID]]
|
|
||||||
Stream events as JSON lines. --filter keeps
|
|
||||||
only events from one subsystem, optionally
|
|
||||||
narrowed to a single id. Exits on SIGINT.
|
|
||||||
|
|
||||||
Any other subcommand is forwarded to the daemon; registered handlers
|
|
||||||
(e.g. `clide git status` once `builtin.git` lands) resolve there.
|
|
||||||
Matches D-006's exit-code contract:
|
|
||||||
0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict
|
|
||||||
''');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runDaemon(List<String> args) async {
|
|
||||||
final socketPath = defaultSocketPath();
|
|
||||||
final dispatcher = DaemonDispatcher();
|
|
||||||
late final DaemonServer server;
|
|
||||||
server = DaemonServer(
|
|
||||||
socketPath: socketPath,
|
|
||||||
dispatch: dispatcher.dispatch,
|
|
||||||
);
|
|
||||||
final toolchain = Toolchain();
|
|
||||||
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
|
||||||
|
|
||||||
final events = _ServerEventSink(server);
|
|
||||||
final registry = PaneRegistry(events: events);
|
|
||||||
registerPaneCommands(dispatcher, registry);
|
|
||||||
|
|
||||||
final files = FilesService.atCwd(events: events);
|
|
||||||
registerFilesCommands(dispatcher, files);
|
|
||||||
|
|
||||||
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
|
||||||
registerEditorCommands(dispatcher, editor);
|
|
||||||
final gitClient = GitClient(toolchain: toolchain, workDir: files.root);
|
|
||||||
registerGitCommands(dispatcher, gitClient, events);
|
|
||||||
|
|
||||||
final pql = PqlClient(workDir: files.root, toolchain: toolchain);
|
|
||||||
registerPqlCommands(dispatcher, pql);
|
|
||||||
|
|
||||||
final stopping = Completer<void>();
|
|
||||||
void shutdown(ProcessSignal sig) {
|
|
||||||
if (!stopping.isCompleted) {
|
|
||||||
stderr.writeln('clide daemon: received ${sig.toString()}, shutting down');
|
|
||||||
stopping.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessSignal.sigint.watch().listen(shutdown);
|
|
||||||
ProcessSignal.sigterm.watch().listen(shutdown);
|
|
||||||
|
|
||||||
await server.start();
|
|
||||||
await stopping.future;
|
|
||||||
await registry.shutdown();
|
|
||||||
await editor.shutdown();
|
|
||||||
await files.shutdown();
|
|
||||||
await server.stop();
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Thin adapter: the server doesn't `implement DaemonEventSink` itself
|
|
||||||
/// (that would tie ipc/ to panes/); instead the daemon entrypoint wraps
|
|
||||||
/// it at the seam where both are known.
|
|
||||||
class _ServerEventSink implements DaemonEventSink {
|
|
||||||
_ServerEventSink(this._server);
|
|
||||||
final DaemonServer _server;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void emit(IpcEvent event) => _server.broadcast(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CLI helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Read the "text" argument for insert / replace-selection. A lone
|
|
||||||
/// `-` means "slurp stdin"; anything else is concatenated into the
|
|
||||||
/// text body (so `clide insert hello world` emits "hello world").
|
|
||||||
Future<String> _readTextArg(List<String> rest) async {
|
|
||||||
if (rest.isEmpty) _die('usage: clide <verb> <text> (or `-` to read stdin)');
|
|
||||||
if (rest.length == 1 && rest.first == '-') {
|
|
||||||
final bytes = <int>[];
|
|
||||||
await for (final chunk in stdin) {
|
|
||||||
bytes.addAll(chunk);
|
|
||||||
}
|
|
||||||
return utf8.decode(bytes);
|
|
||||||
}
|
|
||||||
return rest.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Socket> _connectSocket() async {
|
|
||||||
final socketPath = defaultSocketPath();
|
|
||||||
try {
|
|
||||||
return await Socket.connect(
|
|
||||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.toolError,
|
|
||||||
kind: IpcErrorKind.toolError,
|
|
||||||
message: 'daemon not reachable at $socketPath',
|
|
||||||
hint: 'run `clide --daemon` in another terminal.',
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.toolError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runCliArgs(
|
|
||||||
String cmd,
|
|
||||||
Map<String, Object?> args, {
|
|
||||||
required bool exitOnOk,
|
|
||||||
}) async {
|
|
||||||
final socket = await _connectSocket();
|
|
||||||
final request = IpcRequest(id: '1', cmd: cmd, args: args);
|
|
||||||
socket.writeln(request.encode());
|
|
||||||
|
|
||||||
// Responses come back on the same socket. Events may be interleaved
|
|
||||||
// (the daemon broadcasts), so we skip events until we see the
|
|
||||||
// response whose id matches our request.
|
|
||||||
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await for (final line in lines) {
|
|
||||||
if (line.isEmpty) continue;
|
|
||||||
final msg = IpcMessage.decode(line);
|
|
||||||
if (msg is! IpcResponse) continue;
|
|
||||||
if (msg.id != request.id) continue;
|
|
||||||
await socket.close();
|
|
||||||
if (msg.ok) {
|
|
||||||
stdout.writeln(jsonEncode(msg.data));
|
|
||||||
if (exitOnOk) exit(IpcExitCode.ok);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
final err = msg.error!;
|
|
||||||
_emitError(
|
|
||||||
code: err.code,
|
|
||||||
kind: err.kind,
|
|
||||||
message: err.message,
|
|
||||||
hint: err.hint,
|
|
||||||
);
|
|
||||||
exit(err.code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.toolError,
|
|
||||||
kind: IpcErrorKind.toolError,
|
|
||||||
message: 'daemon closed socket before responding',
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.toolError);
|
|
||||||
} on FormatException catch (e) {
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.toolError,
|
|
||||||
kind: IpcErrorKind.toolError,
|
|
||||||
message: 'bad response from daemon: $e',
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.toolError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `clide git <verb> [args]` — maps to `git.*` IPC verbs.
|
|
||||||
Future<void> _runGit(List<String> args) async {
|
|
||||||
if (args.isEmpty) {
|
|
||||||
_die('usage: clide git <verb> [args…]');
|
|
||||||
}
|
|
||||||
switch (args.first) {
|
|
||||||
case 'status':
|
|
||||||
await _runCliArgs('git.status', const {}, exitOnOk: true);
|
|
||||||
case 'diff':
|
|
||||||
final staged = args.contains('--staged');
|
|
||||||
final paths = args.sublist(1).where((a) => !a.startsWith('--')).toList();
|
|
||||||
await _runCliArgs(
|
|
||||||
'git.diff',
|
|
||||||
{'staged': staged, if (paths.isNotEmpty) 'paths': paths},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
case 'stage':
|
|
||||||
final paths = args.sublist(1);
|
|
||||||
if (paths.isEmpty) _die('usage: clide git stage <path…>');
|
|
||||||
await _runCliArgs('git.stage', {'paths': paths}, exitOnOk: true);
|
|
||||||
case 'stage-all':
|
|
||||||
await _runCliArgs('git.stage-all', const {}, exitOnOk: true);
|
|
||||||
case 'unstage':
|
|
||||||
final paths = args.sublist(1);
|
|
||||||
await _runCliArgs('git.unstage', {'paths': paths}, exitOnOk: true);
|
|
||||||
case 'discard':
|
|
||||||
final paths = args.sublist(1);
|
|
||||||
if (paths.isEmpty) _die('usage: clide git discard <path…>');
|
|
||||||
await _runCliArgs('git.discard', {'paths': paths}, exitOnOk: true);
|
|
||||||
case 'commit':
|
|
||||||
if (args.length < 2) _die('usage: clide git commit "<message>"');
|
|
||||||
final message = args.sublist(1).join(' ');
|
|
||||||
await _runCliArgs('git.commit', {'message': message}, exitOnOk: true);
|
|
||||||
case 'log':
|
|
||||||
var count = 20;
|
|
||||||
for (var i = 1; i < args.length; i++) {
|
|
||||||
if (args[i] == '--count' && i + 1 < args.length) {
|
|
||||||
count = int.tryParse(args[i + 1]) ?? 20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _runCliArgs('git.log', {'count': count}, exitOnOk: true);
|
|
||||||
case 'stash':
|
|
||||||
String? message;
|
|
||||||
for (var i = 1; i < args.length; i++) {
|
|
||||||
if (args[i] == '--message' && i + 1 < args.length) {
|
|
||||||
message = args[i + 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _runCliArgs(
|
|
||||||
'git.stash',
|
|
||||||
{if (message != null) 'message': message},
|
|
||||||
exitOnOk: true,
|
|
||||||
);
|
|
||||||
case 'stash-pop':
|
|
||||||
await _runCliArgs('git.stash-pop', const {}, exitOnOk: true);
|
|
||||||
case 'pull':
|
|
||||||
await _runCliArgs('git.pull', const {}, exitOnOk: true);
|
|
||||||
case 'push':
|
|
||||||
final rest = args.sublist(1);
|
|
||||||
final setUpstream = rest.contains('-u');
|
|
||||||
final positional = rest.where((a) => a != '-u').toList();
|
|
||||||
await _runCliArgs(
|
|
||||||
'git.push',
|
|
||||||
{
|
|
||||||
if (setUpstream) 'setUpstream': true,
|
|
||||||
if (positional.isNotEmpty) 'remote': positional[0],
|
|
||||||
if (positional.length > 1) 'branch': positional[1],
|
|
||||||
},
|
|
||||||
exitOnOk: true);
|
|
||||||
default:
|
|
||||||
_die('unknown git verb: ${args.first}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events.
|
|
||||||
Future<void> _runTail(List<String> args) async {
|
|
||||||
// Parse flags: --events (required today; keeps us honest when more
|
|
||||||
// modes like --history land), --filter SUBSYSTEM[:ID].
|
|
||||||
var wantEvents = false;
|
|
||||||
String? filterSubsystem;
|
|
||||||
String? filterId;
|
|
||||||
for (var i = 0; i < args.length; i++) {
|
|
||||||
final a = args[i];
|
|
||||||
if (a == '--events') {
|
|
||||||
wantEvents = true;
|
|
||||||
} else if (a == '--filter') {
|
|
||||||
if (i + 1 >= args.length) _die('--filter requires an argument');
|
|
||||||
final spec = args[++i];
|
|
||||||
final colon = spec.indexOf(':');
|
|
||||||
if (colon < 0) {
|
|
||||||
filterSubsystem = spec;
|
|
||||||
} else {
|
|
||||||
filterSubsystem = spec.substring(0, colon);
|
|
||||||
filterId = spec.substring(colon + 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_die('unknown argument: $a');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!wantEvents) _die('clide tail: pass --events');
|
|
||||||
|
|
||||||
final socket = await _connectSocket();
|
|
||||||
|
|
||||||
// Shutdown on SIGINT / SIGTERM — close the socket so the stream
|
|
||||||
// drains and we exit cleanly.
|
|
||||||
void quit() {
|
|
||||||
unawaited(socket.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessSignal.sigint.watch().listen((_) => quit());
|
|
||||||
ProcessSignal.sigterm.watch().listen((_) => quit());
|
|
||||||
|
|
||||||
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await for (final line in lines) {
|
|
||||||
if (line.isEmpty) continue;
|
|
||||||
IpcMessage msg;
|
|
||||||
try {
|
|
||||||
msg = IpcMessage.decode(line);
|
|
||||||
} on FormatException {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (msg is! IpcEvent) continue;
|
|
||||||
if (filterSubsystem != null && msg.subsystem != filterSubsystem) continue;
|
|
||||||
if (filterId != null && msg.data['id'] != filterId) continue;
|
|
||||||
stdout.writeln(line);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await socket.close();
|
|
||||||
}
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitError({
|
|
||||||
required int code,
|
|
||||||
required String kind,
|
|
||||||
required String message,
|
|
||||||
String? hint,
|
|
||||||
}) {
|
|
||||||
final err = IpcError(code: code, kind: kind, message: message, hint: hint);
|
|
||||||
stderr.writeln(jsonEncode(err.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Never _die(String msg) {
|
|
||||||
_emitError(
|
|
||||||
code: IpcExitCode.userError,
|
|
||||||
kind: IpcErrorKind.userError,
|
|
||||||
message: msg,
|
|
||||||
);
|
|
||||||
exit(IpcExitCode.userError);
|
|
||||||
}
|
|
||||||
+1
-7
@@ -20,11 +20,6 @@ if ! command -v dart >/dev/null; then
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v ptyc >/dev/null && [[ ! -x "ptyc/bin/ptyc" ]]; then
|
|
||||||
echo "test-core: building ptyc (required by PTY tests)"
|
|
||||||
make -C ptyc >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
|
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
|
||||||
# tests are faster still. 120s is generous for CI warmup, tiny for a
|
# tests are faster still. 120s is generous for CI warmup, tiny for a
|
||||||
# hang.
|
# hang.
|
||||||
@@ -33,7 +28,7 @@ TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
|||||||
# Run dart test in its own process group so we can kill descendants on
|
# Run dart test in its own process group so we can kill descendants on
|
||||||
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
||||||
# after SIGTERM if the test ignores it.
|
# after SIGTERM if the test ignores it.
|
||||||
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/cli test/pql"
|
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/pql"
|
||||||
|
|
||||||
echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)"
|
echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)"
|
||||||
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
||||||
@@ -42,7 +37,6 @@ if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
|||||||
if [[ $rc -eq 124 ]]; then
|
if [[ $rc -eq 124 ]]; then
|
||||||
echo "test-core: TIMEOUT — killing descendants" >&2
|
echo "test-core: TIMEOUT — killing descendants" >&2
|
||||||
pkill -9 -f "dart test test/" 2>/dev/null || true
|
pkill -9 -f "dart test test/" 2>/dev/null || true
|
||||||
pkill -9 -f "ptyc" 2>/dev/null || true
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
exit $rc
|
exit $rc
|
||||||
|
|||||||
+1
-9
@@ -1,16 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# End-to-end layer: daemon subprocess + web WASM Playwright smoke.
|
# End-to-end layer: web WASM Playwright smoke.
|
||||||
# Neither fits in `make test`; together they're the "everything still
|
|
||||||
# works across process/runtime boundaries" gate.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> build bin/clide (required by daemon subprocess test)"
|
|
||||||
make build
|
|
||||||
|
|
||||||
echo "==> daemon subprocess test"
|
|
||||||
dart test test/daemon/
|
|
||||||
|
|
||||||
echo "==> browser WASM smoke (Playwright)"
|
echo "==> browser WASM smoke (Playwright)"
|
||||||
./tools/ui/build.sh
|
./tools/ui/build.sh
|
||||||
./tools/ui/serve.sh
|
./tools/ui/serve.sh
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ Core, rendering, IPC, kernel, panel manager.
|
|||||||
|
|
||||||
### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
||||||
- **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 and ptyc-as-peer principles survive; 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.
|
||||||
- **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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Test pyramid, drivers, client-side constraint.
|
|||||||
|
|
||||||
### D-23: Test pyramid — seven layers
|
### D-23: Test pyramid — seven layers
|
||||||
- **Date:** 2026-04-21
|
- **Date:** 2026-04-21
|
||||||
- **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build + `clide --daemon` subprocess) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s).
|
- **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s).
|
||||||
- **Rationale:** Each layer catches a distinct regression class. Skipping any layer means that class ships unprotected. Pushed back when earlier rounds proposed "just widget + E2E"; widget can't catch paint regressions (that's golden), E2E can't catch a11y tree drift (that's semantics).
|
- **Rationale:** Each layer catches a distinct regression class. Skipping any layer means that class ships unprotected. Pushed back when earlier rounds proposed "just widget + E2E"; widget can't catch paint regressions (that's golden), E2E can't catch a11y tree drift (that's semantics).
|
||||||
- **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-29](#d-29-pre-push-gate-fast-layer-only)).
|
- **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-29](#d-29-pre-push-gate-fast-layer-only)).
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
// Primary panes leave the tmux session alive so the next launch
|
// Primary panes leave the tmux session alive so the next launch
|
||||||
// re-attaches via `tmux new-session -A` (D-41).
|
// re-attaches via `tmux new-session -A` (D-41).
|
||||||
//
|
//
|
||||||
// pane.close kills the ptyc-spawned tmux *client*; the tmux server
|
// pane.close kills the PTY-spawned tmux *client*; the tmux server
|
||||||
// keeps the session alive. We need an explicit kill-session for
|
// keeps the session alive. We need an explicit kill-session for
|
||||||
// secondaries to actually disappear (D-41 close semantics).
|
// secondaries to actually disappear (D-41 close semantics).
|
||||||
if (id != null && !widget.isPrimary) {
|
if (id != null && !widget.isPrimary) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// tmux server interactions for Claude panes (D-41 lifecycle).
|
/// tmux server interactions for Claude panes (D-41 lifecycle).
|
||||||
///
|
///
|
||||||
/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is
|
/// `pane.close` only kills the PTY-spawned tmux *client*; tmux is
|
||||||
/// client/server, so the server-side session keeps running after the
|
/// client/server, so the server-side session keeps running after the
|
||||||
/// client disconnects. To honour D-41 ("closing a secondary kills that
|
/// client disconnects. To honour D-41 ("closing a secondary kills that
|
||||||
/// tmux session" + "secondary numbering resets between clide runs"),
|
/// tmux session" + "secondary numbering resets between clide runs"),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ipc = _kernelIpc();
|
final ipc = _kernelIpc();
|
||||||
if (ipc == null || !ipc.isConnected) {
|
if (ipc == null || !ipc.isConnected) {
|
||||||
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.');
|
setState(() => _error = 'Backend not connected.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-10
@@ -1,8 +1,5 @@
|
|||||||
/// clide — Dart core library.
|
/// clide — Dart core library.
|
||||||
///
|
///
|
||||||
/// Shared by `bin/clide.dart` (CLI + daemon) and by the Flutter app
|
|
||||||
/// under `app/` (which depends on this package via `path: ../`).
|
|
||||||
///
|
|
||||||
/// See:
|
/// See:
|
||||||
/// * `decisions/architecture.md` `D-005` — layout + language rationale.
|
/// * `decisions/architecture.md` `D-005` — layout + language rationale.
|
||||||
/// * `decisions/architecture.md` `D-006` — CLI + event contract.
|
/// * `decisions/architecture.md` `D-006` — CLI + event contract.
|
||||||
@@ -10,11 +7,10 @@ library;
|
|||||||
|
|
||||||
// Flutter-app-visible surface. Deliberately **does not** export the
|
// Flutter-app-visible surface. Deliberately **does not** export the
|
||||||
// `pty/` or `panes/registry.dart` modules — those import `dart:ffi`
|
// `pty/` or `panes/registry.dart` modules — those import `dart:ffi`
|
||||||
// and pull in the PTY machinery that only runs on desktop. The
|
// and pull in the PTY machinery that only runs on desktop.
|
||||||
// daemon entrypoint (`bin/clide.dart`) imports them via deep paths.
|
|
||||||
//
|
//
|
||||||
// `Pane` + `PaneKind` + the event-sink interfaces travel here because
|
// `Pane` + `PaneKind` + the event-sink interfaces travel here because
|
||||||
// they're pure data types that both the app and the daemon reference.
|
// they're pure data types referenced throughout the app.
|
||||||
|
|
||||||
export 'src/daemon/dispatcher.dart';
|
export 'src/daemon/dispatcher.dart';
|
||||||
export 'src/editor/buffer.dart';
|
export 'src/editor/buffer.dart';
|
||||||
@@ -28,15 +24,14 @@ export 'src/pql/client.dart' show PqlClient, PqlException;
|
|||||||
export 'src/ipc/envelope.dart';
|
export 'src/ipc/envelope.dart';
|
||||||
export 'src/ipc/paths.dart';
|
export 'src/ipc/paths.dart';
|
||||||
export 'src/ipc/schema_v1.dart';
|
export 'src/ipc/schema_v1.dart';
|
||||||
export 'src/ipc/server.dart';
|
|
||||||
export 'src/panes/event_sink.dart';
|
export 'src/panes/event_sink.dart';
|
||||||
export 'src/panes/pane.dart' show Pane, PaneKind;
|
export 'src/panes/pane.dart' show Pane, PaneKind;
|
||||||
|
|
||||||
/// Build-time-stamped version string.
|
/// Build-time-stamped version string.
|
||||||
///
|
///
|
||||||
/// The Makefile's `build` target passes `--define=clideVersion=…` when
|
/// The Makefile's `build` target passes `--define=clideVersion=…`,
|
||||||
/// invoking `dart compile exe`, stamping `project.yaml`'s `version:`
|
/// stamping `pubspec.yaml`'s `version:` plus the git short SHA and
|
||||||
/// plus the git short SHA and dirty marker.
|
/// dirty marker.
|
||||||
const clideVersion = String.fromEnvironment(
|
const clideVersion = String.fromEnvironment(
|
||||||
'clideVersion',
|
'clideVersion',
|
||||||
defaultValue: '2.0.0-dev',
|
defaultValue: '2.0.0-dev',
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class Backend {
|
|||||||
git: tcData['git'] as String?,
|
git: tcData['git'] as String?,
|
||||||
pql: tcData['pql'] as String?,
|
pql: tcData['pql'] as String?,
|
||||||
tmux: tcData['tmux'] as String?,
|
tmux: tcData['tmux'] as String?,
|
||||||
ptyc: tcData['ptyc'] as String?,
|
|
||||||
shell: tcData['shell'] as String?,
|
shell: tcData['shell'] as String?,
|
||||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||||
));
|
));
|
||||||
@@ -86,7 +85,6 @@ class Backend {
|
|||||||
git: tcData['git'] as String?,
|
git: tcData['git'] as String?,
|
||||||
pql: tcData['pql'] as String?,
|
pql: tcData['pql'] as String?,
|
||||||
tmux: tcData['tmux'] as String?,
|
tmux: tcData['tmux'] as String?,
|
||||||
ptyc: tcData['ptyc'] as String?,
|
|
||||||
shell: tcData['shell'] as String?,
|
shell: tcData['shell'] as String?,
|
||||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ void backendEntry(BackendBootMessage boot) {
|
|||||||
late Toolchain toolchain;
|
late Toolchain toolchain;
|
||||||
|
|
||||||
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
||||||
// We need a project root for ptyc/dugite paths. Use a sensible
|
// We need a project root for dugite paths. Use a sensible default;
|
||||||
// default; the real project comes from project.open.
|
// the real project comes from project.open.
|
||||||
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
|
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
|
||||||
toolchain = Toolchain();
|
toolchain = Toolchain();
|
||||||
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
|
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
|
||||||
@@ -78,7 +78,7 @@ void backendEntry(BackendBootMessage boot) {
|
|||||||
final workDir = Directory(projectPath);
|
final workDir = Directory(projectPath);
|
||||||
|
|
||||||
// Re-resolve toolchain with the actual project root (finds
|
// Re-resolve toolchain with the actual project root (finds
|
||||||
// dugite in native/dugite/, ptyc in ptyc/bin/, etc.)
|
// dugite in native/dugite/, etc.)
|
||||||
toolchain = Toolchain();
|
toolchain = Toolchain();
|
||||||
toolchain.applyResolved(resolveToolchainPaths(projectPath));
|
toolchain.applyResolved(resolveToolchainPaths(projectPath));
|
||||||
|
|
||||||
@@ -138,7 +138,6 @@ Map<String, Object?> _serializeToolchain(Toolchain tc) => {
|
|||||||
'git': tc.git,
|
'git': tc.git,
|
||||||
'pql': tc.pql,
|
'pql': tc.pql,
|
||||||
'tmux': tc.tmux,
|
'tmux': tc.tmux,
|
||||||
'ptyc': tc.ptyc,
|
|
||||||
'shell': tc.shell,
|
'shell': tc.shell,
|
||||||
'gitEnv': tc.gitEnv,
|
'gitEnv': tc.gitEnv,
|
||||||
'missing': tc.missing,
|
'missing': tc.missing,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"connected": { "translation": "connected" },
|
"connected": { "translation": "connected" },
|
||||||
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
|
"connected.hint": { "translation": "backend isolate is reachable" },
|
||||||
"disconnected": { "translation": "disconnected" },
|
"disconnected": { "translation": "disconnected" },
|
||||||
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
|
"disconnected.hint": { "translation": "backend isolate is not running" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"subtitle.spawning": { "translation": "spawning shell…" },
|
"subtitle.spawning": { "translation": "spawning shell…" },
|
||||||
"subtitle.exited": { "translation": "Shell exited." },
|
"subtitle.exited": { "translation": "Shell exited." },
|
||||||
"error.unavailable": { "translation": "Terminal unavailable" },
|
"error.unavailable": { "translation": "Terminal unavailable" },
|
||||||
"error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." }
|
"error.daemon": { "translation": "Backend not connected." }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ class DaemonClient extends ChangeNotifier {
|
|||||||
code: IpcExitCode.toolError,
|
code: IpcExitCode.toolError,
|
||||||
kind: IpcErrorKind.toolError,
|
kind: IpcErrorKind.toolError,
|
||||||
message: 'daemon not connected',
|
message: 'daemon not connected',
|
||||||
hint: 'is `clide --daemon` running?',
|
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../../src/pty/env.dart';
|
import '../../src/pty/env.dart';
|
||||||
|
|
||||||
class ToolCheck extends ChangeNotifier {
|
class ToolCheck extends ChangeNotifier {
|
||||||
bool ptycOk = false;
|
|
||||||
bool pqlOk = false;
|
bool pqlOk = false;
|
||||||
bool tmuxOk = false;
|
bool tmuxOk = false;
|
||||||
bool gitOk = false;
|
bool gitOk = false;
|
||||||
bool checked = false;
|
bool checked = false;
|
||||||
|
|
||||||
bool get allOk => ptycOk && pqlOk && tmuxOk && gitOk;
|
bool get allOk => pqlOk && tmuxOk && gitOk;
|
||||||
|
|
||||||
List<String> get errors => [
|
List<String> get errors => [
|
||||||
if (!ptycOk) 'ptyc not found',
|
|
||||||
if (!pqlOk) 'pql not found',
|
if (!pqlOk) 'pql not found',
|
||||||
if (!tmuxOk) 'tmux not found',
|
if (!tmuxOk) 'tmux not found',
|
||||||
if (!gitOk) 'git not found',
|
if (!gitOk) 'git not found',
|
||||||
@@ -24,12 +22,6 @@ class ToolCheck extends ChangeNotifier {
|
|||||||
static String? workspaceRoot;
|
static String? workspaceRoot;
|
||||||
|
|
||||||
Future<void> check() async {
|
Future<void> check() async {
|
||||||
final root = workspaceRoot ?? Directory.current.path;
|
|
||||||
ptycOk = File('$root/native/linux-x64/ptyc').existsSync() ||
|
|
||||||
File('$root/native/macos-arm64/ptyc').existsSync() ||
|
|
||||||
File('$root/native/macos-x64/ptyc').existsSync() ||
|
|
||||||
File('$root/ptyc/bin/ptyc').existsSync() ||
|
|
||||||
_existsOnPath('ptyc');
|
|
||||||
pqlOk = _existsOnPath('pql');
|
pqlOk = _existsOnPath('pql');
|
||||||
tmuxOk = _existsOnPath('tmux');
|
tmuxOk = _existsOnPath('tmux');
|
||||||
gitOk = _existsOnPath('git');
|
gitOk = _existsOnPath('git');
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class ResolvedPaths {
|
|||||||
this.git,
|
this.git,
|
||||||
this.pql,
|
this.pql,
|
||||||
this.tmux,
|
this.tmux,
|
||||||
this.ptyc,
|
|
||||||
this.shell,
|
this.shell,
|
||||||
this.gitEnv,
|
this.gitEnv,
|
||||||
});
|
});
|
||||||
@@ -24,7 +23,6 @@ class ResolvedPaths {
|
|||||||
final String? git;
|
final String? git;
|
||||||
final String? pql;
|
final String? pql;
|
||||||
final String? tmux;
|
final String? tmux;
|
||||||
final String? ptyc;
|
|
||||||
final String? shell;
|
final String? shell;
|
||||||
final Map<String, String>? gitEnv;
|
final Map<String, String>? gitEnv;
|
||||||
}
|
}
|
||||||
@@ -33,7 +31,6 @@ class Toolchain extends ChangeNotifier {
|
|||||||
String? _git;
|
String? _git;
|
||||||
String? _pql;
|
String? _pql;
|
||||||
String? _tmux;
|
String? _tmux;
|
||||||
String? _ptyc;
|
|
||||||
String? _shell;
|
String? _shell;
|
||||||
Map<String, String>? _gitEnv;
|
Map<String, String>? _gitEnv;
|
||||||
bool _resolved = false;
|
bool _resolved = false;
|
||||||
@@ -41,7 +38,6 @@ class Toolchain extends ChangeNotifier {
|
|||||||
String get git => _git ?? 'git';
|
String get git => _git ?? 'git';
|
||||||
String get pql => _pql ?? 'pql';
|
String get pql => _pql ?? 'pql';
|
||||||
String get tmux => _tmux ?? 'tmux';
|
String get tmux => _tmux ?? 'tmux';
|
||||||
String get ptyc => _ptyc ?? 'ptyc';
|
|
||||||
String get shell => _shell ?? '/bin/bash';
|
String get shell => _shell ?? '/bin/bash';
|
||||||
|
|
||||||
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
|
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
|
||||||
@@ -76,7 +72,6 @@ class Toolchain extends ChangeNotifier {
|
|||||||
_git = p.git;
|
_git = p.git;
|
||||||
_pql = p.pql;
|
_pql = p.pql;
|
||||||
_tmux = p.tmux;
|
_tmux = p.tmux;
|
||||||
_ptyc = p.ptyc;
|
|
||||||
_shell = p.shell;
|
_shell = p.shell;
|
||||||
_gitEnv = p.gitEnv;
|
_gitEnv = p.gitEnv;
|
||||||
_resolved = true;
|
_resolved = true;
|
||||||
@@ -106,20 +101,10 @@ class Toolchain extends ChangeNotifier {
|
|||||||
final tmux = _findOnPath('tmux');
|
final tmux = _findOnPath('tmux');
|
||||||
final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
|
final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
|
||||||
|
|
||||||
final ptyc = _firstExisting([
|
|
||||||
'$workspaceRoot/ptyc/bin/ptyc',
|
|
||||||
'$workspaceRoot/native/linux-x64/ptyc',
|
|
||||||
'$workspaceRoot/native/macos-arm64/ptyc',
|
|
||||||
'$workspaceRoot/native/macos-x64/ptyc',
|
|
||||||
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
|
|
||||||
]) ??
|
|
||||||
_findOnPath('ptyc');
|
|
||||||
|
|
||||||
return ResolvedPaths(
|
return ResolvedPaths(
|
||||||
git: git,
|
git: git,
|
||||||
pql: pql,
|
pql: pql,
|
||||||
tmux: tmux,
|
tmux: tmux,
|
||||||
ptyc: ptyc,
|
|
||||||
shell: shell,
|
shell: shell,
|
||||||
gitEnv: gitEnv,
|
gitEnv: gitEnv,
|
||||||
);
|
);
|
||||||
@@ -182,14 +167,6 @@ ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
|||||||
git: git,
|
git: git,
|
||||||
pql: _findOnPathStandalone('pql'),
|
pql: _findOnPathStandalone('pql'),
|
||||||
tmux: _findOnPathStandalone('tmux'),
|
tmux: _findOnPathStandalone('tmux'),
|
||||||
ptyc: _firstExistingStandalone([
|
|
||||||
'$workspaceRoot/ptyc/bin/ptyc',
|
|
||||||
'$workspaceRoot/native/linux-x64/ptyc',
|
|
||||||
'$workspaceRoot/native/macos-arm64/ptyc',
|
|
||||||
'$workspaceRoot/native/macos-x64/ptyc',
|
|
||||||
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
|
|
||||||
]) ??
|
|
||||||
_findOnPathStandalone('ptyc'),
|
|
||||||
shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
||||||
gitEnv: gitEnv,
|
gitEnv: gitEnv,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class LuaHost {
|
|||||||
|
|
||||||
/// Boot the vendored liblua. Throws until Tier 6.
|
/// Boot the vendored liblua. Throws until Tier 6.
|
||||||
static Future<LuaHost> start() async {
|
static Future<LuaHost> start() async {
|
||||||
throw UnsupportedError('Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).');
|
throw UnsupportedError('Lua runtime lands at Tier 6.');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {}
|
Future<void> dispose() async {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
/// editor.read editor.set-selection editor.set-content
|
/// editor.read editor.set-selection editor.set-content
|
||||||
///
|
///
|
||||||
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
|
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
|
||||||
/// one-to-one onto these in `bin/clide.dart`.
|
/// one-to-one onto these via the IPC dispatch layer.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io' show FileSystemException;
|
import 'dart:io' show FileSystemException;
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:clide/src/ipc/envelope.dart';
|
|
||||||
|
|
||||||
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
|
|
||||||
|
|
||||||
/// Default per-request timeout. A handler that doesn't return within
|
|
||||||
/// this window gets a `tool_error` response so the connection's read
|
|
||||||
/// pipeline isn't blocked indefinitely. Long-running commands (git
|
|
||||||
/// pull/push, large pql queries) can override per-command later.
|
|
||||||
const Duration _kDefaultRequestTimeout = Duration(seconds: 60);
|
|
||||||
|
|
||||||
/// Unix-socket JSON-lines server. Each connection is an independent
|
|
||||||
/// bidirectional line-framed stream: client writes requests, daemon
|
|
||||||
/// writes responses + events on the same socket.
|
|
||||||
class DaemonServer {
|
|
||||||
DaemonServer({
|
|
||||||
required this.socketPath,
|
|
||||||
required this.dispatch,
|
|
||||||
Duration requestTimeout = _kDefaultRequestTimeout,
|
|
||||||
}) : _requestTimeout = requestTimeout;
|
|
||||||
|
|
||||||
final String socketPath;
|
|
||||||
final RequestDispatcher dispatch;
|
|
||||||
final Duration _requestTimeout;
|
|
||||||
|
|
||||||
ServerSocket? _server;
|
|
||||||
final Set<Socket> _clients = {};
|
|
||||||
|
|
||||||
/// Broadcast [event] to every currently-connected client. Sockets
|
|
||||||
/// that error on write are dropped — the client's read side will
|
|
||||||
/// notice the close. Errors are logged so silent event loss is
|
|
||||||
/// debuggable.
|
|
||||||
void broadcast(IpcEvent event) {
|
|
||||||
final line = event.encode();
|
|
||||||
for (final c in List<Socket>.from(_clients)) {
|
|
||||||
try {
|
|
||||||
c.writeln(line);
|
|
||||||
} catch (e) {
|
|
||||||
stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e');
|
|
||||||
_clients.remove(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> start() async {
|
|
||||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
|
||||||
try {
|
|
||||||
_server = await ServerSocket.bind(addr, 0);
|
|
||||||
} on SocketException {
|
|
||||||
// Either a stale socket from a prior crash, or a live daemon.
|
|
||||||
// Probe by trying to connect — if a live peer answers, refuse
|
|
||||||
// to start so we don't rip its socket out.
|
|
||||||
try {
|
|
||||||
final probe = await Socket.connect(addr, 0).timeout(const Duration(milliseconds: 200));
|
|
||||||
await probe.close();
|
|
||||||
throw StateError('clide daemon already running at $socketPath');
|
|
||||||
} on TimeoutException {
|
|
||||||
// No one answered — proceed to unlink and rebind.
|
|
||||||
} on SocketException {
|
|
||||||
// No one listening — proceed to unlink and rebind.
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await File(socketPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
_server = await ServerSocket.bind(addr, 0);
|
|
||||||
}
|
|
||||||
stderr.writeln('clide daemon listening on $socketPath');
|
|
||||||
_server!.listen(_handleClient, onError: (e) {
|
|
||||||
stderr.writeln('clide daemon accept error: $e');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stop() async {
|
|
||||||
for (final c in List<Socket>.from(_clients)) {
|
|
||||||
await c.close();
|
|
||||||
}
|
|
||||||
_clients.clear();
|
|
||||||
await _server?.close();
|
|
||||||
_server = null;
|
|
||||||
try {
|
|
||||||
await File(socketPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleClient(Socket client) {
|
|
||||||
_clients.add(client);
|
|
||||||
client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
|
|
||||||
(line) => _handleLine(client, line),
|
|
||||||
onDone: () => _clients.remove(client),
|
|
||||||
onError: (Object e) {
|
|
||||||
stderr.writeln('clide daemon client error: $e');
|
|
||||||
_clients.remove(client);
|
|
||||||
},
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleLine(Socket client, String line) async {
|
|
||||||
if (line.isEmpty) return;
|
|
||||||
IpcMessage? msg;
|
|
||||||
try {
|
|
||||||
msg = IpcMessage.decode(line);
|
|
||||||
} on FormatException catch (e) {
|
|
||||||
stderr.writeln('clide daemon: bad line from client: $e');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg is! IpcRequest) return;
|
|
||||||
IpcResponse resp;
|
|
||||||
try {
|
|
||||||
resp = await dispatch(msg).timeout(_requestTimeout);
|
|
||||||
} on TimeoutException {
|
|
||||||
stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)');
|
|
||||||
resp = IpcResponse.err(
|
|
||||||
id: msg.id,
|
|
||||||
error: IpcError(
|
|
||||||
code: 2,
|
|
||||||
kind: 'tool_error',
|
|
||||||
message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
|
|
||||||
resp = IpcResponse.err(
|
|
||||||
id: msg.id,
|
|
||||||
error: IpcError(
|
|
||||||
code: 2,
|
|
||||||
kind: 'tool_error',
|
|
||||||
message: 'dispatch failed: $e',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
client.writeln(resp.encode());
|
|
||||||
} catch (e) {
|
|
||||||
// Client disconnected mid-dispatch — drop it so future events
|
|
||||||
// don't try to write to a dead socket.
|
|
||||||
stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e');
|
|
||||||
_clients.remove(client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
/// travels cleanly into the Flutter app (which can't depend on
|
/// travels cleanly into the Flutter app (which can't depend on
|
||||||
/// `dart:ffi`-using code for the web build).
|
/// `dart:ffi`-using code for the web build).
|
||||||
///
|
///
|
||||||
/// The daemon's [PaneRegistry] keeps a parallel `PtySession` keyed on
|
/// [PaneRegistry] keeps a parallel [NativePty] keyed on [id] and
|
||||||
/// [id] and mutates [isClosed] when the session exits.
|
/// mutates [isClosed] when the session exits.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
/// Kind of a pane. Keep this enum small and explicit — each kind
|
/// Kind of a pane. Keep this enum small and explicit — each kind
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// [PaneRegistry] — daemon-side state for all live panes.
|
/// [PaneRegistry] — backend-side state for all live panes.
|
||||||
///
|
///
|
||||||
/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards
|
/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards
|
||||||
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
|
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
|
||||||
/// Pane commands (pane.spawn / list / write / resize / close) resolve
|
/// Pane commands (pane.spawn / list / write / resize / close) resolve
|
||||||
/// against this registry; extension UIs subscribe to the emitted events.
|
/// against this registry; extension UIs subscribe to the emitted events.
|
||||||
@@ -31,10 +31,6 @@ class PaneRegistry {
|
|||||||
Pane? get(String id) => _panes[id];
|
Pane? get(String id) => _panes[id];
|
||||||
|
|
||||||
/// Spawn a child under a PTY and wire its output to events.
|
/// Spawn a child under a PTY and wire its output to events.
|
||||||
///
|
|
||||||
/// [ptycPath] is plumbed through to [PtySession.spawn]; callers that
|
|
||||||
/// have a dev-built `ptyc/bin/ptyc` or a non-PATH install can point
|
|
||||||
/// at it explicitly.
|
|
||||||
Future<Pane> spawn({
|
Future<Pane> spawn({
|
||||||
required PaneKind kind,
|
required PaneKind kind,
|
||||||
required List<String> argv,
|
required List<String> argv,
|
||||||
@@ -49,7 +45,7 @@ class PaneRegistry {
|
|||||||
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
|
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
|
||||||
|
|
||||||
// Merge the caller's env on top of the process environment +
|
// Merge the caller's env on top of the process environment +
|
||||||
// terminal defaults, matching the old ptyc contract.
|
// Terminal defaults for the PTY child.
|
||||||
final fullEnv = <String, String>{
|
final fullEnv = <String, String>{
|
||||||
...Platform.environment,
|
...Platform.environment,
|
||||||
'TERM': 'xterm-256color',
|
'TERM': 'xterm-256color',
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ String _buildExpandedPath() {
|
|||||||
return [...missing, ...existing].join(':');
|
return [...missing, ...existing].join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Base env clide's daemon builds for every PTY child. Callers merge
|
/// Base env clide builds for every PTY child. Callers merge with the
|
||||||
/// with the user's environment before passing to `ptyc` — a child that
|
/// user's environment — a child that needs user env like `HOME` /
|
||||||
/// needs user env like `HOME` / `USER` / `SHELL` still gets them; the
|
/// `USER` / `SHELL` still gets them; the keys here override the ones
|
||||||
/// keys here override the ones the child cares about.
|
/// the child cares about.
|
||||||
const Map<String, String> clidePtyEnvDefaults = {
|
const Map<String, String> clidePtyEnvDefaults = {
|
||||||
'TERM': 'xterm-256color',
|
'TERM': 'xterm-256color',
|
||||||
'COLORTERM': 'truecolor',
|
'COLORTERM': 'truecolor',
|
||||||
|
|||||||
@@ -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 (`recvmsg`,
|
/// A PTY operation failed. [op] identifies the step (`forkpty`,
|
||||||
/// `socketpair`, `ptyc`, etc.); [errno] is POSIX errno when the
|
/// `read`, `ioctl`, etc.); [errno] is POSIX errno when the failure
|
||||||
/// failure came from a syscall, otherwise `null`.
|
/// 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});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
/// Raw FFI bindings to the libc functions the PTY wrapper needs.
|
/// Raw FFI bindings to the libc functions the PTY wrapper needs.
|
||||||
///
|
///
|
||||||
/// `dart:io` covers neither `socketpair(2)`, `recvmsg(2)` with ancillary
|
/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
|
||||||
/// data, nor read/write on arbitrary file descriptors — the three
|
/// `ioctl`, or `poll` — FFI is the minimum tool for the job.
|
||||||
/// things the [`ptyc`](../../../ptyc/README.md) fd-transfer protocol
|
|
||||||
/// requires. FFI is the minimum tool for the job.
|
|
||||||
///
|
///
|
||||||
/// Linux + macOS only for now. Windows is covered by platform checks
|
/// Linux + macOS only for now. Windows is covered by platform checks
|
||||||
/// higher up; when Windows support lands it'll need a parallel binding
|
/// higher up; when Windows support lands it'll need a parallel binding
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
/// Receive a single file descriptor over a unix socket via
|
|
||||||
/// `SCM_RIGHTS` ancillary data.
|
|
||||||
///
|
|
||||||
/// Pairs with `ptyc`'s `send_fd()`: the peer sends one byte of payload
|
|
||||||
/// plus the fd in cmsg; this function reads both and returns the fd.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:ffi' as ffi;
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
|
||||||
|
|
||||||
import '../errors.dart';
|
|
||||||
import 'libc.dart' as libc;
|
|
||||||
|
|
||||||
/// Blocks on [socketFd] waiting for a single-byte payload carrying a
|
|
||||||
/// fd over `SCM_RIGHTS`. Returns the received fd on success.
|
|
||||||
///
|
|
||||||
/// Throws a [PlatformException] if `recvmsg` fails or the peer sends
|
|
||||||
/// no ancillary data.
|
|
||||||
int recvFd(int socketFd) {
|
|
||||||
// Layout: one-byte payload buffer + CMSG_SPACE(sizeof(int)) control
|
|
||||||
// buffer. `CMSG_SPACE` is just `ALIGN(sizeof(cmsghdr)) + ALIGN(data)`
|
|
||||||
// — for a single int that's 16 + 4 rounded up to 8 = 24 on 64-bit,
|
|
||||||
// but we over-allocate to 32 to be safe across platforms.
|
|
||||||
const payloadLen = 1;
|
|
||||||
const controlLen = 32;
|
|
||||||
|
|
||||||
final payload = pkg_ffi.calloc<ffi.Uint8>(payloadLen);
|
|
||||||
final control = pkg_ffi.calloc<ffi.Uint8>(controlLen);
|
|
||||||
final iov = pkg_ffi.calloc<libc.Iovec>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
iov.ref.iov_base = payload;
|
|
||||||
iov.ref.iov_len = payloadLen;
|
|
||||||
|
|
||||||
int received;
|
|
||||||
int msgControllen;
|
|
||||||
|
|
||||||
if (Platform.isMacOS) {
|
|
||||||
final msg = pkg_ffi.calloc<libc.MsghdrDarwin>();
|
|
||||||
try {
|
|
||||||
msg.ref.msg_name = ffi.nullptr;
|
|
||||||
msg.ref.msg_namelen = 0;
|
|
||||||
msg.ref.msg_iov = iov;
|
|
||||||
msg.ref.msg_iovlen = 1;
|
|
||||||
msg.ref.msg_control = control.cast();
|
|
||||||
msg.ref.msg_controllen = controlLen;
|
|
||||||
msg.ref.msg_flags = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
received = libc.recvmsgDarwin(socketFd, msg, 0);
|
|
||||||
if (received >= 0) break;
|
|
||||||
final err = libc.errno;
|
|
||||||
if (err == 4 /* EINTR */) continue;
|
|
||||||
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
|
|
||||||
}
|
|
||||||
msgControllen = msg.ref.msg_controllen;
|
|
||||||
} finally {
|
|
||||||
pkg_ffi.calloc.free(msg);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final msg = pkg_ffi.calloc<libc.Msghdr>();
|
|
||||||
try {
|
|
||||||
msg.ref.msg_name = ffi.nullptr;
|
|
||||||
msg.ref.msg_namelen = 0;
|
|
||||||
msg.ref.msg_iov = iov;
|
|
||||||
msg.ref.msg_iovlen = 1;
|
|
||||||
msg.ref.msg_control = control.cast();
|
|
||||||
msg.ref.msg_controllen = controlLen;
|
|
||||||
msg.ref.msg_flags = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
received = libc.recvmsgLinux(socketFd, msg, 0);
|
|
||||||
if (received >= 0) break;
|
|
||||||
final err = libc.errno;
|
|
||||||
if (err == 4 /* EINTR */) continue;
|
|
||||||
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
|
|
||||||
}
|
|
||||||
msgControllen = msg.ref.msg_controllen;
|
|
||||||
} finally {
|
|
||||||
pkg_ffi.calloc.free(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (received == 0 || msgControllen < 16) {
|
|
||||||
throw const PtyException(
|
|
||||||
'recvmsg',
|
|
||||||
'peer closed without sending ancillary data',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the first cmsghdr out of the control buffer. On macOS,
|
|
||||||
// cmsg_len is socklen_t (4 bytes); on Linux it's size_t (8 bytes).
|
|
||||||
int cmsgLevel, cmsgType, dataOffset;
|
|
||||||
if (Platform.isMacOS) {
|
|
||||||
final hdr = control.cast<libc.CmsghdrDarwin>().ref;
|
|
||||||
cmsgLevel = hdr.cmsg_level;
|
|
||||||
cmsgType = hdr.cmsg_type;
|
|
||||||
dataOffset = ffi.sizeOf<libc.CmsghdrDarwin>();
|
|
||||||
} else {
|
|
||||||
final hdr = control.cast<libc.CmsghdrLinux>().ref;
|
|
||||||
cmsgLevel = hdr.cmsg_level;
|
|
||||||
cmsgType = hdr.cmsg_type;
|
|
||||||
dataOffset = ffi.sizeOf<libc.CmsghdrLinux>();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmsgLevel != libc.solSocket || cmsgType != libc.scmRights) {
|
|
||||||
throw PtyException(
|
|
||||||
'recvmsg',
|
|
||||||
'unexpected cmsg level=$cmsgLevel type=$cmsgType',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
|
|
||||||
return fdPtr.value;
|
|
||||||
} finally {
|
|
||||||
pkg_ffi.calloc.free(iov);
|
|
||||||
pkg_ffi.calloc.free(control);
|
|
||||||
pkg_ffi.calloc.free(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
/// Native PTY via forkpty() — replaces the ptyc helper binary.
|
/// Native PTY via forkpty().
|
||||||
///
|
///
|
||||||
/// Uses Dart FFI to call forkpty() directly. The master fd stays
|
/// Uses Dart FFI to call forkpty() directly. The master fd stays
|
||||||
/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate
|
/// in-process. The reader isolate uses poll() for clean shutdown.
|
||||||
/// uses poll() for clean shutdown.
|
|
||||||
///
|
///
|
||||||
/// Based on the pty-spike proof-of-concept. Platform-aware:
|
/// Based on the pty-spike proof-of-concept. Platform-aware:
|
||||||
/// macOS: forkpty in libSystem (DynamicLibrary.process)
|
/// macOS: forkpty in libSystem (DynamicLibrary.process)
|
||||||
@@ -82,8 +81,6 @@ const _kWnohang = 1;
|
|||||||
// -- NativePty --------------------------------------------------------------
|
// -- NativePty --------------------------------------------------------------
|
||||||
|
|
||||||
/// A pseudo-terminal backed by forkpty() via Dart FFI.
|
/// A pseudo-terminal backed by forkpty() via Dart FFI.
|
||||||
///
|
|
||||||
/// Drop-in replacement for the old ptyc-based PtySession.
|
|
||||||
class NativePty {
|
class NativePty {
|
||||||
final int _fd;
|
final int _fd;
|
||||||
final int pid;
|
final int pid;
|
||||||
|
|||||||
@@ -1,442 +0,0 @@
|
|||||||
/// [PtySession] — high-level PTY lifecycle.
|
|
||||||
///
|
|
||||||
/// Spawns `ptyc` with the given argv/cwd/env, receives the master fd
|
|
||||||
/// via `SCM_RIGHTS`, and exposes:
|
|
||||||
///
|
|
||||||
/// - [output] — a broadcast stream of bytes read from the child.
|
|
||||||
/// - [write] — send bytes to the child's stdin.
|
|
||||||
/// - [resize] — change the child's window size.
|
|
||||||
/// - [kill] — send a signal to the child.
|
|
||||||
/// - [close] — close the master fd and stop reading.
|
|
||||||
///
|
|
||||||
/// Reading happens in a background isolate that loops on blocking
|
|
||||||
/// `read(fd)` calls and posts bytes to the main isolate via a
|
|
||||||
/// [ReceivePort]. Closing the fd from the main isolate causes `read()`
|
|
||||||
/// to return EBADF; the isolate sees that and exits.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:ffi' as ffi;
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:isolate';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
|
||||||
|
|
||||||
import 'env.dart';
|
|
||||||
import 'errors.dart';
|
|
||||||
import 'ffi/libc.dart' as libc;
|
|
||||||
import 'ffi/scm_rights.dart' as scm;
|
|
||||||
|
|
||||||
class _RecvFdArgs {
|
|
||||||
const _RecvFdArgs(this.socketFd, this.sendPort);
|
|
||||||
final int socketFd;
|
|
||||||
final SendPort sendPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A running PTY child plus its master-fd plumbing.
|
|
||||||
class PtySession {
|
|
||||||
PtySession._({
|
|
||||||
required this.pid,
|
|
||||||
required int masterFd,
|
|
||||||
}) : _masterFd = masterFd {
|
|
||||||
_startReader();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The spawned child's PID (not ptyc's — ptyc has already exited).
|
|
||||||
final int pid;
|
|
||||||
|
|
||||||
int _masterFd;
|
|
||||||
|
|
||||||
final _outputCtrl = StreamController<Uint8List>.broadcast();
|
|
||||||
final _readerExited = Completer<void>();
|
|
||||||
Isolate? _readerIsolate;
|
|
||||||
ReceivePort? _readerPort;
|
|
||||||
|
|
||||||
/// Broadcast stream of raw bytes from the child's stdout/stderr.
|
|
||||||
Stream<Uint8List> get output => _outputCtrl.stream;
|
|
||||||
|
|
||||||
/// Whether the session is still alive.
|
|
||||||
bool get isClosed => _masterFd < 0;
|
|
||||||
|
|
||||||
/// Spawn a child under a PTY.
|
|
||||||
///
|
|
||||||
/// [argv] must be non-empty; [argv[0]] is resolved via PATH. [env]
|
|
||||||
/// is merged onto the parent process env via [mergePtyEnv] so
|
|
||||||
/// terminal children inherit `HOME` / `USER` while clide's
|
|
||||||
/// true-colour defaults still take effect.
|
|
||||||
///
|
|
||||||
/// [ptycPath] defaults to looking for `ptyc` on PATH; dev setups
|
|
||||||
/// that haven't `make install`'d the helper can point at the
|
|
||||||
/// development build under `ptyc/bin/ptyc`.
|
|
||||||
static Future<PtySession> spawn({
|
|
||||||
required List<String> argv,
|
|
||||||
String? cwd,
|
|
||||||
Map<String, String>? env,
|
|
||||||
int cols = 80,
|
|
||||||
int rows = 24,
|
|
||||||
String ptycPath = 'ptyc',
|
|
||||||
}) async {
|
|
||||||
if (argv.isEmpty) {
|
|
||||||
throw ArgumentError.value(argv, 'argv', 'must be non-empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
// socketpair for the fd transfer.
|
|
||||||
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
|
||||||
int parentSock = -1;
|
|
||||||
int childSock = -1;
|
|
||||||
Process? proc;
|
|
||||||
try {
|
|
||||||
final rc = libc.socketpair(libc.afUnix, libc.sockStream, 0, sv);
|
|
||||||
if (rc < 0) {
|
|
||||||
throw PtyException('socketpair', 'socketpair failed', errno: libc.errno);
|
|
||||||
}
|
|
||||||
parentSock = sv[0];
|
|
||||||
childSock = sv[1];
|
|
||||||
|
|
||||||
// Build the JSON request for ptyc.
|
|
||||||
final req = _buildRequest(
|
|
||||||
argv: argv,
|
|
||||||
cwd: cwd,
|
|
||||||
env: mergePtyEnv(
|
|
||||||
processEnv: Platform.environment,
|
|
||||||
overrides: env,
|
|
||||||
),
|
|
||||||
cols: cols,
|
|
||||||
rows: rows,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Launch ptyc. We pass childSock to it via PTYC_SOCK_FD so ptyc
|
|
||||||
// reads it from env rather than having to place it at fd 3
|
|
||||||
// specifically — Dart's Process.start doesn't give us fine
|
|
||||||
// control over child fd layout.
|
|
||||||
proc = await Process.start(
|
|
||||||
ptycPath,
|
|
||||||
const [],
|
|
||||||
environment: {
|
|
||||||
...Platform.environment,
|
|
||||||
'PTYC_SOCK_FD': childSock.toString(),
|
|
||||||
},
|
|
||||||
// Inherit the socket fd into the child. Dart exposes this via
|
|
||||||
// a private API in recent versions; until it lands we rely on
|
|
||||||
// default behaviour (Process.start doesn't close arbitrary
|
|
||||||
// fds inherited from the parent's open-fd set).
|
|
||||||
mode: ProcessStartMode.normal,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send the request and close stdin so ptyc sees EOF.
|
|
||||||
proc.stdin.add(req);
|
|
||||||
await proc.stdin.close();
|
|
||||||
|
|
||||||
// Receive the master fd over the parent side of the socketpair.
|
|
||||||
// recvFd blocks until ptyc sends — run in a child isolate so the
|
|
||||||
// calling isolate's event loop stays responsive.
|
|
||||||
final int masterFd;
|
|
||||||
try {
|
|
||||||
masterFd = await _recvFdAsync(parentSock);
|
|
||||||
} catch (_) {
|
|
||||||
proc.kill();
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once we own masterFd, every error path below must close it
|
|
||||||
// before rethrowing. Wrap the rest of the spawn in its own
|
|
||||||
// try/catch so the cleanup is centralized.
|
|
||||||
try {
|
|
||||||
libc.setWinsize(masterFd, cols, rows);
|
|
||||||
|
|
||||||
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
|
|
||||||
final pid = _extractPid(stdoutLine);
|
|
||||||
|
|
||||||
final code = await proc.exitCode;
|
|
||||||
if (code != 0) {
|
|
||||||
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
|
|
||||||
libc.close(masterFd);
|
|
||||||
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
|
|
||||||
}
|
|
||||||
|
|
||||||
return PtySession._(pid: pid, masterFd: masterFd);
|
|
||||||
} catch (_) {
|
|
||||||
libc.close(masterFd);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// parent keeps its own fd until the session is closed; ptyc-side
|
|
||||||
// fd is released either way (ptyc has exited by now).
|
|
||||||
if (childSock >= 0) libc.close(childSock);
|
|
||||||
if (parentSock >= 0) libc.close(parentSock);
|
|
||||||
pkg_ffi.calloc.free(sv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send bytes to the child's stdin. Loops on short writes; throws
|
|
||||||
/// [PtyException] (with errno) on failure. Returns total bytes
|
|
||||||
/// written, which equals [bytes.length] on success.
|
|
||||||
int write(List<int> bytes) {
|
|
||||||
if (isClosed) return 0;
|
|
||||||
final buf = pkg_ffi.calloc<ffi.Uint8>(bytes.length);
|
|
||||||
try {
|
|
||||||
for (var i = 0; i < bytes.length; i++) {
|
|
||||||
buf[i] = bytes[i];
|
|
||||||
}
|
|
||||||
var written = 0;
|
|
||||||
while (written < bytes.length) {
|
|
||||||
final n = libc.write(
|
|
||||||
_masterFd,
|
|
||||||
buf + written,
|
|
||||||
bytes.length - written,
|
|
||||||
);
|
|
||||||
if (n < 0) {
|
|
||||||
final err = libc.errno;
|
|
||||||
if (err == 4 /* EINTR */) continue;
|
|
||||||
throw PtyException('write', 'write to PTY failed', errno: err);
|
|
||||||
}
|
|
||||||
if (n == 0) break;
|
|
||||||
written += n;
|
|
||||||
}
|
|
||||||
return written;
|
|
||||||
} finally {
|
|
||||||
pkg_ffi.calloc.free(buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize the child's terminal.
|
|
||||||
void resize({required int cols, required int rows}) {
|
|
||||||
if (isClosed) return;
|
|
||||||
libc.setWinsize(_masterFd, cols, rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a signal to the child. Uses `Process.killPid` for now; a
|
|
||||||
/// future pass can deliver signals via the PTY's foreground process
|
|
||||||
/// group so Ctrl-C from the UI works naturally.
|
|
||||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
|
|
||||||
return Process.killPid(pid, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close the session. Signals the child, waits briefly for the
|
|
||||||
/// reader isolate to see EOF on the master fd (natural wakeup), and
|
|
||||||
/// then closes + force-kills whatever's still around.
|
|
||||||
///
|
|
||||||
/// Ordering matters: closing the master fd alone does **not** unblock
|
|
||||||
/// a `read()` already in flight on Linux — the blocked syscall holds
|
|
||||||
/// a reference to the kernel file. Killing the child causes the PTY
|
|
||||||
/// to return EOF on master, which is the clean way to wake the
|
|
||||||
/// reader. See D-005 notes; a belt-and-braces `poll()` + self-pipe
|
|
||||||
/// wake path is possible but not worth the FFI surface at Tier 1.
|
|
||||||
Future<void> close() async {
|
|
||||||
if (isClosed) return;
|
|
||||||
final fd = _masterFd;
|
|
||||||
_masterFd = -1;
|
|
||||||
|
|
||||||
// 1. Ask the child nicely so the shell can run its exit traps.
|
|
||||||
try {
|
|
||||||
Process.killPid(pid, ProcessSignal.sigterm);
|
|
||||||
} catch (_) {
|
|
||||||
// Already gone — fine.
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Give the reader isolate up to ~500ms to see EOF and signal
|
|
||||||
// back via its 'eof' message (set by the existing listener,
|
|
||||||
// which completes _readerExited).
|
|
||||||
await _readerExited.future.timeout(
|
|
||||||
const Duration(milliseconds: 500),
|
|
||||||
onTimeout: () {},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Belt and braces: SIGKILL the child, close the master, and
|
|
||||||
// force-kill the isolate regardless. Any still-pending read()
|
|
||||||
// returns on close via EIO; future reads return EBADF.
|
|
||||||
try {
|
|
||||||
Process.killPid(pid, ProcessSignal.sigkill);
|
|
||||||
} catch (_) {}
|
|
||||||
libc.close(fd);
|
|
||||||
_readerPort?.close();
|
|
||||||
_readerIsolate?.kill(priority: Isolate.immediate);
|
|
||||||
_readerPort = null;
|
|
||||||
_readerIsolate = null;
|
|
||||||
|
|
||||||
if (!_outputCtrl.isClosed) await _outputCtrl.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run recvFd in a child isolate so the blocking FFI call doesn't
|
|
||||||
/// stall the calling isolate's event loop.
|
|
||||||
static Future<int> _recvFdAsync(int socketFd) async {
|
|
||||||
final port = ReceivePort();
|
|
||||||
Isolate? iso;
|
|
||||||
try {
|
|
||||||
iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
|
|
||||||
final result = await port.first;
|
|
||||||
if (result is int) return result;
|
|
||||||
throw PtyException('recvFd', '$result');
|
|
||||||
} finally {
|
|
||||||
iso?.kill(priority: Isolate.immediate);
|
|
||||||
port.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _recvFdEntry(_RecvFdArgs args) {
|
|
||||||
try {
|
|
||||||
final fd = scm.recvFd(args.socketFd);
|
|
||||||
args.sendPort.send(fd);
|
|
||||||
} catch (e) {
|
|
||||||
args.sendPort.send('error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- //
|
|
||||||
|
|
||||||
void _startReader() {
|
|
||||||
final port = ReceivePort();
|
|
||||||
_readerPort = port;
|
|
||||||
|
|
||||||
port.listen((dynamic msg) {
|
|
||||||
if (msg is Uint8List) {
|
|
||||||
if (!_outputCtrl.isClosed) _outputCtrl.add(msg);
|
|
||||||
} else if (msg == 'eof') {
|
|
||||||
if (!_readerExited.isCompleted) _readerExited.complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Isolate.spawn<_ReaderArgs>(
|
|
||||||
_readerEntrypoint,
|
|
||||||
_ReaderArgs(fd: _masterFd, sendPort: port.sendPort),
|
|
||||||
).then(
|
|
||||||
(iso) => _readerIsolate = iso,
|
|
||||||
onError: (Object e) {
|
|
||||||
// Spawn failure leaves the session unable to ever produce
|
|
||||||
// output. Surface the error and mark the controller closed
|
|
||||||
// so consumers don't hang waiting on the stream.
|
|
||||||
if (!_outputCtrl.isClosed) {
|
|
||||||
_outputCtrl.addError(PtyException('reader-spawn', '$e'));
|
|
||||||
_outputCtrl.close();
|
|
||||||
}
|
|
||||||
if (!_readerExited.isCompleted) _readerExited.complete();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- request builder ------------------------------------------------------
|
|
||||||
|
|
||||||
static List<int> _buildRequest({
|
|
||||||
required List<String> argv,
|
|
||||||
required String? cwd,
|
|
||||||
required Map<String, String> env,
|
|
||||||
required int cols,
|
|
||||||
required int rows,
|
|
||||||
}) {
|
|
||||||
// Minimal JSON emitter — our request never contains non-ASCII,
|
|
||||||
// so we only need to escape ", \, and the standard control chars.
|
|
||||||
final sb = StringBuffer('{');
|
|
||||||
sb.write('"argv":[');
|
|
||||||
for (var i = 0; i < argv.length; i++) {
|
|
||||||
if (i > 0) sb.write(',');
|
|
||||||
sb.write(_json(argv[i]));
|
|
||||||
}
|
|
||||||
sb.write(']');
|
|
||||||
if (cwd != null) {
|
|
||||||
sb.write(',"cwd":${_json(cwd)}');
|
|
||||||
}
|
|
||||||
sb.write(',"env":{');
|
|
||||||
var first = true;
|
|
||||||
env.forEach((k, v) {
|
|
||||||
if (!first) sb.write(',');
|
|
||||||
first = false;
|
|
||||||
sb.write('${_json(k)}:${_json(v)}');
|
|
||||||
});
|
|
||||||
sb.write('}');
|
|
||||||
sb.write(',"cols":$cols,"rows":$rows');
|
|
||||||
sb.write('}');
|
|
||||||
return utf8.encode(sb.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _json(String s) {
|
|
||||||
final b = StringBuffer('"');
|
|
||||||
for (var i = 0; i < s.length; i++) {
|
|
||||||
final c = s.codeUnitAt(i);
|
|
||||||
switch (c) {
|
|
||||||
case 0x22:
|
|
||||||
b.write(r'\"');
|
|
||||||
break;
|
|
||||||
case 0x5c:
|
|
||||||
b.write(r'\\');
|
|
||||||
break;
|
|
||||||
case 0x08:
|
|
||||||
b.write(r'\b');
|
|
||||||
break;
|
|
||||||
case 0x09:
|
|
||||||
b.write(r'\t');
|
|
||||||
break;
|
|
||||||
case 0x0a:
|
|
||||||
b.write(r'\n');
|
|
||||||
break;
|
|
||||||
case 0x0c:
|
|
||||||
b.write(r'\f');
|
|
||||||
break;
|
|
||||||
case 0x0d:
|
|
||||||
b.write(r'\r');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (c < 0x20) {
|
|
||||||
b.write('\\u${c.toRadixString(16).padLeft(4, '0')}');
|
|
||||||
} else {
|
|
||||||
b.writeCharCode(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.write('"');
|
|
||||||
return b.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _extractPid(String json) {
|
|
||||||
// Narrow regex is enough — ptyc's success envelope is known-shape.
|
|
||||||
final m = RegExp(r'"pid"\s*:\s*(\d+)').firstMatch(json);
|
|
||||||
if (m == null) {
|
|
||||||
throw PtyException('ptyc', 'no pid in ptyc response: $json');
|
|
||||||
}
|
|
||||||
return int.parse(m.group(1)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Reader isolate
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _ReaderArgs {
|
|
||||||
const _ReaderArgs({required this.fd, required this.sendPort});
|
|
||||||
final int fd;
|
|
||||||
final SendPort sendPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs in a separate isolate. Loops on blocking `read(fd)` and posts
|
|
||||||
/// each chunk back to the main isolate as a `Uint8List`. Exits on
|
|
||||||
/// EOF, close, or error.
|
|
||||||
void _readerEntrypoint(_ReaderArgs args) {
|
|
||||||
const chunk = 65536;
|
|
||||||
final buf = pkg_ffi.calloc<ffi.Uint8>(chunk);
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
final n = libc.read(args.fd, buf, chunk);
|
|
||||||
if (n > 0) {
|
|
||||||
final bytes = Uint8List(n);
|
|
||||||
for (var i = 0; i < n; i++) {
|
|
||||||
bytes[i] = buf[i];
|
|
||||||
}
|
|
||||||
args.sendPort.send(bytes);
|
|
||||||
} else if (n == 0) {
|
|
||||||
// child closed pty → EOF
|
|
||||||
args.sendPort.send('eof');
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
final err = libc.errno;
|
|
||||||
if (err == 4 /* EINTR */) continue;
|
|
||||||
// 9=EBADF (fd closed from main), 5=EIO (child exited on
|
|
||||||
// Linux). Either way, we're done.
|
|
||||||
args.sendPort.send('eof');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pkg_ffi.calloc.free(buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,6 @@ import 'src/daemon/pane_commands.dart';
|
|||||||
import 'src/ipc/envelope.dart';
|
import 'src/ipc/envelope.dart';
|
||||||
import 'src/panes/event_sink.dart';
|
import 'src/panes/event_sink.dart';
|
||||||
import 'src/panes/registry.dart';
|
import 'src/panes/registry.dart';
|
||||||
import 'src/pty/session.dart';
|
|
||||||
import 'src/daemon/dispatcher.dart';
|
import 'src/daemon/dispatcher.dart';
|
||||||
import 'src/pty/env.dart' show expandedPath;
|
import 'src/pty/env.dart' show expandedPath;
|
||||||
|
|
||||||
@@ -124,7 +123,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
_log('toolchain.git', tc.git);
|
_log('toolchain.git', tc.git);
|
||||||
_log('toolchain.pql', tc.pql);
|
_log('toolchain.pql', tc.pql);
|
||||||
_log('toolchain.tmux', tc.tmux);
|
_log('toolchain.tmux', tc.tmux);
|
||||||
_log('toolchain.ptyc', tc.ptyc);
|
|
||||||
_log('toolchain.shell', tc.shell);
|
_log('toolchain.shell', tc.shell);
|
||||||
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
|
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
|
||||||
_say('');
|
_say('');
|
||||||
@@ -132,14 +130,12 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
await _testExists('git', tc.git);
|
await _testExists('git', tc.git);
|
||||||
await _testExists('pql', tc.pql);
|
await _testExists('pql', tc.pql);
|
||||||
await _testExists('tmux', tc.tmux);
|
await _testExists('tmux', tc.tmux);
|
||||||
await _testExists('ptyc', tc.ptyc);
|
|
||||||
await _testExists('shell', tc.shell);
|
await _testExists('shell', tc.shell);
|
||||||
_say('');
|
_say('');
|
||||||
|
|
||||||
await _testExec('git --version', tc.git, ['--version'], workDir);
|
await _testExec('git --version', tc.git, ['--version'], workDir);
|
||||||
await _testExec('pql --version', tc.pql, ['--version'], workDir);
|
await _testExec('pql --version', tc.pql, ['--version'], workDir);
|
||||||
await _testExec('tmux -V', tc.tmux, ['-V'], workDir);
|
await _testExec('tmux -V', tc.tmux, ['-V'], workDir);
|
||||||
await _testExec('ptyc (no args)', tc.ptyc, [], workDir);
|
|
||||||
await _testExec('shell --version', tc.shell, ['--version'], workDir);
|
await _testExec('shell --version', tc.shell, ['--version'], workDir);
|
||||||
_say('');
|
_say('');
|
||||||
|
|
||||||
@@ -191,17 +187,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
|
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
|
||||||
});
|
});
|
||||||
|
|
||||||
// ptyc stdin/stdout test — send a valid request, verify JSON response
|
|
||||||
await _testAsync('ptyc spawn echo', () async {
|
|
||||||
final proc = await Process.start(tc.ptyc, []);
|
|
||||||
// Send a request for /bin/echo — simplest possible child
|
|
||||||
proc.stdin.write('{"argv":["/bin/echo","hello"],"cwd":"/tmp","env":{},"cols":80,"rows":24}');
|
|
||||||
await proc.stdin.close();
|
|
||||||
final stdout = await proc.stdout.transform(const SystemEncoding().decoder).join();
|
|
||||||
final exitCode = await proc.exitCode;
|
|
||||||
return 'exit=$exitCode stdout=${stdout.trim().split('\n').first}';
|
|
||||||
});
|
|
||||||
|
|
||||||
_say('');
|
_say('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,93 +380,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
return 'exit=$exit stderr=${stderr.trim()}';
|
return 'exit=$exit stderr=${stderr.trim()}';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Direct PtySession test — bypasses IPC, tests fd transfer + reader.
|
|
||||||
await _testAsync('PtySession.spawn direct', () async {
|
|
||||||
final session = await PtySession.spawn(
|
|
||||||
argv: [tc.shell, '-c', 'echo DIRECT_PTY_TEST'],
|
|
||||||
cwd: workDir,
|
|
||||||
ptycPath: tc.ptyc,
|
|
||||||
);
|
|
||||||
_say(' session pid=${session.pid} masterFd exists');
|
|
||||||
final bytes = <int>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
session.output.listen(
|
|
||||||
(chunk) {
|
|
||||||
bytes.addAll(chunk);
|
|
||||||
_say(' got ${chunk.length} bytes');
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
_say(' stream done');
|
|
||||||
if (!done.isCompleted) done.complete();
|
|
||||||
},
|
|
||||||
onError: (e) => _say(' stream error: $e'),
|
|
||||||
);
|
|
||||||
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
|
||||||
_say(' timeout waiting for output, got ${bytes.length} bytes so far');
|
|
||||||
});
|
|
||||||
await session.close();
|
|
||||||
final output = utf8.decode(bytes, allowMalformed: true);
|
|
||||||
final ok = output.contains('DIRECT_PTY_TEST');
|
|
||||||
return ok ? 'output=$output' : 'no marker in ${bytes.length} bytes: ${output.substring(0, output.length.clamp(0, 100))}';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!Platform.isMacOS) {
|
|
||||||
// Additional direct PtySession tests (Linux only — no merged thread).
|
|
||||||
|
|
||||||
// Test 1: spawn /bin/echo via PtySession, read output
|
|
||||||
await _testAsync('pty spawn echo', () async {
|
|
||||||
final session = await PtySession.spawn(
|
|
||||||
argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'],
|
|
||||||
cwd: workDir,
|
|
||||||
ptycPath: tc.ptyc,
|
|
||||||
);
|
|
||||||
final bytes = <int>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
|
||||||
await done.future.timeout(const Duration(seconds: 5));
|
|
||||||
await session.close();
|
|
||||||
final output = utf8.decode(bytes, allowMalformed: true);
|
|
||||||
final ok = output.contains('CLIDE_PTY_TEST_OK');
|
|
||||||
return ok ? 'output contains marker' : 'marker not found in ${output.length} bytes';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 2: spawn shell, write a command, verify output
|
|
||||||
await _testAsync('pty spawn shell', () async {
|
|
||||||
final session = await PtySession.spawn(
|
|
||||||
argv: [tc.shell, '-c', 'echo CLIDE_SHELL_TEST'],
|
|
||||||
cwd: workDir,
|
|
||||||
ptycPath: tc.ptyc,
|
|
||||||
);
|
|
||||||
final bytes = <int>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
|
||||||
await done.future.timeout(const Duration(seconds: 5));
|
|
||||||
await session.close();
|
|
||||||
final output = utf8.decode(bytes, allowMalformed: true);
|
|
||||||
final ok = output.contains('CLIDE_SHELL_TEST');
|
|
||||||
return ok ? 'shell output contains marker' : 'marker not found in ${output.length} bytes';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 3: spawn interactive shell, write to stdin, verify file creation
|
|
||||||
await _testAsync('pty write to child', () async {
|
|
||||||
final marker = '/tmp/clide-pty-test-${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
final session = await PtySession.spawn(
|
|
||||||
argv: [tc.shell],
|
|
||||||
cwd: workDir,
|
|
||||||
ptycPath: tc.ptyc,
|
|
||||||
);
|
|
||||||
session.write(utf8.encode('touch $marker && exit\n'));
|
|
||||||
final bytes = <int>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
|
||||||
await done.future.timeout(const Duration(seconds: 5));
|
|
||||||
await session.close();
|
|
||||||
final fileCreated = File(marker).existsSync();
|
|
||||||
if (fileCreated) File(marker).deleteSync();
|
|
||||||
return fileCreated ? 'file created + cleaned up' : 'file not created';
|
|
||||||
});
|
|
||||||
} // end !Platform.isMacOS
|
|
||||||
|
|
||||||
_say('');
|
_say('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
<string>(allow process-exec* (literal "__SHELL__"))</string>
|
<string>(allow process-exec* (literal "__SHELL__"))</string>
|
||||||
<string>(allow process-exec* (literal "/usr/bin/which"))</string>
|
<string>(allow process-exec* (literal "/usr/bin/which"))</string>
|
||||||
<string>(allow process-exec* (subpath "__HOMEDIR__/.local/bin"))</string>
|
<string>(allow process-exec* (subpath "__HOMEDIR__/.local/bin"))</string>
|
||||||
<string>(allow process-exec* (subpath "__PROJECTS__/clide/ptyc/bin"))</string>
|
|
||||||
<string>(allow process-exec* (subpath "__PROJECTS__/clide/native/dugite"))</string>
|
<string>(allow process-exec* (subpath "__PROJECTS__/clide/native/dugite"))</string>
|
||||||
<string>(allow process-fork)</string>
|
<string>(allow process-fork)</string>
|
||||||
<string>(allow file-read* file-write* (subpath "__PROJECTS__"))</string>
|
<string>(allow file-read* file-write* (subpath "__PROJECTS__"))</string>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# ptyc — PTY-spawn helper for clide.
|
|
||||||
#
|
|
||||||
# Single source file, libc only. No configure step. Builds everywhere a
|
|
||||||
# POSIX-y C compiler + unix sockets exist.
|
|
||||||
|
|
||||||
CC ?= cc
|
|
||||||
CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2
|
|
||||||
LDFLAGS ?=
|
|
||||||
|
|
||||||
BIN := bin/ptyc
|
|
||||||
|
|
||||||
.PHONY: all
|
|
||||||
all: $(BIN)
|
|
||||||
|
|
||||||
$(BIN): ptyc.c | bin
|
|
||||||
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
|
||||||
|
|
||||||
bin:
|
|
||||||
mkdir -p bin
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
test: $(BIN)
|
|
||||||
./test_ptyc.sh
|
|
||||||
|
|
||||||
.PHONY: clean
|
|
||||||
clean:
|
|
||||||
rm -rf bin *.o
|
|
||||||
-127
@@ -1,127 +0,0 @@
|
|||||||
# ptyc
|
|
||||||
|
|
||||||
Small POSIX helper that spawns a child process under a PTY and hands
|
|
||||||
the master fd back to its caller. Language-agnostic; usable from any
|
|
||||||
program that can fork a subprocess and receive a file descriptor over a
|
|
||||||
unix socket.
|
|
||||||
|
|
||||||
Clide uses it for every PTY it owns (terminal panes, Claude sessions,
|
|
||||||
tmux wrappers, LSP servers, debug adapters). See
|
|
||||||
[`D-005`](../decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer)
|
|
||||||
for the architectural rationale; ptyc is a peer of
|
|
||||||
[`pql`](https://github.com/postmeridiem/pql), not a clide subsystem.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make # produces bin/ptyc
|
|
||||||
make test # runs test_ptyc.sh against the built binary
|
|
||||||
make clean
|
|
||||||
```
|
|
||||||
|
|
||||||
No third-party dependencies. `CC`, `CFLAGS`, and `LDFLAGS` are
|
|
||||||
overrideable in the usual way.
|
|
||||||
|
|
||||||
## Wire contract
|
|
||||||
|
|
||||||
ptyc is a one-shot helper. The caller:
|
|
||||||
|
|
||||||
1. Creates a `socketpair(AF_UNIX, SOCK_STREAM, 0)`.
|
|
||||||
2. Launches `ptyc` as a subprocess, passing one end of the socket to
|
|
||||||
the child as **file descriptor 3** (the default) or whatever fd is
|
|
||||||
given in the `PTYC_SOCK_FD` environment variable. The other end of
|
|
||||||
the socket stays with the caller. The env-var override exists
|
|
||||||
because some language runtimes (Python's `subprocess` with
|
|
||||||
`stdout=PIPE`, for example) shuffle their own pipe fds through the
|
|
||||||
low numbers and it's cheaper for the caller to pick a higher fd
|
|
||||||
than to dup2 it down.
|
|
||||||
3. Writes the request as **JSON on stdin** and closes stdin (EOF
|
|
||||||
signals end of request).
|
|
||||||
4. Receives the master PTY fd over the socket via **`SCM_RIGHTS`**
|
|
||||||
ancillary data (with a single-byte `'x'` payload so the receiver
|
|
||||||
knows when to `recvmsg`).
|
|
||||||
5. Reads the success response from stdout (single line of JSON) and
|
|
||||||
reaps the exited `ptyc` process.
|
|
||||||
|
|
||||||
### Request
|
|
||||||
|
|
||||||
JSON object on stdin. All fields optional except `argv`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"argv": ["bash", "-l"],
|
|
||||||
"cwd": "/home/me/work",
|
|
||||||
"env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
|
|
||||||
"cols": 80,
|
|
||||||
"rows": 24
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `argv` — required, non-empty array of strings. `argv[0]` is resolved
|
|
||||||
via `PATH`.
|
|
||||||
- `cwd` — optional. If omitted, the child inherits ptyc's cwd.
|
|
||||||
- `env` — optional object. If present, the child's environment is
|
|
||||||
**replaced** with exactly the keys given (ptyc does `clearenv()` and
|
|
||||||
then `putenv` per entry). If absent, the child inherits ptyc's
|
|
||||||
environment. This is a deliberate choice: the daemon is expected to
|
|
||||||
build the env it wants, not rely on a merge.
|
|
||||||
- `cols`, `rows` — optional. Default `80` × `24`. Applied via
|
|
||||||
`TIOCSWINSZ` before fork.
|
|
||||||
|
|
||||||
### Success response (stdout)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"ok":true,"pid":12345}
|
|
||||||
```
|
|
||||||
|
|
||||||
One line, trailing newline. The master PTY fd is already on the socket
|
|
||||||
by the time stdout is written. `pid` is the spawned child's PID — the
|
|
||||||
caller is responsible for `waitpid`'ing it when appropriate.
|
|
||||||
|
|
||||||
### Error response (stderr)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"ok":false,"error":"exec: No such file or directory","errno":2}
|
|
||||||
```
|
|
||||||
|
|
||||||
Written on stderr. No fd is sent. ptyc exits with a non-zero code.
|
|
||||||
|
|
||||||
### Exit codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| `0` | Success — fd sent, success response on stdout. |
|
|
||||||
| `1` | Bad request — JSON parse error, missing `argv`, bad field values. |
|
|
||||||
| `2` | Syscall failed — fork, exec, PTY open, `sendmsg`, etc. Check `errno` in the response. |
|
|
||||||
|
|
||||||
## Limits
|
|
||||||
|
|
||||||
Compile-time caps, deliberately small:
|
|
||||||
|
|
||||||
- `MAX_ARGV` = 64 entries
|
|
||||||
- `MAX_ENV` = 256 entries
|
|
||||||
- `MAX_INPUT` = 64 KiB request size
|
|
||||||
|
|
||||||
These are far above what any reasonable pane invocation needs; if you
|
|
||||||
hit them you're holding ptyc wrong. Edit the `#define`s in `ptyc.c` and
|
|
||||||
rebuild.
|
|
||||||
|
|
||||||
## Security notes
|
|
||||||
|
|
||||||
- The JSON parser is scoped to the shape above. It rejects anything
|
|
||||||
else. Strings support the standard `\"` `\\` `\/` `\b` `\f` `\n`
|
|
||||||
`\r` `\t` escapes and ASCII-range `\uXXXX`. Non-ASCII Unicode escapes
|
|
||||||
(and surrogate pairs) are rejected — the daemon is expected to emit
|
|
||||||
raw UTF-8 bytes.
|
|
||||||
- Input is trusted (daemon is local, same user). ptyc does not sanitise
|
|
||||||
argv or env beyond format-level checks — if the daemon asks ptyc to
|
|
||||||
exec `rm`, ptyc execs `rm`.
|
|
||||||
- ptyc does not `setuid` or `setgid`. It runs as the invoking user.
|
|
||||||
|
|
||||||
## Session persistence
|
|
||||||
|
|
||||||
ptyc is stateless and one-shot. Session persistence (survive app
|
|
||||||
restart) is the **caller's** concern. Clide achieves it by spawning
|
|
||||||
ptyc with `tmux new-session -A -s <name> -- <cmd>` for Claude panes;
|
|
||||||
tmux handles the persistence layer and ptyc just spawns tmux. See
|
|
||||||
`D-041` (Claude panes — one primary per repo, tmux-backed).
|
|
||||||
-479
@@ -1,479 +0,0 @@
|
|||||||
/*
|
|
||||||
* ptyc — spawn a child under a PTY, hand the master fd back.
|
|
||||||
*
|
|
||||||
* Wire contract (documented in README.md):
|
|
||||||
* stdin : JSON request {"argv":[...],"cwd":"...","env":{...},"cols":N,"rows":N}
|
|
||||||
* stdout : JSON response {"ok":true,"pid":N} on success
|
|
||||||
* stderr : JSON diagnostic {"ok":false,"error":"...","errno":N} on failure
|
|
||||||
* fd 3 : unix-domain socket; master fd is sent over SCM_RIGHTS on success
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 success
|
|
||||||
* 1 bad request (JSON parse error, missing field, bad value)
|
|
||||||
* 2 syscall failed (fork/exec/pty/socket)
|
|
||||||
*
|
|
||||||
* Dependencies: libc only. POSIX APIs where available (posix_openpt,
|
|
||||||
* grantpt, unlockpt, ptsname). Single-threaded, one-shot, ~300 LOC.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#define _POSIX_C_SOURCE 200809L
|
|
||||||
#define _XOPEN_SOURCE 600
|
|
||||||
#ifdef __APPLE__
|
|
||||||
#define _DARWIN_C_SOURCE /* CMSG_SPACE / CMSG_LEN on macOS */
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include <ctype.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <stdarg.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <sys/ioctl.h>
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <sys/wait.h>
|
|
||||||
#include <termios.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
/* POSIX exposes `environ` but requires an explicit declaration. We
|
|
||||||
* set it to NULL in the child to replace the inherited environment
|
|
||||||
* when the caller supplied one — `clearenv()` is GNU-only, not POSIX. */
|
|
||||||
extern char **environ;
|
|
||||||
|
|
||||||
/* Discard write() result without tripping warn_unused_result. We only
|
|
||||||
* call this on the exec-failure pipe in the child immediately before
|
|
||||||
* _exit(127); the parent either receives the bytes or notices EOF via
|
|
||||||
* the CLOEXEC pipe. Nothing useful to do with the return value. */
|
|
||||||
static void report_errno(int fd, int e) {
|
|
||||||
ssize_t r = write(fd, &e, sizeof(e));
|
|
||||||
(void)r;
|
|
||||||
}
|
|
||||||
|
|
||||||
#define MAX_ARGV 64
|
|
||||||
#define MAX_ENV 256
|
|
||||||
#define MAX_INPUT (64 * 1024)
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
/* error reporting */
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
static void die_bad_request(const char *msg) {
|
|
||||||
fprintf(stderr, "{\"ok\":false,\"error\":\"%s\",\"errno\":0}\n", msg);
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void die_syscall(const char *msg) {
|
|
||||||
int e = errno;
|
|
||||||
/* Avoid quoting edge cases: strerror results don't contain quotes on
|
|
||||||
* any platform we care about; if this ever bites us we'll escape. */
|
|
||||||
fprintf(stderr, "{\"ok\":false,\"error\":\"%s: %s\",\"errno\":%d}\n", msg,
|
|
||||||
strerror(e), e);
|
|
||||||
exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
/* minimal JSON parser */
|
|
||||||
/* */
|
|
||||||
/* Scoped to exactly the shape we accept. String escapes supported for */
|
|
||||||
/* the subset we emit (\" \\ \n \r \t \b \f \/ and \uXXXX for ASCII). */
|
|
||||||
/* Surrogate pairs, nested arrays, and non-string numbers-as-keys are */
|
|
||||||
/* not supported — the daemon never emits them. */
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char *src;
|
|
||||||
size_t len;
|
|
||||||
size_t pos;
|
|
||||||
} Parser;
|
|
||||||
|
|
||||||
static void p_skip_ws(Parser *p) {
|
|
||||||
while (p->pos < p->len) {
|
|
||||||
char c = p->src[p->pos];
|
|
||||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
|
||||||
p->pos++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static int p_peek(Parser *p) {
|
|
||||||
p_skip_ws(p);
|
|
||||||
return p->pos < p->len ? (unsigned char)p->src[p->pos] : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int p_expect(Parser *p, char c) {
|
|
||||||
if (p_peek(p) != (unsigned char)c) return 0;
|
|
||||||
p->pos++;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parse a JSON string into a freshly-allocated NUL-terminated buffer. */
|
|
||||||
static char *p_string(Parser *p) {
|
|
||||||
if (p_peek(p) != '"') return NULL;
|
|
||||||
p->pos++;
|
|
||||||
size_t start = p->pos;
|
|
||||||
/* First pass: find end and compute output length. */
|
|
||||||
size_t out_len = 0;
|
|
||||||
while (p->pos < p->len && p->src[p->pos] != '"') {
|
|
||||||
if (p->src[p->pos] == '\\') {
|
|
||||||
if (p->pos + 1 >= p->len) return NULL;
|
|
||||||
char esc = p->src[p->pos + 1];
|
|
||||||
if (esc == 'u') {
|
|
||||||
if (p->pos + 5 >= p->len) return NULL;
|
|
||||||
/* We only accept ASCII in \uXXXX. */
|
|
||||||
for (int i = 2; i < 6; i++) {
|
|
||||||
if (!isxdigit((unsigned char)p->src[p->pos + i])) return NULL;
|
|
||||||
}
|
|
||||||
p->pos += 6;
|
|
||||||
} else {
|
|
||||||
p->pos += 2;
|
|
||||||
}
|
|
||||||
out_len++;
|
|
||||||
} else {
|
|
||||||
p->pos++;
|
|
||||||
out_len++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p->pos >= p->len || p->src[p->pos] != '"') return NULL;
|
|
||||||
size_t end = p->pos;
|
|
||||||
p->pos++; /* consume closing quote */
|
|
||||||
|
|
||||||
char *out = malloc(out_len + 1);
|
|
||||||
if (!out) return NULL;
|
|
||||||
size_t j = 0;
|
|
||||||
for (size_t i = start; i < end;) {
|
|
||||||
if (p->src[i] == '\\') {
|
|
||||||
char esc = p->src[i + 1];
|
|
||||||
switch (esc) {
|
|
||||||
case '"': out[j++] = '"'; i += 2; break;
|
|
||||||
case '\\': out[j++] = '\\'; i += 2; break;
|
|
||||||
case '/': out[j++] = '/'; i += 2; break;
|
|
||||||
case 'b': out[j++] = '\b'; i += 2; break;
|
|
||||||
case 'f': out[j++] = '\f'; i += 2; break;
|
|
||||||
case 'n': out[j++] = '\n'; i += 2; break;
|
|
||||||
case 'r': out[j++] = '\r'; i += 2; break;
|
|
||||||
case 't': out[j++] = '\t'; i += 2; break;
|
|
||||||
case 'u': {
|
|
||||||
unsigned int cp = 0;
|
|
||||||
for (int k = 0; k < 4; k++) {
|
|
||||||
char h = p->src[i + 2 + k];
|
|
||||||
cp <<= 4;
|
|
||||||
if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0');
|
|
||||||
else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10);
|
|
||||||
else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10);
|
|
||||||
}
|
|
||||||
/* ASCII-range only. Anything else is a request-format bug. */
|
|
||||||
if (cp > 0x7f) { free(out); return NULL; }
|
|
||||||
out[j++] = (char)cp;
|
|
||||||
i += 6;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: free(out); return NULL;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out[j++] = p->src[i++];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[j] = '\0';
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int p_int(Parser *p, long *out) {
|
|
||||||
p_skip_ws(p);
|
|
||||||
size_t start = p->pos;
|
|
||||||
if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+'))
|
|
||||||
p->pos++;
|
|
||||||
int digits = 0;
|
|
||||||
while (p->pos < p->len && isdigit((unsigned char)p->src[p->pos])) {
|
|
||||||
p->pos++;
|
|
||||||
digits++;
|
|
||||||
}
|
|
||||||
if (!digits) {
|
|
||||||
p->pos = start;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
char buf[32];
|
|
||||||
size_t n = p->pos - start;
|
|
||||||
if (n >= sizeof(buf)) return 0;
|
|
||||||
memcpy(buf, p->src + start, n);
|
|
||||||
buf[n] = '\0';
|
|
||||||
*out = strtol(buf, NULL, 10);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
/* request */
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char *argv[MAX_ARGV + 1]; /* NULL-terminated */
|
|
||||||
int argc;
|
|
||||||
char *cwd; /* optional, NULL means inherit */
|
|
||||||
char *env[MAX_ENV + 1]; /* each "KEY=VAL" */
|
|
||||||
int envc;
|
|
||||||
int cols;
|
|
||||||
int rows;
|
|
||||||
} Request;
|
|
||||||
|
|
||||||
static void req_init(Request *r) {
|
|
||||||
memset(r, 0, sizeof(*r));
|
|
||||||
r->cols = 80;
|
|
||||||
r->rows = 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void req_free(Request *r) {
|
|
||||||
for (int i = 0; i < r->argc; i++) free(r->argv[i]);
|
|
||||||
for (int i = 0; i < r->envc; i++) free(r->env[i]);
|
|
||||||
free(r->cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parse_argv(Parser *p, Request *r) {
|
|
||||||
if (!p_expect(p, '[')) die_bad_request("argv must be an array");
|
|
||||||
if (p_peek(p) == ']') { p->pos++; return; }
|
|
||||||
for (;;) {
|
|
||||||
if (r->argc >= MAX_ARGV) die_bad_request("argv too long");
|
|
||||||
char *s = p_string(p);
|
|
||||||
if (!s) die_bad_request("argv element must be a string");
|
|
||||||
r->argv[r->argc++] = s;
|
|
||||||
if (p_expect(p, ',')) continue;
|
|
||||||
if (p_expect(p, ']')) break;
|
|
||||||
die_bad_request("malformed argv array");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parse_env(Parser *p, Request *r) {
|
|
||||||
if (!p_expect(p, '{')) die_bad_request("env must be an object");
|
|
||||||
if (p_peek(p) == '}') { p->pos++; return; }
|
|
||||||
for (;;) {
|
|
||||||
if (r->envc >= MAX_ENV) die_bad_request("env too large");
|
|
||||||
char *k = p_string(p);
|
|
||||||
if (!k) die_bad_request("env key must be a string");
|
|
||||||
if (!p_expect(p, ':')) { free(k); die_bad_request("env missing ':'"); }
|
|
||||||
char *v = p_string(p);
|
|
||||||
if (!v) { free(k); die_bad_request("env value must be a string"); }
|
|
||||||
size_t kl = strlen(k), vl = strlen(v);
|
|
||||||
char *kv = malloc(kl + 1 + vl + 1);
|
|
||||||
if (!kv) { free(k); free(v); die_syscall("malloc"); }
|
|
||||||
memcpy(kv, k, kl);
|
|
||||||
kv[kl] = '=';
|
|
||||||
memcpy(kv + kl + 1, v, vl);
|
|
||||||
kv[kl + 1 + vl] = '\0';
|
|
||||||
free(k); free(v);
|
|
||||||
r->env[r->envc++] = kv;
|
|
||||||
if (p_expect(p, ',')) continue;
|
|
||||||
if (p_expect(p, '}')) break;
|
|
||||||
die_bad_request("malformed env object");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void parse_request(const char *src, size_t len, Request *r) {
|
|
||||||
Parser p = { .src = src, .len = len, .pos = 0 };
|
|
||||||
if (!p_expect(&p, '{')) die_bad_request("top-level must be an object");
|
|
||||||
if (p_peek(&p) == '}') { p.pos++; goto done; }
|
|
||||||
for (;;) {
|
|
||||||
char *key = p_string(&p);
|
|
||||||
if (!key) die_bad_request("key must be a string");
|
|
||||||
if (!p_expect(&p, ':')) { free(key); die_bad_request("missing ':'"); }
|
|
||||||
if (strcmp(key, "argv") == 0) {
|
|
||||||
parse_argv(&p, r);
|
|
||||||
} else if (strcmp(key, "cwd") == 0) {
|
|
||||||
r->cwd = p_string(&p);
|
|
||||||
if (!r->cwd) { free(key); die_bad_request("cwd must be a string"); }
|
|
||||||
} else if (strcmp(key, "env") == 0) {
|
|
||||||
parse_env(&p, r);
|
|
||||||
} else if (strcmp(key, "cols") == 0) {
|
|
||||||
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("cols must be an integer"); }
|
|
||||||
if (v < 1 || v > 65535) { free(key); die_bad_request("cols out of range"); }
|
|
||||||
r->cols = (int)v;
|
|
||||||
} else if (strcmp(key, "rows") == 0) {
|
|
||||||
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("rows must be an integer"); }
|
|
||||||
if (v < 1 || v > 65535) { free(key); die_bad_request("rows out of range"); }
|
|
||||||
r->rows = (int)v;
|
|
||||||
} else {
|
|
||||||
free(key);
|
|
||||||
die_bad_request("unknown key");
|
|
||||||
}
|
|
||||||
free(key);
|
|
||||||
if (p_expect(&p, ',')) continue;
|
|
||||||
if (p_expect(&p, '}')) break;
|
|
||||||
die_bad_request("malformed object");
|
|
||||||
}
|
|
||||||
done:
|
|
||||||
if (r->argc == 0) die_bad_request("argv is required and non-empty");
|
|
||||||
r->argv[r->argc] = NULL;
|
|
||||||
r->env[r->envc] = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
/* read stdin into a bounded buffer */
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
static char *slurp_stdin(size_t *out_len) {
|
|
||||||
char *buf = malloc(MAX_INPUT);
|
|
||||||
if (!buf) die_syscall("malloc");
|
|
||||||
size_t n = 0;
|
|
||||||
while (n < MAX_INPUT) {
|
|
||||||
ssize_t r = read(0, buf + n, MAX_INPUT - n);
|
|
||||||
if (r == 0) break;
|
|
||||||
if (r < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
die_syscall("read(stdin)");
|
|
||||||
}
|
|
||||||
n += (size_t)r;
|
|
||||||
}
|
|
||||||
if (n == MAX_INPUT) die_bad_request("request too large");
|
|
||||||
*out_len = n;
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
/* PTY open + spawn */
|
|
||||||
/* -------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
static void send_fd(int sock, int fd) {
|
|
||||||
/* sendmsg with SCM_RIGHTS; one byte payload so receiver knows to read. */
|
|
||||||
char byte = 'x';
|
|
||||||
struct iovec iov = { .iov_base = &byte, .iov_len = 1 };
|
|
||||||
union {
|
|
||||||
struct cmsghdr hdr;
|
|
||||||
char buf[CMSG_SPACE(sizeof(int))];
|
|
||||||
} cbuf;
|
|
||||||
memset(&cbuf, 0, sizeof(cbuf));
|
|
||||||
|
|
||||||
struct msghdr msg = {0};
|
|
||||||
msg.msg_iov = &iov;
|
|
||||||
msg.msg_iovlen = 1;
|
|
||||||
msg.msg_control = cbuf.buf;
|
|
||||||
msg.msg_controllen = sizeof(cbuf.buf);
|
|
||||||
|
|
||||||
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
|
|
||||||
cmsg->cmsg_level = SOL_SOCKET;
|
|
||||||
cmsg->cmsg_type = SCM_RIGHTS;
|
|
||||||
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
|
|
||||||
memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
ssize_t r = sendmsg(sock, &msg, 0);
|
|
||||||
if (r < 0 && errno == EINTR) continue;
|
|
||||||
if (r < 0) die_syscall("sendmsg(fd)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
Request req;
|
|
||||||
req_init(&req);
|
|
||||||
|
|
||||||
size_t in_len = 0;
|
|
||||||
char *in = slurp_stdin(&in_len);
|
|
||||||
parse_request(in, in_len, &req);
|
|
||||||
free(in);
|
|
||||||
|
|
||||||
/* 1. open master */
|
|
||||||
int master = posix_openpt(O_RDWR | O_NOCTTY);
|
|
||||||
if (master < 0) die_syscall("posix_openpt");
|
|
||||||
if (grantpt(master) < 0) die_syscall("grantpt");
|
|
||||||
if (unlockpt(master) < 0) die_syscall("unlockpt");
|
|
||||||
|
|
||||||
/* 2. open slave (ptsname is POSIX; we're single-threaded) */
|
|
||||||
const char *slave_path = ptsname(master);
|
|
||||||
if (!slave_path) die_syscall("ptsname");
|
|
||||||
int slave = open(slave_path, O_RDWR | O_NOCTTY);
|
|
||||||
if (slave < 0) die_syscall("open(slave)");
|
|
||||||
|
|
||||||
/* 3. apply window size */
|
|
||||||
struct winsize ws = {0};
|
|
||||||
ws.ws_col = (unsigned short)req.cols;
|
|
||||||
ws.ws_row = (unsigned short)req.rows;
|
|
||||||
if (ioctl(master, TIOCSWINSZ, &ws) < 0) die_syscall("ioctl(TIOCSWINSZ)");
|
|
||||||
|
|
||||||
/* 4. exec-failure-reporting pipe (CLOEXEC so it auto-closes on success) */
|
|
||||||
int ef[2];
|
|
||||||
if (pipe(ef) < 0) die_syscall("pipe");
|
|
||||||
if (fcntl(ef[1], F_SETFD, FD_CLOEXEC) < 0) die_syscall("fcntl(FD_CLOEXEC)");
|
|
||||||
|
|
||||||
pid_t pid = fork();
|
|
||||||
if (pid < 0) die_syscall("fork");
|
|
||||||
|
|
||||||
if (pid == 0) {
|
|
||||||
/* ---- child ---- */
|
|
||||||
close(master);
|
|
||||||
close(ef[0]);
|
|
||||||
|
|
||||||
if (setsid() < 0) { report_errno(ef[1], errno); _exit(127); }
|
|
||||||
#ifdef TIOCSCTTY
|
|
||||||
if (ioctl(slave, TIOCSCTTY, 0) < 0) { report_errno(ef[1], errno); _exit(127); }
|
|
||||||
#endif
|
|
||||||
if (dup2(slave, 0) < 0 || dup2(slave, 1) < 0 || dup2(slave, 2) < 0) {
|
|
||||||
report_errno(ef[1], errno);
|
|
||||||
_exit(127);
|
|
||||||
}
|
|
||||||
if (slave > 2) close(slave);
|
|
||||||
|
|
||||||
if (req.cwd && chdir(req.cwd) < 0) { report_errno(ef[1], errno); _exit(127); }
|
|
||||||
|
|
||||||
/* Replace the environment if the caller supplied one; otherwise
|
|
||||||
* inherit. Daemon is expected to build the env it wants — this is
|
|
||||||
* not a "merge" API. */
|
|
||||||
if (req.envc > 0) {
|
|
||||||
environ = NULL;
|
|
||||||
for (int i = 0; i < req.envc; i++) {
|
|
||||||
if (putenv(req.env[i]) != 0) { report_errno(ef[1], errno); _exit(127); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
execvp(req.argv[0], req.argv);
|
|
||||||
/* execvp returned → failure */
|
|
||||||
report_errno(ef[1], errno);
|
|
||||||
_exit(127);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- parent ---- */
|
|
||||||
close(slave);
|
|
||||||
close(ef[1]);
|
|
||||||
|
|
||||||
int child_errno = 0;
|
|
||||||
ssize_t rr;
|
|
||||||
for (;;) {
|
|
||||||
rr = read(ef[0], &child_errno, sizeof(child_errno));
|
|
||||||
if (rr < 0 && errno == EINTR) continue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
close(ef[0]);
|
|
||||||
|
|
||||||
if (rr == (ssize_t)sizeof(child_errno)) {
|
|
||||||
/* exec failed in child; reap it so we don't leak a zombie. */
|
|
||||||
int st;
|
|
||||||
(void)waitpid(pid, &st, 0);
|
|
||||||
close(master);
|
|
||||||
errno = child_errno;
|
|
||||||
die_syscall("execvp");
|
|
||||||
}
|
|
||||||
/* rr == 0: pipe closed via CLOEXEC on successful exec. */
|
|
||||||
|
|
||||||
/* Hand the master fd back to the parent caller over a unix socket.
|
|
||||||
* Default fd is 3; callers that can't reliably place the socket at
|
|
||||||
* fd 3 (e.g. Python's subprocess with stdout=PIPE shifts pipe fds
|
|
||||||
* around fd 3) can override via PTYC_SOCK_FD. */
|
|
||||||
int sock_fd = 3;
|
|
||||||
const char *sock_env = getenv("PTYC_SOCK_FD");
|
|
||||||
if (sock_env && *sock_env) {
|
|
||||||
char *endp = NULL;
|
|
||||||
long v = strtol(sock_env, &endp, 10);
|
|
||||||
if (!endp || *endp != '\0' || v < 0 || v > 65535)
|
|
||||||
die_bad_request("PTYC_SOCK_FD must be a non-negative integer");
|
|
||||||
sock_fd = (int)v;
|
|
||||||
}
|
|
||||||
send_fd(sock_fd, master);
|
|
||||||
close(master);
|
|
||||||
|
|
||||||
/* Emit success response on stdout and exit. */
|
|
||||||
printf("{\"ok\":true,\"pid\":%ld}\n", (long)pid);
|
|
||||||
fflush(stdout);
|
|
||||||
|
|
||||||
req_free(&req);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
BIN="$HERE/bin/ptyc"
|
|
||||||
|
|
||||||
if [[ ! -x "$BIN" ]]; then
|
|
||||||
echo "test: bin/ptyc not built — run 'make' first" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "$BIN" <<'PY'
|
|
||||||
import errno
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
ptyc = sys.argv[1]
|
|
||||||
|
|
||||||
|
|
||||||
def spawn(req):
|
|
||||||
"""Launch ptyc, pipe the request in, receive fd + response."""
|
|
||||||
sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
child_fd = sock_child.fileno()
|
|
||||||
env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)}
|
|
||||||
try:
|
|
||||||
p = subprocess.Popen(
|
|
||||||
[ptyc],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
pass_fds=(child_fd,),
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
sock_child.close()
|
|
||||||
|
|
||||||
p.stdin.write(json.dumps(req).encode())
|
|
||||||
p.stdin.close()
|
|
||||||
|
|
||||||
fd = None
|
|
||||||
try:
|
|
||||||
msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4))
|
|
||||||
for cmsg_level, cmsg_type, cmsg_data in ancdata:
|
|
||||||
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
|
|
||||||
fd = int.from_bytes(cmsg_data[:4], "little")
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
sock_parent.close()
|
|
||||||
|
|
||||||
stdout = p.stdout.read().decode()
|
|
||||||
stderr = p.stderr.read().decode()
|
|
||||||
code = p.wait()
|
|
||||||
return code, stdout, stderr, fd
|
|
||||||
|
|
||||||
|
|
||||||
def expect_ok(req, reads_substr=None):
|
|
||||||
code, out, err, fd = spawn(req)
|
|
||||||
assert code == 0, f"exit={code}, stderr={err!r}"
|
|
||||||
j = json.loads(out)
|
|
||||||
assert j["ok"] is True, out
|
|
||||||
assert j["pid"] > 0, out
|
|
||||||
assert fd is not None and fd >= 0, "no fd received"
|
|
||||||
pid = j["pid"]
|
|
||||||
try:
|
|
||||||
if reads_substr is not None:
|
|
||||||
chunks = []
|
|
||||||
deadline = time.time() + 5.0
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
data = os.read(fd, 4096)
|
|
||||||
except OSError as e:
|
|
||||||
if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux
|
|
||||||
break
|
|
||||||
raise
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
chunks.append(data.decode(errors="replace"))
|
|
||||||
if reads_substr in "".join(chunks):
|
|
||||||
break
|
|
||||||
got = "".join(chunks)
|
|
||||||
assert reads_substr in got, f"expected {reads_substr!r} in {got!r}"
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
# Child should exit on its own after producing its output for an
|
|
||||||
# `echo`; give it a moment, then reap.
|
|
||||||
try:
|
|
||||||
for _ in range(20):
|
|
||||||
rpid, _ = os.waitpid(pid, os.WNOHANG)
|
|
||||||
if rpid == pid:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
else:
|
|
||||||
os.kill(pid, 9)
|
|
||||||
os.waitpid(pid, 0)
|
|
||||||
except ChildProcessError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def expect_err(req, match_fragment):
|
|
||||||
code, out, err, fd = spawn(req)
|
|
||||||
assert code != 0, f"expected failure, got ok: {out!r}"
|
|
||||||
assert fd is None, "error path must not send an fd"
|
|
||||||
j = json.loads(err)
|
|
||||||
assert j["ok"] is False, err
|
|
||||||
assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}"
|
|
||||||
|
|
||||||
|
|
||||||
# 1. Happy path: echo prints and exits cleanly.
|
|
||||||
expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc")
|
|
||||||
|
|
||||||
# 2. env replacement works — child sees exactly the keys we pass.
|
|
||||||
expect_ok(
|
|
||||||
{"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}},
|
|
||||||
reads_substr="FOO=bar",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. cwd respected.
|
|
||||||
expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp")
|
|
||||||
|
|
||||||
# 4. window size propagates (stty reports it).
|
|
||||||
expect_ok(
|
|
||||||
{"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42},
|
|
||||||
reads_substr="42 132",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Bad request: missing argv.
|
|
||||||
expect_err({}, "argv is required")
|
|
||||||
|
|
||||||
# 6. Bad request: argv not an array.
|
|
||||||
expect_err({"argv": "bash"}, "argv must be an array")
|
|
||||||
|
|
||||||
# 7. exec failure reported on error channel.
|
|
||||||
expect_err({"argv": ["/does/not/exist/nope"]}, "execvp")
|
|
||||||
|
|
||||||
# 8. Unknown key rejected.
|
|
||||||
expect_err({"argv": ["true"], "wat": 1}, "unknown key")
|
|
||||||
|
|
||||||
print("ptyc: all smoke tests passed")
|
|
||||||
PY
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
/// End-to-end tests for the tier-2 CLI shortcuts.
|
|
||||||
///
|
|
||||||
/// Each test launches `bin/clide --daemon` as a subprocess, exercises
|
|
||||||
/// a shortcut (`open`, `active`, `insert`, `save`, `tail`), and
|
|
||||||
/// asserts the JSON response shape. Requires a built `bin/clide`
|
|
||||||
/// binary — `ci/test_core.sh` runs `make build` first when needed,
|
|
||||||
/// or here we build on demand.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
const _socketEnv = 'CLIDE_SOCKET_PATH';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late Directory sandbox;
|
|
||||||
late Process daemon;
|
|
||||||
late String socketPath;
|
|
||||||
late String clideBin;
|
|
||||||
|
|
||||||
setUpAll(() async {
|
|
||||||
final candidate = File('bin/clide');
|
|
||||||
if (!candidate.existsSync()) {
|
|
||||||
final built = await Process.run('make', const ['build']);
|
|
||||||
if (built.exitCode != 0) {
|
|
||||||
throw StateError('make build failed: ${built.stderr}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clideBin = candidate.absolute.path;
|
|
||||||
});
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
sandbox = await Directory.systemTemp.createTemp('clide-cli-t2-');
|
|
||||||
await File('${sandbox.path}/doc.md').writeAsString('alpha beta');
|
|
||||||
// Each test gets its own socket path so concurrent test runs don't
|
|
||||||
// collide. Passed through the daemon via env.
|
|
||||||
socketPath = '${sandbox.path}/daemon.sock';
|
|
||||||
daemon = await Process.start(
|
|
||||||
clideBin,
|
|
||||||
const ['--daemon'],
|
|
||||||
workingDirectory: sandbox.path,
|
|
||||||
environment: {
|
|
||||||
...Platform.environment,
|
|
||||||
_socketEnv: socketPath,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Wait for the "listening" line on stderr so we know it's ready.
|
|
||||||
final ready = Completer<void>();
|
|
||||||
daemon.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
|
|
||||||
if (!ready.isCompleted && line.contains('listening')) {
|
|
||||||
ready.complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await ready.future.timeout(const Duration(seconds: 5));
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
daemon.kill(ProcessSignal.sigterm);
|
|
||||||
await daemon.exitCode.timeout(const Duration(seconds: 3), onTimeout: () {
|
|
||||||
daemon.kill(ProcessSignal.sigkill);
|
|
||||||
return -1;
|
|
||||||
});
|
|
||||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<Map<String, Object?>> run(List<String> args) async {
|
|
||||||
final r = await Process.run(
|
|
||||||
clideBin,
|
|
||||||
args,
|
|
||||||
workingDirectory: sandbox.path,
|
|
||||||
environment: {
|
|
||||||
...Platform.environment,
|
|
||||||
_socketEnv: socketPath,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
|
|
||||||
return jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('clide open <path> returns buffer metadata', () async {
|
|
||||||
final r = await run(['open', 'doc.md']);
|
|
||||||
expect(r['id'], startsWith('b_'));
|
|
||||||
expect(r['path'], 'doc.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clide active reflects the most recent open', () async {
|
|
||||||
await run(['open', 'doc.md']);
|
|
||||||
final r = await run(['active']);
|
|
||||||
final active = r['active']! as Map;
|
|
||||||
expect(active['path'], 'doc.md');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clide insert + clide active round-trip', () async {
|
|
||||||
await run(['open', 'doc.md']);
|
|
||||||
await run(['insert', 'hello ']);
|
|
||||||
final r = await run(['active']);
|
|
||||||
final active = r['active']! as Map;
|
|
||||||
expect(active['dirty'], isTrue);
|
|
||||||
expect((active['length'] as num).toInt(), greaterThan('alpha beta'.length));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clide save clears dirty + writes to disk', () async {
|
|
||||||
await run(['open', 'doc.md']);
|
|
||||||
await run(['insert', 'X ']);
|
|
||||||
await run(['save']);
|
|
||||||
final active = (await run(['active']))['active']! as Map;
|
|
||||||
expect(active['dirty'], isFalse);
|
|
||||||
final disk = await File('${sandbox.path}/doc.md').readAsString();
|
|
||||||
expect(disk.startsWith('X '), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clide tail --events streams editor.* events', () async {
|
|
||||||
// Start a tail subscriber.
|
|
||||||
final tail = await Process.start(
|
|
||||||
clideBin,
|
|
||||||
const ['tail', '--events', '--filter', 'editor'],
|
|
||||||
workingDirectory: sandbox.path,
|
|
||||||
environment: {
|
|
||||||
...Platform.environment,
|
|
||||||
_socketEnv: socketPath,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final received = <Map<String, Object?>>[];
|
|
||||||
final sub = tail.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
|
|
||||||
if (line.isEmpty) return;
|
|
||||||
received.add(jsonDecode(line) as Map<String, Object?>);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give the subscriber a beat to connect.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
||||||
|
|
||||||
await run(['open', 'doc.md']);
|
|
||||||
await run(['insert', 'T ']);
|
|
||||||
|
|
||||||
// Wait up to 2s for events.
|
|
||||||
for (var i = 0; i < 20 && received.length < 3; i++) {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
tail.kill(ProcessSignal.sigint);
|
|
||||||
await tail.exitCode.timeout(const Duration(seconds: 2), onTimeout: () {
|
|
||||||
tail.kill(ProcessSignal.sigkill);
|
|
||||||
return -1;
|
|
||||||
});
|
|
||||||
await sub.cancel();
|
|
||||||
|
|
||||||
final kinds = received.map((e) => e['kind']).toList();
|
|
||||||
expect(kinds, containsAll(['editor.opened', 'editor.edited']));
|
|
||||||
// Confirm filter actually filtered — no pane events made it in.
|
|
||||||
for (final e in received) {
|
|
||||||
expect(e['subsystem'], 'editor');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('DaemonServer (in-process)', () {
|
|
||||||
late DaemonServer server;
|
|
||||||
late DaemonDispatcher dispatcher;
|
|
||||||
late String socketPath;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
final tmp = await Directory.systemTemp.createTemp('clide_daemon_');
|
|
||||||
socketPath = '${tmp.path}/daemon.sock';
|
|
||||||
dispatcher = DaemonDispatcher();
|
|
||||||
server = DaemonServer(
|
|
||||||
socketPath: socketPath,
|
|
||||||
dispatch: dispatcher.dispatch,
|
|
||||||
);
|
|
||||||
await server.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await server.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ping round-trips with pong=true', () async {
|
|
||||||
final resp = await _send(
|
|
||||||
socketPath,
|
|
||||||
IpcRequest(id: '1', cmd: 'ping').encode(),
|
|
||||||
);
|
|
||||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
|
||||||
expect(parsed.ok, true);
|
|
||||||
expect(parsed.id, '1');
|
|
||||||
expect(parsed.data['pong'], true);
|
|
||||||
expect(parsed.data['ts'], isA<String>());
|
|
||||||
expect(parsed.data['version'], isA<String>());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('version returns current clideVersion', () async {
|
|
||||||
final resp = await _send(
|
|
||||||
socketPath,
|
|
||||||
IpcRequest(id: 'v', cmd: 'version').encode(),
|
|
||||||
);
|
|
||||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
|
||||||
expect(parsed.ok, true);
|
|
||||||
expect(parsed.data['version'], clideVersion);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown command returns NotFound (exit code 3)', () async {
|
|
||||||
final resp = await _send(
|
|
||||||
socketPath,
|
|
||||||
IpcRequest(id: 'x', cmd: 'this.does.not.exist').encode(),
|
|
||||||
);
|
|
||||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
|
||||||
expect(parsed.ok, false);
|
|
||||||
expect(parsed.error!.code, IpcExitCode.notFound);
|
|
||||||
expect(parsed.error!.kind, IpcErrorKind.notFound);
|
|
||||||
expect(parsed.error!.message, contains('this.does.not.exist'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multiple concurrent requests on one connection', () async {
|
|
||||||
final socket = await Socket.connect(
|
|
||||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
for (var i = 0; i < 5; i++) {
|
|
||||||
socket.writeln(IpcRequest(id: '$i', cmd: 'ping').encode());
|
|
||||||
}
|
|
||||||
final lines = <String>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
final sub = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
|
|
||||||
lines.add(line);
|
|
||||||
if (lines.length == 5) done.complete();
|
|
||||||
});
|
|
||||||
await done.future.timeout(const Duration(seconds: 2));
|
|
||||||
await sub.cancel();
|
|
||||||
await socket.close();
|
|
||||||
final ids = lines.map((l) => (IpcMessage.decode(l) as IpcResponse).id).toSet();
|
|
||||||
expect(ids, {'0', '1', '2', '3', '4'});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('custom handler plugs into dispatcher', () async {
|
|
||||||
dispatcher.register('test.custom', (req) async {
|
|
||||||
return IpcResponse.ok(id: req.id, data: {'echo': req.args});
|
|
||||||
});
|
|
||||||
final resp = await _send(
|
|
||||||
socketPath,
|
|
||||||
IpcRequest(
|
|
||||||
id: 'c',
|
|
||||||
cmd: 'test.custom',
|
|
||||||
args: const {'x': 1},
|
|
||||||
).encode(),
|
|
||||||
);
|
|
||||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
|
||||||
expect(parsed.ok, true);
|
|
||||||
expect(parsed.data['echo'], {'x': 1});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _send(String socketPath, String line) async {
|
|
||||||
final socket = await Socket.connect(
|
|
||||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
socket.writeln(line);
|
|
||||||
final resp = await socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
|
|
||||||
await socket.close();
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
import 'package:clide/kernel/src/toolchain.dart';
|
|
||||||
import 'package:clide/src/daemon/pane_commands.dart';
|
import 'package:clide/src/daemon/pane_commands.dart';
|
||||||
import 'package:clide/src/panes/registry.dart';
|
import 'package:clide/src/panes/registry.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
@@ -17,9 +16,6 @@ import 'package:test/test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
if (!Platform.isLinux && !Platform.isMacOS) return;
|
if (!Platform.isLinux && !Platform.isMacOS) return;
|
||||||
|
|
||||||
final toolchain = Toolchain();
|
|
||||||
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
|
||||||
|
|
||||||
group('pane.* dispatch', () {
|
group('pane.* dispatch', () {
|
||||||
late DaemonDispatcher dispatcher;
|
late DaemonDispatcher dispatcher;
|
||||||
late PaneRegistry registry;
|
late PaneRegistry registry;
|
||||||
@@ -48,7 +44,6 @@ void main() {
|
|||||||
final r = await call('pane.spawn', {
|
final r = await call('pane.spawn', {
|
||||||
'argv': const ['/bin/sh', '-c', 'sleep 0.1'],
|
'argv': const ['/bin/sh', '-c', 'sleep 0.1'],
|
||||||
'kind': 'terminal',
|
'kind': 'terminal',
|
||||||
'ptyc_path': toolchain.ptyc,
|
|
||||||
});
|
});
|
||||||
expect(r.ok, isTrue, reason: r.error?.message);
|
expect(r.ok, isTrue, reason: r.error?.message);
|
||||||
expect(r.data['id'], startsWith('p_'));
|
expect(r.data['id'], startsWith('p_'));
|
||||||
@@ -58,12 +53,10 @@ void main() {
|
|||||||
test('pane.list shows spawned panes', () async {
|
test('pane.list shows spawned panes', () async {
|
||||||
await call('pane.spawn', {
|
await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'ptyc_path': toolchain.ptyc,
|
|
||||||
});
|
});
|
||||||
await call('pane.spawn', {
|
await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'kind': 'claude',
|
'kind': 'claude',
|
||||||
'ptyc_path': toolchain.ptyc,
|
|
||||||
});
|
});
|
||||||
final r = await call('pane.list', const {});
|
final r = await call('pane.list', const {});
|
||||||
final panes = (r.data['panes'] as List).cast<Map>();
|
final panes = (r.data['panes'] as List).cast<Map>();
|
||||||
@@ -74,7 +67,6 @@ void main() {
|
|||||||
test('pane.write accepts text or bytes_b64', () async {
|
test('pane.write accepts text or bytes_b64', () async {
|
||||||
final spawn = await call('pane.spawn', {
|
final spawn = await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'ptyc_path': toolchain.ptyc,
|
|
||||||
});
|
});
|
||||||
final id = spawn.data['id']! as String;
|
final id = spawn.data['id']! as String;
|
||||||
|
|
||||||
@@ -98,7 +90,6 @@ void main() {
|
|||||||
test('pane.resize + pane.close + pane.focus round-trip', () async {
|
test('pane.resize + pane.close + pane.focus round-trip', () async {
|
||||||
final spawn = await call('pane.spawn', {
|
final spawn = await call('pane.spawn', {
|
||||||
'argv': const ['/bin/cat'],
|
'argv': const ['/bin/cat'],
|
||||||
'ptyc_path': toolchain.ptyc,
|
|
||||||
});
|
});
|
||||||
final id = spawn.data['id']! as String;
|
final id = spawn.data['id']! as String;
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
/// Subprocess-level daemon smoke. Only runs if `bin/clide` has been
|
|
||||||
/// built (the test skips itself otherwise). This is the release-gate
|
|
||||||
/// suite — catches signal-handling, socket-unlink, and version-stamp
|
|
||||||
/// regressions that the in-process test masks.
|
|
||||||
void main() {
|
|
||||||
final binary = File('bin/clide');
|
|
||||||
|
|
||||||
group('bin/clide --daemon (subprocess)', () {
|
|
||||||
setUpAll(() {
|
|
||||||
if (!binary.existsSync()) {
|
|
||||||
markTestSkipped('bin/clide not built; run `make build` first to enable this suite');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starts, responds to ping, exits cleanly on SIGTERM', () async {
|
|
||||||
if (!binary.existsSync()) return;
|
|
||||||
|
|
||||||
// Use a fresh socket under a unique temp path so parallel test
|
|
||||||
// runs don't collide. The daemon resolves its socket path from
|
|
||||||
// XDG_RUNTIME_DIR + USER (see defaultSocketPath()).
|
|
||||||
final tmp = await Directory.systemTemp.createTemp('clide_sub_');
|
|
||||||
final env = Map<String, String>.from(Platform.environment)
|
|
||||||
..['XDG_RUNTIME_DIR'] = tmp.path
|
|
||||||
..['USER'] = 'daemon';
|
|
||||||
final socketPath = '${tmp.path}/clide-daemon.sock';
|
|
||||||
|
|
||||||
final process = await Process.start(
|
|
||||||
binary.absolute.path,
|
|
||||||
['--daemon'],
|
|
||||||
environment: env,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for "listening on ..." on stderr before connecting.
|
|
||||||
final ready = Completer<void>();
|
|
||||||
final stderrLines = <String>[];
|
|
||||||
final sub = process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
|
|
||||||
stderrLines.add(line);
|
|
||||||
if (line.contains('listening')) ready.complete();
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ready.future.timeout(const Duration(seconds: 3));
|
|
||||||
|
|
||||||
// Connect and ping
|
|
||||||
final sock = await Socket.connect(
|
|
||||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
|
||||||
0,
|
|
||||||
).timeout(const Duration(seconds: 3));
|
|
||||||
sock.writeln(IpcRequest(id: '1', cmd: 'ping').encode());
|
|
||||||
final line = await sock.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 3));
|
|
||||||
await sock.close();
|
|
||||||
final resp = IpcMessage.decode(line) as IpcResponse;
|
|
||||||
expect(resp.ok, true);
|
|
||||||
expect(resp.data['pong'], true);
|
|
||||||
|
|
||||||
// Clean shutdown
|
|
||||||
process.kill(ProcessSignal.sigterm);
|
|
||||||
final exitCode = await process.exitCode.timeout(const Duration(seconds: 3));
|
|
||||||
expect(exitCode, 0);
|
|
||||||
|
|
||||||
// Socket file should be unlinked
|
|
||||||
expect(await File(socketPath).exists(), false);
|
|
||||||
} finally {
|
|
||||||
await sub.cancel();
|
|
||||||
try {
|
|
||||||
process.kill(ProcessSignal.sigkill);
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await tmp.delete(recursive: true);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}, timeout: const Timeout(Duration(seconds: 15)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:alchemist/alchemist.dart';
|
||||||
|
|
||||||
|
import '../helpers/golden_harness.dart';
|
||||||
|
|
||||||
|
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||||
|
return AlchemistConfig.runWithConfig(
|
||||||
|
config: clideGoldenConfig(),
|
||||||
|
run: testMain,
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 809 B |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -2,21 +2,18 @@ import 'package:alchemist/alchemist.dart';
|
|||||||
|
|
||||||
/// Alchemist config shared across all golden tests.
|
/// Alchemist config shared across all golden tests.
|
||||||
///
|
///
|
||||||
/// * CI mode uses the Ahem font (shipped with Flutter's test harness) so
|
/// Platform goldens only — keyed by OS (`goldens/linux/`, `goldens/macos/`).
|
||||||
/// goldens render identically on every Linux runner and developer
|
/// CI goldens (Ahem font in `goldens/ci/`) are disabled because Skia's
|
||||||
/// machine. Any drift between platforms points to a real theme-token
|
/// geometric anti-aliasing differs between macOS and Linux even with Ahem,
|
||||||
/// regression, not a font-rendering fluke.
|
/// producing sub-pixel diffs that fail cross-platform.
|
||||||
/// * Local mode keeps developer-machine fonts so you can eyeball
|
AlchemistConfig clideGoldenConfig() {
|
||||||
/// renders naturally; the `--update-goldens` workflow still produces
|
return const AlchemistConfig(
|
||||||
/// CI-valid goldens because CI runs the config below.
|
|
||||||
AlchemistConfig clideGoldenConfig({bool forceCiMode = false}) {
|
|
||||||
return AlchemistConfig(
|
|
||||||
theme: null, // we're not using Material ThemeData
|
theme: null, // we're not using Material ThemeData
|
||||||
platformGoldensConfig: PlatformGoldensConfig(
|
platformGoldensConfig: PlatformGoldensConfig(
|
||||||
enabled: !forceCiMode,
|
|
||||||
),
|
|
||||||
ciGoldensConfig: const CiGoldensConfig(
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
),
|
),
|
||||||
|
ciGoldensConfig: CiGoldensConfig(
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// Unit tests for [PaneRegistry].
|
/// Unit tests for [PaneRegistry].
|
||||||
///
|
///
|
||||||
/// Exercises spawn / list / write / resize / close against the real
|
/// Exercises spawn / list / write / resize / close against the real
|
||||||
/// `ptyc` binary (small enough, and realistic enough, to not be worth
|
/// NativePty (forkpty via FFI). Events are captured via
|
||||||
/// mocking). Events are captured via [RecordingEventSink].
|
/// [RecordingEventSink].
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|||||||
Reference in New Issue
Block a user