async recvFd + backend terminal test (sandbox blocks ptyc exec)
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8f6ab1ac95
commit
45e8132a41
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"exported_at": "2026-04-26T13:26:38Z",
|
||||
"exported_at": "2026-04-26T13:55:29Z",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "D-1",
|
||||
|
||||
@@ -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<String, Completer<IpcResponse>> _pending = {};
|
||||
int _nextId = 0;
|
||||
|
||||
|
||||
@@ -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<int> _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() {
|
||||
|
||||
+54
-8
@@ -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<ClideTestApp> {
|
||||
Future<void> _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 = <String>[];
|
||||
final sub = backend.client.events.on<DaemonEvent>().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<ClideTestApp> {
|
||||
if (fileCreated) File(marker).deleteSync();
|
||||
return fileCreated ? 'file created + cleaned up' : 'file not created';
|
||||
});
|
||||
} // end !Platform.isMacOS
|
||||
|
||||
print('[testmode]');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user