From 45e8132a41a8b2e9db998310edcf4ff81c54a98c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 26 Apr 2026 15:55:29 +0200 Subject: [PATCH] async recvFd + backend terminal test (sandbox blocks ptyc exec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PtySession.spawn() now runs recvFd in a child isolate via Isolate.spawn so the blocking FFI call doesn't stall the backend isolate's event loop. Terminal testmode test spawns a real backend isolate, opens a project, and sends pane.spawn via IPC — the exact same path the full app uses. Currently fails: ptyc successfully starts but its fork+execvp is blocked by the macOS sandbox ("Operation not permitted"). The SBPL allows /bin/zsh exec from the app process, but ptyc's child process may not inherit the exec permission, or the PTYC_SOCK_FD is not inherited by the ptyc child (Dart Process.start fd inheritance on macOS). IsolateClient.events getter exposed for test access. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pql/pql-plan.json | 2 +- lib/kernel/src/ipc/isolate_client.dart | 3 ++ lib/src/pty/session.dart | 31 ++++++++++++- lib/test_app.dart | 62 ++++++++++++++++++++++---- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 1440521f..5c270c70 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-04-26T13:26:38Z", + "exported_at": "2026-04-26T13:55:29Z", "decisions": [ { "id": "D-1", diff --git a/lib/kernel/src/ipc/isolate_client.dart b/lib/kernel/src/ipc/isolate_client.dart index 90aca14f..582d8af7 100644 --- a/lib/kernel/src/ipc/isolate_client.dart +++ b/lib/kernel/src/ipc/isolate_client.dart @@ -26,6 +26,9 @@ class IsolateClient extends DaemonClient { final SendPort _backendPort; final DaemonBus _events; + + /// The event bus that receives events from the backend. + DaemonBus get events => _events; final Map> _pending = {}; int _nextId = 0; diff --git a/lib/src/pty/session.dart b/lib/src/pty/session.dart index 89f7905a..457a7f4b 100644 --- a/lib/src/pty/session.dart +++ b/lib/src/pty/session.dart @@ -29,6 +29,12 @@ import 'errors.dart'; import 'ffi/libc.dart' as libc; import 'ffi/scm_rights.dart' as scm; +class _RecvFdArgs { + const _RecvFdArgs(this.socketFd, this.sendPort); + final int socketFd; + final SendPort sendPort; +} + /// A running PTY child plus its master-fd plumbing. class PtySession { PtySession._({ @@ -124,7 +130,9 @@ class PtySession { await proc.stdin.close(); // Receive the master fd over the parent side of the socketpair. - final masterFd = scm.recvFd(parentSock); + // recvFd blocks until ptyc sends — run in a child isolate so the + // calling isolate's event loop stays responsive. + final masterFd = await _recvFdAsync(parentSock); // Apply initial winsize (ptyc already did this, but doing it // again from Dart confirms the wire + gives a place to call it @@ -229,6 +237,27 @@ class PtySession { if (!_outputCtrl.isClosed) await _outputCtrl.close(); } + /// Run recvFd in a child isolate so the blocking FFI call doesn't + /// stall the calling isolate's event loop. + static Future _recvFdAsync(int socketFd) async { + final port = ReceivePort(); + final iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort)); + final result = await port.first; + iso.kill(priority: Isolate.immediate); + port.close(); + if (result is int) return result; + throw PtyException('recvFd', '$result'); + } + + static void _recvFdEntry(_RecvFdArgs args) { + try { + final fd = scm.recvFd(args.socketFd); + args.sendPort.send(fd); + } catch (e) { + args.sendPort.send('error: $e'); + } + } + // ---------------------------------------------------------------- // void _startReader() { diff --git a/lib/test_app.dart b/lib/test_app.dart index bd899471..875ab77b 100644 --- a/lib/test_app.dart +++ b/lib/test_app.dart @@ -26,6 +26,11 @@ import 'builtin/git/git.dart'; import 'builtin/terminal/terminal.dart'; import 'extension/extension.dart' show ClideExtension; import 'kernel/kernel.dart'; +import 'kernel/src/backend.dart'; +import 'kernel/src/events/bus.dart'; +import 'kernel/src/events/types.dart'; +import 'kernel/src/ipc/isolate_client.dart'; +import 'kernel/src/log.dart'; import 'src/pty/session.dart'; import 'kernel/src/toolchain.dart'; import 'src/daemon/dispatcher.dart'; @@ -319,14 +324,54 @@ class _ClideTestAppState extends State { Future _runTerminalTests(Toolchain tc, String workDir) async { print('[testmode] --- terminal ---'); - if (Platform.isMacOS) { - // PtySession.spawn() does synchronous FFI calls (socketpair, recvFd) - // that block the merged UI/platform thread, preventing Dart timers - // from firing. PTY must move to the backend isolate on macOS. - _addResult('pty spawn (macOS)', false, 'SKIPPED — FFI blocks merged thread, needs backend isolate'); - print('[testmode]'); - return; - } + // On macOS, PtySession FFI blocks the merged thread. Test via + // backend isolate IPC instead (same path the real app uses). + await _testAsync('pane.spawn via backend', () async { + print('[testmode] spawning backend...'); + final backend = await Backend.spawn( + hintRoot: workDir, + clientFactory: (port) => IsolateClient( + log: Logger(), + events: DaemonBus(), + backendPort: port, + ), + ); + print('[testmode] backend ready, opening project...'); + await backend.openProject(workDir); + print('[testmode] project open, spawning pane...'); + + // Spawn a pane running /bin/echo. + // Use the shell (allowed by SBPL), not /bin/echo (not allowed). + final spawnResp = await backend.client.request('pane.spawn', args: { + 'argv': [tc.shell, '-c', 'echo CLIDE_BACKEND_PTY_OK'], + 'kind': 'terminal', + }); + print('[testmode] spawn response: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}'); + if (!spawnResp.ok) { + backend.dispose(); + return 'spawn failed: ${spawnResp.error?.message}'; + } + final paneId = spawnResp.data['id'] as String; + + // Collect output events for up to 3 seconds. + final outputParts = []; + final sub = backend.client.events.on().listen((e) { + if (e.subsystem == 'pane' && e.kind == 'pane.output' && e.data['id'] == paneId) { + final b64 = e.data['bytes_b64'] as String?; + if (b64 != null) outputParts.add(utf8.decode(base64Decode(b64), allowMalformed: true)); + } + }); + await Future.delayed(const Duration(seconds: 3)); + await sub.cancel(); + backend.dispose(); + + final output = outputParts.join(); + final ok = output.contains('CLIDE_BACKEND_PTY_OK'); + return ok ? 'output contains marker' : 'marker not found in ${output.length} chars: ${output.substring(0, output.length.clamp(0, 100))}'; + }); + + if (!Platform.isMacOS) { + // Direct PtySession tests (only on Linux where threads are separate). // Test 1: spawn /bin/echo via PtySession, read output await _testAsync('pty spawn echo', () async { @@ -380,6 +425,7 @@ class _ClideTestAppState extends State { if (fileCreated) File(marker).deleteSync(); return fileCreated ? 'file created + cleaned up' : 'file not created'; }); + } // end !Platform.isMacOS print('[testmode]'); }