feat(web): fence dart:ffi behind web stubs so the WASM build compiles (T-438, D-100)
`flutter build web --wasm` had been broken since the tree-sitter/PTY dart:ffi pivot. Per D-100 (resolving Q-50: keep the web "happy accident" alive), every native binding now sits behind a `dart.library.ffi` conditional import with a graceful web stub. Desktop builds are unchanged — no fidelity loss; the web target degrades (no terminal, native git, or syntax highlighting). Discriminator is `dart.library.ffi`, not `dart.library.io` — dart2wasm provides dart:io, so FFI is the only blocker. Fences: - PTY: pty_session → pty_backend_io / pty_backend_web (stub throws). - tree-sitter: pure types → syntax_result.dart; tree_sitter_service is now a facade over _ffi/_stub; tree_sitter_boot_io/stub fences TreeSitterLib.init(). - watchdog: watchdog_windows_stub (all -1 sampler). - claude ABI probe: native_abi_io/stub (was `dart:ffi show Abi`). - testmode fd-check: fd_check_io/stub. Also dart2js-safe: the 64-bit FNV literals in session_naming.dart + paths.dart (the dual JS fallback rejected them) — split into 32-bit halves, dropped a no-op 64-bit mask. Desktop/wasm hash values unchanged. CI: added a `web-wasm` job (flutter build web --wasm) so the fence can't rot. Two FFI-constructing tree-sitter tests import _ffi.dart directly (the analyzer resolves the conditional facade to the stub branch). Verified: `flutter build web --wasm` → built; `flutter analyze` clean; `make test` green. Full Playwright e2e harness wiring is the tracked follow-on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,13 +114,21 @@ jobs:
|
||||
path: ${{ github.workspace }}/clide-logs
|
||||
if-no-files-found: ignore
|
||||
|
||||
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot
|
||||
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is
|
||||
# unavailable on the wasm target). Whether the web target gets conditional-
|
||||
# import fences or is dropped is an open question — see Q-50 in
|
||||
# governance/questions/architecture.md. Re-add the job (setup-node, npm
|
||||
# install + playwright install in tools/ui, `make test-e2e`) when Q-50
|
||||
# resolves toward keeping it.
|
||||
web-wasm:
|
||||
name: web build (wasm compile gate)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: flutter pub get
|
||||
# Compile gate for the dart:ffi web fence (D-100 / T-438, resolving Q-50):
|
||||
# every native binding lives behind a `dart.library.ffi` conditional import
|
||||
# with a web stub. If a new one lands without its stub, this fails — the
|
||||
# fence can't silently rot. Build-only for now; the full web-WASM Playwright
|
||||
# e2e (`make test-e2e`: setup-node + playwright install in tools/ui +
|
||||
# serve) is the follow-on once the harness is wired back up.
|
||||
- run: flutter build web --wasm
|
||||
|
||||
docs:
|
||||
name: dart doc (lib API)
|
||||
|
||||
@@ -5373,3 +5373,84 @@ The one live remnant — the dead `make test-e2e` / `ui-dev` / `ui-smoke` target
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'review', 'done', NULL, '2026-06-15 14:25:10', '2026-06-15 14:25:10', '2026-06-15 14:25:10', NULL, 'fba93f08e50b81ab6c3c4b5d1dd4f1d9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 14:32:22', '2026-06-15 14:32:22', '2026-06-15 14:32:22', NULL, '80f8ce4f94230fe7c842bd25e1b7f131', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 14:33:11', '2026-06-15 14:33:11', '2026-06-15 14:33:11', NULL, '783dee14da908512f766c8d9b0351e1d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'description', NULL, '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
|
||||
|
||||
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
|
||||
|
||||
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
|
||||
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
|
||||
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
|
||||
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
|
||||
|
||||
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
|
||||
|
||||
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
|
||||
|
||||
**Proposed fix (two parts).**
|
||||
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
|
||||
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
|
||||
|
||||
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
|
||||
|
||||
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', NULL, '2026-06-15 15:23:47', '2026-06-15 15:23:47', '2026-06-15 15:23:47', NULL, 'c5a27e255cd62d26167231269985ba39', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
|
||||
|
||||
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
|
||||
|
||||
**dart:ffi importers to fence (as of 2026-06-15):**
|
||||
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
|
||||
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
|
||||
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
|
||||
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
|
||||
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
|
||||
|
||||
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
|
||||
|
||||
**Acceptance.**
|
||||
- `flutter build web --wasm` compiles the tree.
|
||||
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
|
||||
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
|
||||
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
|
||||
|
||||
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
|
||||
|
||||
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
|
||||
|
||||
**dart:ffi importers to fence (as of 2026-06-15):**
|
||||
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
|
||||
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
|
||||
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
|
||||
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
|
||||
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
|
||||
|
||||
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
|
||||
|
||||
**Acceptance.**
|
||||
- `flutter build web --wasm` compiles the tree.
|
||||
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
|
||||
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
|
||||
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
|
||||
|
||||
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
|
||||
|
||||
---
|
||||
|
||||
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
|
||||
|
||||
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
|
||||
|
||||
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
|
||||
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
|
||||
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
|
||||
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
|
||||
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
|
||||
- testmode fd-check: `fd_check_io/stub.dart`.
|
||||
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
|
||||
|
||||
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
|
||||
|
||||
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
|
||||
|
||||
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, '0cf096a810a7a075307c8df8321b5c33', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'review', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, 'a7d81444607b6ec3aa8440fcbc9abee3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 15:31:15', '2026-06-15 15:31:15', '2026-06-15 15:31:15', NULL, '5fed82be61479c8191ed8d0663507cad', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -263,3 +263,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -3,3 +3,5 @@ INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, dele
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'path', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, 'e73b3b85732a2c21be23353a0f7f1493', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'web', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, '8c93337891e74d67d2465ba2c3b901d5', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
|
||||
@@ -6113,3 +6113,124 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
|
||||
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
|
||||
|
||||
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:33:11', NULL, '6733711cd4992646502b3d765eb107be', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '4291af0f25de8a8cc5f58e088b7072ce', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
|
||||
|
||||
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
|
||||
|
||||
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
|
||||
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
|
||||
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
|
||||
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
|
||||
|
||||
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
|
||||
|
||||
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
|
||||
|
||||
**Proposed fix (two parts).**
|
||||
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
|
||||
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
|
||||
|
||||
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
|
||||
|
||||
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:23:47', NULL, '611a14a812f99edc4b615df9d2e8cdc4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
|
||||
|
||||
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
|
||||
|
||||
**dart:ffi importers to fence (as of 2026-06-15):**
|
||||
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
|
||||
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
|
||||
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
|
||||
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
|
||||
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
|
||||
|
||||
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
|
||||
|
||||
**Acceptance.**
|
||||
- `flutter build web --wasm` compiles the tree.
|
||||
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
|
||||
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
|
||||
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
|
||||
|
||||
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
|
||||
|
||||
---
|
||||
|
||||
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
|
||||
|
||||
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
|
||||
|
||||
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
|
||||
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
|
||||
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
|
||||
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
|
||||
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
|
||||
- testmode fd-check: `fd_check_io/stub.dart`.
|
||||
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
|
||||
|
||||
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
|
||||
|
||||
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
|
||||
|
||||
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:30:48', NULL, '31d185462126f7a8782eec557356e68a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
|
||||
|
||||
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
|
||||
|
||||
**dart:ffi importers to fence (as of 2026-06-15):**
|
||||
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
|
||||
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
|
||||
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
|
||||
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
|
||||
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
|
||||
|
||||
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
|
||||
|
||||
**Acceptance.**
|
||||
- `flutter build web --wasm` compiles the tree.
|
||||
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
|
||||
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
|
||||
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
|
||||
|
||||
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
|
||||
|
||||
---
|
||||
|
||||
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
|
||||
|
||||
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
|
||||
|
||||
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
|
||||
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
|
||||
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
|
||||
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
|
||||
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
|
||||
- testmode fd-check: `fd_check_io/stub.dart`.
|
||||
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
|
||||
|
||||
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
|
||||
|
||||
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
|
||||
|
||||
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'review', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:30:48', NULL, '2820923f9f455f0ab74e76f79661c726', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
|
||||
|
||||
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
|
||||
|
||||
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
|
||||
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
|
||||
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
|
||||
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
|
||||
|
||||
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
|
||||
|
||||
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
|
||||
|
||||
**Proposed fix (two parts).**
|
||||
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
|
||||
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
|
||||
|
||||
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
|
||||
|
||||
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:31:15', NULL, 'c816196f97e6e80bd828c319f99619c8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -49,6 +49,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL)
|
||||
before clearing the transcript and respawning. A dead pane also now shows the
|
||||
CLI's own reason instead of a bare "exited (code 1)". (T-437)
|
||||
- **Web/WASM build compiles again.** `flutter build web --wasm` (and the
|
||||
`test-e2e` / `ui-dev` / `ui-smoke` harness) had been broken since the
|
||||
tree-sitter/PTY `dart:ffi` pivot. Every native binding (PTY, tree-sitter, the
|
||||
Lua host, the Windows watchdog, the ABI/fd-inheritance probes) now sits behind
|
||||
a `dart.library.ffi` conditional import with a graceful web stub, and the FNV
|
||||
hash constants are dart2js-safe. A `flutter build web --wasm` compile gate was
|
||||
added to CI so the fence can't silently rot. Desktop builds are unchanged — the
|
||||
web target degrades (no terminal, no native git, no syntax highlighting), it
|
||||
does not compromise desktop fidelity. (T-438, D-100, Q-50)
|
||||
|
||||
## [2.5.0] — 2026-06-14
|
||||
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
/// IO wrapper the orchestrator calls. Flutter-free by design.
|
||||
library;
|
||||
|
||||
import 'dart:ffi' show Abi;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
||||
|
||||
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
|
||||
// build gets a default dir name with no FFI introspection.
|
||||
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
|
||||
|
||||
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
|
||||
/// hosted session (T-217), so the agent isn't prompted on every IDE call.
|
||||
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is
|
||||
@@ -90,26 +93,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
|
||||
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
|
||||
/// when clide runs un-installed (dogfooding clide-on-clide).
|
||||
String nativeClideDirName({Abi? abi}) {
|
||||
switch (abi ?? Abi.current()) {
|
||||
case Abi.macosArm64:
|
||||
return 'macos-arm64';
|
||||
case Abi.macosX64:
|
||||
return 'macos-x64';
|
||||
case Abi.linuxArm64:
|
||||
return 'linux-arm64';
|
||||
case Abi.linuxX64:
|
||||
return 'linux-x64';
|
||||
default:
|
||||
// Windows / other — clide is desktop linux/macos today; fall back to a
|
||||
// best-effort name so the probe simply misses rather than throwing.
|
||||
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of [agentBootstrap]: the env delta to overlay and the extra
|
||||
/// spawn args (context note + allow rule) to prepend to a session's argv.
|
||||
class AgentBootstrap {
|
||||
@@ -128,7 +111,7 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
|
||||
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH'];
|
||||
final candidates = <String>[
|
||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||
'$workspaceRoot/native/${nativeClideDirName()}',
|
||||
'$workspaceRoot/native/${currentNativeDirName()}',
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
|
||||
/// (T-438 web fence, D-100). Desktop-only; the web build uses
|
||||
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
|
||||
/// wasm graph.
|
||||
library;
|
||||
|
||||
import 'dart:ffi' show Abi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
|
||||
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
|
||||
/// un-installed. [abi] is an injection seam for tests; production passes none.
|
||||
String currentNativeDirName({Abi? abi}) {
|
||||
switch (abi ?? Abi.current()) {
|
||||
case Abi.macosArm64:
|
||||
return 'macos-arm64';
|
||||
case Abi.macosX64:
|
||||
return 'macos-x64';
|
||||
case Abi.linuxArm64:
|
||||
return 'linux-arm64';
|
||||
case Abi.linuxX64:
|
||||
return 'linux-x64';
|
||||
default:
|
||||
// Windows / other — clide is desktop linux/macOS today; fall back to a
|
||||
// best-effort name so the probe simply misses rather than throwing.
|
||||
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
|
||||
/// native dir name only matters for locating a dev-tree `clide` binary, which
|
||||
/// doesn't exist on the web target — so a harmless default suffices.
|
||||
library;
|
||||
|
||||
String currentNativeDirName() => 'linux-x64';
|
||||
@@ -130,12 +130,18 @@ String freshSessionId() {
|
||||
/// same id). Expands an FNV-1a stream into 16 bytes.
|
||||
String _deterministicUuid(String seed) {
|
||||
final bytes = <int>[];
|
||||
var h = 0xcbf29ce484222325;
|
||||
// FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
|
||||
// web fallback accepts it (a full 64-bit literal "can't be represented
|
||||
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
|
||||
// a session id (no claude process there).
|
||||
var h = (0xcbf29ce4 << 32) | 0x84222325;
|
||||
const prime = 0x100000001b3;
|
||||
for (var i = 0; i < 16; i++) {
|
||||
for (final c in utf8.encode('$seed:$i')) {
|
||||
h ^= c;
|
||||
h = (h * prime) & 0xFFFFFFFFFFFFFFFF;
|
||||
// 64-bit modular wrap is implicit on the VM/wasm; the explicit
|
||||
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
|
||||
h = h * prime;
|
||||
}
|
||||
bytes.add(h & 0xff);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/// FFI fd-inheritance probe for the testmode harness (T-438 web fence, D-100).
|
||||
///
|
||||
/// Desktop-only — [test_app.dart] selects [fd_check_stub.dart] on web so the
|
||||
/// `dart:ffi` / `package:ffi` / libc imports stay out of the wasm graph.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
|
||||
/// Probe whether `Process.start` inherits socket fds (the macOS question the
|
||||
/// testmode harness answers). Returns a human-readable result line.
|
||||
Future<String> fdInheritanceCheck() async {
|
||||
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
||||
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
|
||||
final parent = sv[0];
|
||||
final child = sv[1];
|
||||
pkg_ffi.calloc.free(sv);
|
||||
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||
final exit = await proc.exitCode;
|
||||
libc.close(parent);
|
||||
libc.close(child);
|
||||
return 'exit=$exit stderr=${stderr.trim()}';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
|
||||
library;
|
||||
|
||||
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
|
||||
@@ -0,0 +1,54 @@
|
||||
/// Pure syntax-highlight result types + the capture-role→theme-color map
|
||||
/// (T-438 web fence, D-100). No `dart:ffi`, so it is shared by the FFI-backed
|
||||
/// [TreeSitterService] impl and its web stub — both expose identical data types.
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
|
||||
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
|
||||
/// Throws on missing or unreadable assets.
|
||||
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
|
||||
|
||||
/// Loads the highlight query (`.scm` source) for [language], or returns
|
||||
/// null if no query is bundled for it.
|
||||
typedef GrammarQueryLoader = Future<String?> Function(String language);
|
||||
|
||||
class SyntaxSpan {
|
||||
const SyntaxSpan({required this.start, required this.end, required this.role});
|
||||
|
||||
final int start;
|
||||
final int end;
|
||||
final String role;
|
||||
}
|
||||
|
||||
class SyntaxResult {
|
||||
const SyntaxResult(this.spans);
|
||||
final List<SyntaxSpan> spans;
|
||||
|
||||
static const empty = SyntaxResult([]);
|
||||
}
|
||||
|
||||
/// Map a tree-sitter capture [role] to a theme color.
|
||||
Color syntaxColorForRole(String role, SurfaceTokens tokens) {
|
||||
return switch (role) {
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
||||
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
|
||||
'string' || 'string.special' => tokens.syntaxString,
|
||||
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
|
||||
'comment' => tokens.syntaxComment,
|
||||
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
|
||||
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
|
||||
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
|
||||
'property' || 'field' => tokens.syntaxMethod,
|
||||
'constant' || 'constant.builtin' => tokens.syntaxNumber,
|
||||
'tag' || 'attribute' => tokens.syntaxKeyword,
|
||||
'namespace' || 'module' => tokens.syntaxType,
|
||||
'text.title' => tokens.syntaxKeyword,
|
||||
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
|
||||
'text.emphasis' || 'text.strong' => tokens.syntaxType,
|
||||
_ => tokens.globalForeground,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Desktop tree-sitter bootstrap (T-438 web fence, D-100): dlopen the vendored
|
||||
/// libtree-sitter once at startup. The web build uses [tree_sitter_boot_stub.dart].
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
|
||||
/// Initialize the tree-sitter library; returns false if it can't be loaded.
|
||||
bool initTreeSitter() => TreeSitterLib.init();
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Web stub (T-438 web fence, D-100): no tree-sitter FFI to initialize.
|
||||
library;
|
||||
|
||||
bool initTreeSitter() => false;
|
||||
@@ -1,303 +1,7 @@
|
||||
/// Platform facade for the tree-sitter highlighter (T-438 web fence, D-100):
|
||||
/// the FFI-backed [TreeSitterService] on desktop, a no-op stub on web. Both
|
||||
/// re-export the shared [SyntaxSpan]/[SyntaxResult] types and `colorForRole`,
|
||||
/// so consumers import this file unchanged.
|
||||
library;
|
||||
|
||||
import 'dart:convert' show utf8;
|
||||
import 'dart:ffi';
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
|
||||
/// Throws on missing or unreadable assets.
|
||||
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
|
||||
|
||||
/// Loads the highlight query (`.scm` source) for [language], or returns
|
||||
/// null if no query is bundled for it.
|
||||
typedef GrammarQueryLoader = Future<String?> Function(String language);
|
||||
|
||||
class SyntaxSpan {
|
||||
const SyntaxSpan({required this.start, required this.end, required this.role});
|
||||
|
||||
final int start;
|
||||
final int end;
|
||||
final String role;
|
||||
}
|
||||
|
||||
class SyntaxResult {
|
||||
const SyntaxResult(this.spans);
|
||||
final List<SyntaxSpan> spans;
|
||||
|
||||
static const empty = SyntaxResult([]);
|
||||
}
|
||||
|
||||
class _LoadedGrammar {
|
||||
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
|
||||
|
||||
final Pointer<Void> language;
|
||||
final Pointer<TSQuery> query;
|
||||
final List<String> captureNames;
|
||||
}
|
||||
|
||||
class TreeSitterService {
|
||||
static final TreeSitterService shared = TreeSitterService();
|
||||
|
||||
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
|
||||
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
|
||||
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
|
||||
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
|
||||
: _injectedLib = lib,
|
||||
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
|
||||
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
|
||||
|
||||
final TreeSitterLib? _injectedLib;
|
||||
final GrammarBytesLoader _grammarBytes;
|
||||
final GrammarQueryLoader _grammarQuery;
|
||||
|
||||
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
|
||||
|
||||
static Future<Uint8List> _defaultGrammarBytes(String language) async {
|
||||
final data = await rootBundle.load('assets/grammars/$language.wasm');
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Future<String?> _defaultGrammarQuery(String language) async {
|
||||
try {
|
||||
return await rootBundle.loadString('assets/queries/$language.scm');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, _LoadedGrammar> _grammars = {};
|
||||
final Set<String> _unavailable = {};
|
||||
|
||||
Pointer<TSWasmStore>? _store;
|
||||
Pointer<TSParser>? _parser;
|
||||
Pointer<TSQueryCursor>? _cursor;
|
||||
|
||||
bool _initDone = false;
|
||||
|
||||
bool _init() {
|
||||
if (_initDone) return _parser != null;
|
||||
_initDone = true;
|
||||
|
||||
final lib = _lib;
|
||||
if (lib == null) return false;
|
||||
|
||||
final engine = lib.wasmEngineNew();
|
||||
if (engine == nullptr) return false;
|
||||
|
||||
final error = calloc<TSWasmError>();
|
||||
_store = lib.wasmStoreNew(engine, error);
|
||||
lib.wasmEngineDelete(engine);
|
||||
|
||||
if (_store == null || _store == nullptr) {
|
||||
calloc.free(error);
|
||||
return false;
|
||||
}
|
||||
calloc.free(error);
|
||||
|
||||
_parser = lib.parserNew();
|
||||
if (_parser == null || _parser == nullptr) return false;
|
||||
lib.parserSetWasmStore(_parser!, _store!);
|
||||
|
||||
_cursor = lib.queryCursorNew();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<_LoadedGrammar?> _loadGrammar(String language) async {
|
||||
if (_unavailable.contains(language)) return null;
|
||||
final cached = _grammars[language];
|
||||
if (cached != null) return cached;
|
||||
|
||||
if (!_init()) {
|
||||
_unavailable.add(language);
|
||||
return null;
|
||||
}
|
||||
|
||||
final lib = _lib!;
|
||||
|
||||
try {
|
||||
// Load grammar WASM bytes.
|
||||
final wasmBytes = await _grammarBytes(language);
|
||||
|
||||
// Load into WASM store.
|
||||
final nameNative = language.toNativeUtf8();
|
||||
final wasmNative = calloc<Uint8>(wasmBytes.length);
|
||||
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
|
||||
final error = calloc<TSWasmError>();
|
||||
|
||||
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
|
||||
|
||||
calloc.free(wasmNative);
|
||||
calloc.free(nameNative);
|
||||
|
||||
if (lang == nullptr) {
|
||||
final msg = error.ref.message;
|
||||
if (msg != nullptr) calloc.free(msg);
|
||||
calloc.free(error);
|
||||
_unavailable.add(language);
|
||||
return null;
|
||||
}
|
||||
calloc.free(error);
|
||||
|
||||
// Load highlight query.
|
||||
final querySource = await _grammarQuery(language);
|
||||
|
||||
Pointer<TSQuery> query = nullptr;
|
||||
List<String> captureNames = [];
|
||||
|
||||
if (querySource != null) {
|
||||
final queryNative = querySource.toNativeUtf8();
|
||||
final queryLen = utf8.encode(querySource).length;
|
||||
final errorOffset = calloc<Uint32>();
|
||||
final errorType = calloc<Int32>();
|
||||
|
||||
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
|
||||
|
||||
calloc.free(queryNative);
|
||||
calloc.free(errorOffset);
|
||||
calloc.free(errorType);
|
||||
|
||||
if (query != nullptr) {
|
||||
final count = lib.queryCaptureCount(query);
|
||||
final lenOut = calloc<Uint32>();
|
||||
for (var i = 0; i < count; i++) {
|
||||
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
|
||||
final len = lenOut.value;
|
||||
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
|
||||
}
|
||||
calloc.free(lenOut);
|
||||
}
|
||||
}
|
||||
|
||||
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
|
||||
_grammars[language] = grammar;
|
||||
return grammar;
|
||||
} catch (_) {
|
||||
_unavailable.add(language);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasGrammar(String path) async {
|
||||
final lang = grammarForPath(path);
|
||||
if (lang == null) return false;
|
||||
return (await _loadGrammar(lang)) != null;
|
||||
}
|
||||
|
||||
Future<String?> languageFor(String path) async {
|
||||
final lang = grammarForPath(path);
|
||||
if (lang == null) return null;
|
||||
return (await _loadGrammar(lang)) != null ? lang : null;
|
||||
}
|
||||
|
||||
List<String> get loadedLanguages => _grammars.keys.toList();
|
||||
|
||||
Future<SyntaxResult> highlight(String path, String source) async {
|
||||
final lang = grammarForPath(path);
|
||||
if (lang == null) return SyntaxResult.empty;
|
||||
|
||||
final grammar = await _loadGrammar(lang);
|
||||
if (grammar == null || grammar.query == nullptr) {
|
||||
return SyntaxResult.empty;
|
||||
}
|
||||
|
||||
final lib = _lib!;
|
||||
final parser = _parser!;
|
||||
final cursor = _cursor!;
|
||||
|
||||
// Set language on parser for this parse.
|
||||
lib.parserSetLanguage(parser, grammar.language);
|
||||
|
||||
// Parse source.
|
||||
final sourceNative = source.toNativeUtf8();
|
||||
final sourceLen = utf8.encode(source).length;
|
||||
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
|
||||
|
||||
if (tree == nullptr) {
|
||||
calloc.free(sourceNative);
|
||||
return SyntaxResult.empty;
|
||||
}
|
||||
|
||||
final root = lib.treeRootNode(tree);
|
||||
|
||||
// Run highlight query.
|
||||
lib.queryCursorExec(cursor, grammar.query, root);
|
||||
|
||||
final match = calloc<TSQueryMatch>();
|
||||
final spans = <SyntaxSpan>[];
|
||||
|
||||
while (lib.queryCursorNextMatch(cursor, match)) {
|
||||
final m = match.ref;
|
||||
for (var i = 0; i < m.captureCount; i++) {
|
||||
final cap = m.captures[i];
|
||||
final captureIndex = cap.index;
|
||||
if (captureIndex < grammar.captureNames.length) {
|
||||
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calloc.free(match);
|
||||
lib.treeDelete(tree);
|
||||
calloc.free(sourceNative);
|
||||
|
||||
return SyntaxResult(spans);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final lib = _lib;
|
||||
if (lib == null) return;
|
||||
|
||||
for (final grammar in _grammars.values) {
|
||||
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
|
||||
}
|
||||
_grammars.clear();
|
||||
|
||||
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
|
||||
// Parser and WASM store are cleaned up together — deleting the parser
|
||||
// does not delete the store, but the store owns the languages.
|
||||
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
|
||||
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
|
||||
|
||||
_parser = null;
|
||||
_store = null;
|
||||
_cursor = null;
|
||||
_unavailable.clear();
|
||||
}
|
||||
|
||||
/// Resets the service to a pre-init state. Tests use this to re-exercise
|
||||
/// `_init()` without constructing a new singleton; production code never
|
||||
/// needs it.
|
||||
@visibleForTesting
|
||||
void resetForTests() {
|
||||
dispose();
|
||||
_initDone = false;
|
||||
}
|
||||
|
||||
static Color colorForRole(String role, SurfaceTokens tokens) {
|
||||
return switch (role) {
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
||||
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
|
||||
'string' || 'string.special' => tokens.syntaxString,
|
||||
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
|
||||
'comment' => tokens.syntaxComment,
|
||||
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
|
||||
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
|
||||
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
|
||||
'property' || 'field' => tokens.syntaxMethod,
|
||||
'constant' || 'constant.builtin' => tokens.syntaxNumber,
|
||||
'tag' || 'attribute' => tokens.syntaxKeyword,
|
||||
'namespace' || 'module' => tokens.syntaxType,
|
||||
'text.title' => tokens.syntaxKeyword,
|
||||
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
|
||||
'text.emphasis' || 'text.strong' => tokens.syntaxType,
|
||||
_ => tokens.globalForeground,
|
||||
};
|
||||
}
|
||||
}
|
||||
export 'tree_sitter_service_stub.dart' if (dart.library.ffi) 'tree_sitter_service_ffi.dart';
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/// FFI-backed tree-sitter highlighter (T-438 web fence, D-100). Selected by the
|
||||
/// [tree_sitter_service.dart] facade when `dart.library.ffi` is available; the
|
||||
/// web build gets [tree_sitter_service_stub.dart] instead. Pure result types
|
||||
/// live in [syntax_result.dart] (re-exported so consumers import only the
|
||||
/// facade).
|
||||
library;
|
||||
|
||||
import 'dart:convert' show utf8;
|
||||
import 'dart:ffi';
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
export 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||
|
||||
class _LoadedGrammar {
|
||||
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
|
||||
|
||||
final Pointer<Void> language;
|
||||
final Pointer<TSQuery> query;
|
||||
final List<String> captureNames;
|
||||
}
|
||||
|
||||
class TreeSitterService {
|
||||
static final TreeSitterService shared = TreeSitterService();
|
||||
|
||||
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
|
||||
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
|
||||
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
|
||||
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
|
||||
: _injectedLib = lib,
|
||||
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
|
||||
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
|
||||
|
||||
final TreeSitterLib? _injectedLib;
|
||||
final GrammarBytesLoader _grammarBytes;
|
||||
final GrammarQueryLoader _grammarQuery;
|
||||
|
||||
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
|
||||
|
||||
static Future<Uint8List> _defaultGrammarBytes(String language) async {
|
||||
final data = await rootBundle.load('assets/grammars/$language.wasm');
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Future<String?> _defaultGrammarQuery(String language) async {
|
||||
try {
|
||||
return await rootBundle.loadString('assets/queries/$language.scm');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, _LoadedGrammar> _grammars = {};
|
||||
final Set<String> _unavailable = {};
|
||||
|
||||
Pointer<TSWasmStore>? _store;
|
||||
Pointer<TSParser>? _parser;
|
||||
Pointer<TSQueryCursor>? _cursor;
|
||||
|
||||
bool _initDone = false;
|
||||
|
||||
bool _init() {
|
||||
if (_initDone) return _parser != null;
|
||||
_initDone = true;
|
||||
|
||||
final lib = _lib;
|
||||
if (lib == null) return false;
|
||||
|
||||
final engine = lib.wasmEngineNew();
|
||||
if (engine == nullptr) return false;
|
||||
|
||||
final error = calloc<TSWasmError>();
|
||||
_store = lib.wasmStoreNew(engine, error);
|
||||
lib.wasmEngineDelete(engine);
|
||||
|
||||
if (_store == null || _store == nullptr) {
|
||||
calloc.free(error);
|
||||
return false;
|
||||
}
|
||||
calloc.free(error);
|
||||
|
||||
_parser = lib.parserNew();
|
||||
if (_parser == null || _parser == nullptr) return false;
|
||||
lib.parserSetWasmStore(_parser!, _store!);
|
||||
|
||||
_cursor = lib.queryCursorNew();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<_LoadedGrammar?> _loadGrammar(String language) async {
|
||||
if (_unavailable.contains(language)) return null;
|
||||
final cached = _grammars[language];
|
||||
if (cached != null) return cached;
|
||||
|
||||
if (!_init()) {
|
||||
_unavailable.add(language);
|
||||
return null;
|
||||
}
|
||||
|
||||
final lib = _lib!;
|
||||
|
||||
try {
|
||||
// Load grammar WASM bytes.
|
||||
final wasmBytes = await _grammarBytes(language);
|
||||
|
||||
// Load into WASM store.
|
||||
final nameNative = language.toNativeUtf8();
|
||||
final wasmNative = calloc<Uint8>(wasmBytes.length);
|
||||
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
|
||||
final error = calloc<TSWasmError>();
|
||||
|
||||
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
|
||||
|
||||
calloc.free(wasmNative);
|
||||
calloc.free(nameNative);
|
||||
|
||||
if (lang == nullptr) {
|
||||
final msg = error.ref.message;
|
||||
if (msg != nullptr) calloc.free(msg);
|
||||
calloc.free(error);
|
||||
_unavailable.add(language);
|
||||
return null;
|
||||
}
|
||||
calloc.free(error);
|
||||
|
||||
// Load highlight query.
|
||||
final querySource = await _grammarQuery(language);
|
||||
|
||||
Pointer<TSQuery> query = nullptr;
|
||||
List<String> captureNames = [];
|
||||
|
||||
if (querySource != null) {
|
||||
final queryNative = querySource.toNativeUtf8();
|
||||
final queryLen = utf8.encode(querySource).length;
|
||||
final errorOffset = calloc<Uint32>();
|
||||
final errorType = calloc<Int32>();
|
||||
|
||||
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
|
||||
|
||||
calloc.free(queryNative);
|
||||
calloc.free(errorOffset);
|
||||
calloc.free(errorType);
|
||||
|
||||
if (query != nullptr) {
|
||||
final count = lib.queryCaptureCount(query);
|
||||
final lenOut = calloc<Uint32>();
|
||||
for (var i = 0; i < count; i++) {
|
||||
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
|
||||
final len = lenOut.value;
|
||||
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
|
||||
}
|
||||
calloc.free(lenOut);
|
||||
}
|
||||
}
|
||||
|
||||
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
|
||||
_grammars[language] = grammar;
|
||||
return grammar;
|
||||
} catch (_) {
|
||||
_unavailable.add(language);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasGrammar(String path) async {
|
||||
final lang = grammarForPath(path);
|
||||
if (lang == null) return false;
|
||||
return (await _loadGrammar(lang)) != null;
|
||||
}
|
||||
|
||||
Future<String?> languageFor(String path) async {
|
||||
final lang = grammarForPath(path);
|
||||
if (lang == null) return null;
|
||||
return (await _loadGrammar(lang)) != null ? lang : null;
|
||||
}
|
||||
|
||||
List<String> get loadedLanguages => _grammars.keys.toList();
|
||||
|
||||
Future<SyntaxResult> highlight(String path, String source) async {
|
||||
final lang = grammarForPath(path);
|
||||
if (lang == null) return SyntaxResult.empty;
|
||||
|
||||
final grammar = await _loadGrammar(lang);
|
||||
if (grammar == null || grammar.query == nullptr) {
|
||||
return SyntaxResult.empty;
|
||||
}
|
||||
|
||||
final lib = _lib!;
|
||||
final parser = _parser!;
|
||||
final cursor = _cursor!;
|
||||
|
||||
// Set language on parser for this parse.
|
||||
lib.parserSetLanguage(parser, grammar.language);
|
||||
|
||||
// Parse source.
|
||||
final sourceNative = source.toNativeUtf8();
|
||||
final sourceLen = utf8.encode(source).length;
|
||||
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
|
||||
|
||||
if (tree == nullptr) {
|
||||
calloc.free(sourceNative);
|
||||
return SyntaxResult.empty;
|
||||
}
|
||||
|
||||
final root = lib.treeRootNode(tree);
|
||||
|
||||
// Run highlight query.
|
||||
lib.queryCursorExec(cursor, grammar.query, root);
|
||||
|
||||
final match = calloc<TSQueryMatch>();
|
||||
final spans = <SyntaxSpan>[];
|
||||
|
||||
while (lib.queryCursorNextMatch(cursor, match)) {
|
||||
final m = match.ref;
|
||||
for (var i = 0; i < m.captureCount; i++) {
|
||||
final cap = m.captures[i];
|
||||
final captureIndex = cap.index;
|
||||
if (captureIndex < grammar.captureNames.length) {
|
||||
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calloc.free(match);
|
||||
lib.treeDelete(tree);
|
||||
calloc.free(sourceNative);
|
||||
|
||||
return SyntaxResult(spans);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final lib = _lib;
|
||||
if (lib == null) return;
|
||||
|
||||
for (final grammar in _grammars.values) {
|
||||
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
|
||||
}
|
||||
_grammars.clear();
|
||||
|
||||
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
|
||||
// Parser and WASM store are cleaned up together — deleting the parser
|
||||
// does not delete the store, but the store owns the languages.
|
||||
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
|
||||
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
|
||||
|
||||
_parser = null;
|
||||
_store = null;
|
||||
_cursor = null;
|
||||
_unavailable.clear();
|
||||
}
|
||||
|
||||
/// Resets the service to a pre-init state. Tests use this to re-exercise
|
||||
/// `_init()` without constructing a new singleton; production code never
|
||||
/// needs it.
|
||||
@visibleForTesting
|
||||
void resetForTests() {
|
||||
dispose();
|
||||
_initDone = false;
|
||||
}
|
||||
|
||||
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Web stub for [TreeSitterService] (T-438 web fence, D-100): no tree-sitter
|
||||
/// FFI on web, so highlighting is a no-op — every query returns no spans and
|
||||
/// the editor / code block render plain text. Mirrors the FFI impl's public
|
||||
/// API (and re-exports the shared result types) so the facade is transparent.
|
||||
library;
|
||||
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
|
||||
export 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||
|
||||
class TreeSitterService {
|
||||
static final TreeSitterService shared = TreeSitterService();
|
||||
|
||||
TreeSitterService();
|
||||
|
||||
Future<bool> hasGrammar(String path) async => false;
|
||||
Future<String?> languageFor(String path) async => null;
|
||||
List<String> get loadedLanguages => const [];
|
||||
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult.empty;
|
||||
void dispose() {}
|
||||
void resetForTests() {}
|
||||
|
||||
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
|
||||
}
|
||||
@@ -25,7 +25,9 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'watchdog_windows.dart';
|
||||
// Web fence (T-438, D-100): the FFI-backed Windows sampler is reachable only
|
||||
// when `dart.library.ffi` is available; the web build gets an all-`-1` stub.
|
||||
import 'watchdog_windows_stub.dart' if (dart.library.ffi) 'watchdog_windows.dart';
|
||||
|
||||
/// One resource sample of the current process. A field of `-1` means "not
|
||||
/// available on this platform or the probe failed" — never an error.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/// Web/non-FFI stub for the Windows resource sampler (T-438 web fence, D-100).
|
||||
///
|
||||
/// [watchdog.dart] selects this when `dart.library.ffi` is absent, keeping the
|
||||
/// `kernel32`/`psapi` FFI bindings out of the wasm graph. The watchdog isolate
|
||||
/// never spawns on web, and `forPlatform()` never returns the Windows sampler
|
||||
/// there — this exists only to satisfy the import. Returns an all-unavailable
|
||||
/// sample (every field `-1`) if ever called.
|
||||
library;
|
||||
|
||||
import 'watchdog.dart';
|
||||
|
||||
class WindowsResourceSampler implements ResourceSampler {
|
||||
@override
|
||||
ResourceSample sample() => const ResourceSample();
|
||||
}
|
||||
+4
-2
@@ -59,7 +59,9 @@ import 'package:clide/src/ipc/server.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
// Web fence (T-438, D-100): tree-sitter init is FFI-backed on desktop, a no-op
|
||||
// on web (highlighting degrades to plain text there).
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_boot_stub.dart' if (dart.library.ffi) 'package:clide/kernel/src/syntax/tree_sitter_boot_io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -81,7 +83,7 @@ Future<void> main() async {
|
||||
|
||||
binding.ensureSemantics();
|
||||
|
||||
TreeSitterLib.init();
|
||||
initTreeSitter();
|
||||
|
||||
final appDir = await _resolveAppDir();
|
||||
final themes = await _loadBundledThemes();
|
||||
|
||||
@@ -101,7 +101,11 @@ String logDirectory([Map<String, String>? env]) {
|
||||
String fnv1a64Hex(String s) {
|
||||
// Desktop-only (the IPC server is desktop-only per D-56). Dart VM
|
||||
// ints are 64-bit; arithmetic wraps modulo 2^64 naturally.
|
||||
var h = 0xcbf29ce484222325;
|
||||
// FNV-1a 64-bit offset basis. Split into two 32-bit halves so the dart2js
|
||||
// web fallback accepts it — a 0xcbf2…2325 literal "can't be represented
|
||||
// exactly in JavaScript" (T-438). Correct on the VM/wasm (int64); the web
|
||||
// build never hashes a socket path (the IPC server is desktop-only).
|
||||
var h = (0xcbf29ce4 << 32) | 0x84222325;
|
||||
const prime = 0x100000001b3;
|
||||
final bytes = utf8.encode(s);
|
||||
for (final b in bytes) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Desktop PTY backend selection (T-438 web fence, D-100).
|
||||
///
|
||||
/// [pty_session.dart] imports this only when `dart.library.ffi` is available;
|
||||
/// the web build gets [pty_backend_web.dart] instead, so the FFI-backed
|
||||
/// [NativePty]/[WindowsPty] never enter the wasm compile graph.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'native_pty.dart';
|
||||
import 'pty_log.dart';
|
||||
import 'pty_session.dart';
|
||||
import 'windows_pty.dart';
|
||||
|
||||
/// Spawn a PTY child using the platform backend — ConPTY on Windows
|
||||
/// ([WindowsPty]), `posix_openpt` + `posix_spawn` elsewhere ([NativePty]).
|
||||
PtySession startPtyBackend({
|
||||
required String executable,
|
||||
List<String> arguments = const [],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
PtyLog log = PtyLog.none,
|
||||
}) {
|
||||
if (Platform.isWindows) {
|
||||
return WindowsPty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
log: log,
|
||||
);
|
||||
}
|
||||
return NativePty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
log: log,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Web stub for the PTY backend (T-438 web fence, D-100).
|
||||
///
|
||||
/// The web/WASM target has no PTY — spawning a child under a pseudo-terminal is
|
||||
/// desktop-only (it needs `dart:ffi`). [pty_session.dart] selects this when
|
||||
/// `dart.library.ffi` is absent, keeping [NativePty]/[WindowsPty] out of the
|
||||
/// wasm graph. The web build is a UI/e2e surface, not a functional desktop
|
||||
/// replacement, so a terminal is never spawned there; calling this is a bug.
|
||||
library;
|
||||
|
||||
import 'pty_log.dart';
|
||||
import 'pty_session.dart';
|
||||
|
||||
PtySession startPtyBackend({
|
||||
required String executable,
|
||||
List<String> arguments = const [],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
PtyLog log = PtyLog.none,
|
||||
}) => throw UnsupportedError('PTY sessions are not available on the web target.');
|
||||
@@ -8,12 +8,13 @@
|
||||
/// in → EOF on child exit → close() reaps.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'native_pty.dart';
|
||||
// Web fence (T-438, D-100): the FFI-backed backends are reachable only when
|
||||
// `dart.library.ffi` is available; the web build gets a throwing stub, so
|
||||
// `dart:ffi` never enters the wasm compile graph.
|
||||
import 'pty_backend_web.dart' if (dart.library.ffi) 'pty_backend_io.dart';
|
||||
import 'pty_log.dart';
|
||||
import 'windows_pty.dart';
|
||||
|
||||
abstract interface class PtySession {
|
||||
/// OS process id of the spawned child.
|
||||
@@ -51,25 +52,12 @@ PtySession startPtySession({
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
PtyLog log = PtyLog.none,
|
||||
}) {
|
||||
if (Platform.isWindows) {
|
||||
return WindowsPty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
log: log,
|
||||
);
|
||||
}
|
||||
return NativePty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
log: log,
|
||||
);
|
||||
}
|
||||
}) => startPtyBackend(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
log: log,
|
||||
);
|
||||
|
||||
+7
-17
@@ -25,10 +25,11 @@ import 'builtin/files/files.dart';
|
||||
import 'builtin/git/git.dart';
|
||||
import 'builtin/terminal/terminal.dart';
|
||||
import 'extension/extension.dart' show ClideExtension;
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
import 'kernel/kernel.dart';
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
|
||||
// Web fence (T-438, D-100): the FFI fd-inheritance probe is desktop-only; the
|
||||
// web build gets a no-op stub so dart:ffi / package:ffi / libc stay out.
|
||||
import 'fd_check_stub.dart' if (dart.library.ffi) 'fd_check_io.dart';
|
||||
import 'src/daemon/pane_commands.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
import 'src/ipc/paths.dart' show logDirectory;
|
||||
@@ -375,20 +376,9 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)';
|
||||
});
|
||||
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS?
|
||||
await _testAsync('fd inheritance check', () async {
|
||||
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
||||
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
|
||||
final parent = sv[0];
|
||||
final child = sv[1];
|
||||
pkg_ffi.calloc.free(sv);
|
||||
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||
final exit = await proc.exitCode;
|
||||
libc.close(parent);
|
||||
libc.close(child);
|
||||
return 'exit=$exit stderr=${stderr.trim()}';
|
||||
});
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS? (T-438: the
|
||||
// FFI body lives in fd_check_io.dart so the web build can stub it out.)
|
||||
await _testAsync('fd inheritance check', fdInheritanceCheck);
|
||||
|
||||
_say('');
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import 'dart:ffi';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
// The FFI impl directly (not the facade): these tests inject a fake
|
||||
// TreeSitterLib via the FFI-only constructor params (T-438).
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service_ffi.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
// The FFI impl directly (not the facade): the smoke test injects a fake
|
||||
// TreeSitterLib via the FFI-only constructor params (T-438).
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service_ffi.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _libPath = 'native/linux-x64/libtree-sitter.so';
|
||||
|
||||
Reference in New Issue
Block a user