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:
2026-06-15 17:31:24 +02:00
co-authored by Claude Opus 4.8
parent 967db2f8d9
commit ca08c2a17d
28 changed files with 796 additions and 380 deletions
+5 -22
View File
@@ -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);
+28
View File
@@ -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';
+8 -2
View File
@@ -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);
}
+29
View File
@@ -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()}';
}
+4
View File
@@ -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)';
+54
View File
@@ -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;
+5 -301
View File
@@ -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);
}
+3 -1
View File
@@ -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.
+15
View File
@@ -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
View File
@@ -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();
+5 -1
View File
@@ -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) {
+46
View File
@@ -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,
);
}
+21
View File
@@ -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.');
+13 -25
View File
@@ -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
View File
@@ -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('');
}