port clide runtime to Windows (ConPTY, AF_UNIX, PATHEXT)
Bring the runtime up on Windows without disturbing the POSIX paths. PTY: introduce a platform-neutral PtySession contract with a factory that picks NativePty (posix_openpt/posix_spawn) or the new WindowsPty (ConPTY via CreatePseudoConsole). The pane registry programs against the interface; NativePty now implements it. IPC: the per-workspace AF_UNIX socket lives under %LOCALAPPDATA% and is hashed from a canonical workspace key (backslash + ASCII-folded case) so the Dart server and the C client agree despite NTFS case- insensitivity. The C client grows a Win32 shim (winsock afunix); chmod is a no-op on Windows where the per-user ACL is the gate. Toolchain: PATH probing splits on ';' and tries PATHEXT extensions; the shell defaults to PowerShell (pwsh, then powershell); tmux is treated as optional since it has no Windows build; dugite falls back to PATH git for now. Build: add `make build-windows`, a clide-cli MSVC build wrapped by ci/build_cli_windows.sh, and a ConPTY smoke-test suite that self- skips off-platform. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/// WindowsPty (ConPTY) smoke tests — the Windows sibling of
|
||||
/// `session_test.dart`. Windows only; skipped elsewhere.
|
||||
///
|
||||
/// Same `tags: ['pty']` discipline as the POSIX suite: tests that
|
||||
/// depend on the reader isolate delivering ConPTY output run serially
|
||||
/// via `dart test` per `ci/test.sh`; only the synchronous-throw test
|
||||
/// stays untagged.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/pty/errors.dart';
|
||||
import 'package:clide/src/pty/windows_pty.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../helpers/timeouts.dart';
|
||||
|
||||
void main() {
|
||||
if (!Platform.isWindows) return;
|
||||
|
||||
group('WindowsPty', () {
|
||||
test('spawns cmd /c echo and reads output', tags: ['pty'], () async {
|
||||
final s = WindowsPty.start(
|
||||
executable: 'cmd.exe',
|
||||
arguments: ['/c', 'echo hello-pty'],
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
environment: {...Platform.environment, 'TERM': 'xterm-256color'},
|
||||
);
|
||||
addTearDown(s.close);
|
||||
|
||||
final got = await _readUntil(s, 'hello-pty', ioTimeout);
|
||||
expect(got, contains('hello-pty'));
|
||||
});
|
||||
|
||||
test('write sends keystrokes to child', tags: ['pty'], () async {
|
||||
final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment, 'TERM': 'xterm-256color'});
|
||||
addTearDown(s.close);
|
||||
|
||||
final buf = StringBuffer();
|
||||
final firstByte = Completer<void>();
|
||||
final sub = s.output.listen((bytes) {
|
||||
buf.write(utf8.decode(bytes, allowMalformed: true));
|
||||
if (!firstByte.isCompleted) firstByte.complete();
|
||||
});
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await firstByte.future.timeout(ioTimeout, onTimeout: () => fail('shell never produced its first byte within ${ioTimeout.inSeconds}s'));
|
||||
|
||||
s.write(utf8.encode('echo write-test-ok\r\n'));
|
||||
|
||||
final result = await _waitForBuffer(buf, 'write-test-ok', ioTimeout);
|
||||
expect(result, contains('write-test-ok'));
|
||||
});
|
||||
|
||||
test('child exit closes the output stream without close()', tags: ['pty'], () async {
|
||||
// The waiter isolate must ClosePseudoConsole on child exit, or the
|
||||
// reader blocks forever and pane.exit never fires.
|
||||
final s = WindowsPty.start(executable: 'cmd.exe', arguments: ['/c', 'echo bye'], columns: 80, rows: 24, environment: {...Platform.environment});
|
||||
addTearDown(s.close);
|
||||
|
||||
final done = Completer<void>();
|
||||
s.output.listen((_) {}, onDone: () => done.complete());
|
||||
await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s of child exit'));
|
||||
expect(s.isClosed, isTrue);
|
||||
});
|
||||
|
||||
test('close kills child and closes output', tags: ['pty'], () async {
|
||||
final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment});
|
||||
|
||||
final done = Completer<void>();
|
||||
s.output.listen((_) {}, onDone: () => done.complete());
|
||||
|
||||
await s.close();
|
||||
await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s after s.close()'));
|
||||
expect(s.isClosed, isTrue);
|
||||
});
|
||||
|
||||
test('bare command name resolves via PATH + PATHEXT', tags: ['pty'], () async {
|
||||
// 'cmd' is bare and extension-less; resolution must find cmd.exe.
|
||||
final s = WindowsPty.start(
|
||||
executable: 'cmd',
|
||||
arguments: ['/c', 'echo path-resolution-ok'],
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
environment: {...Platform.environment},
|
||||
);
|
||||
addTearDown(s.close);
|
||||
|
||||
final got = await _readUntil(s, 'path-resolution-ok', ioTimeout);
|
||||
expect(got, contains('path-resolution-ok'));
|
||||
});
|
||||
|
||||
test('resize survives a live session', tags: ['pty'], () async {
|
||||
final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment});
|
||||
addTearDown(s.close);
|
||||
s.resize(cols: 120, rows: 40);
|
||||
expect(s.isClosed, isFalse);
|
||||
});
|
||||
|
||||
test('non-existent executable surfaces a PtyException at spawn time', () {
|
||||
// CreateProcessW fails with ERROR_FILE_NOT_FOUND (2) — same code
|
||||
// POSIX ENOENT happens to use, but asserted independently here.
|
||||
expect(
|
||||
() => WindowsPty.start(
|
||||
executable: 'C:\\clide-no-such-binary-${DateTime.now().microsecondsSinceEpoch}.exe',
|
||||
arguments: const [],
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
environment: {...Platform.environment},
|
||||
),
|
||||
throwsA(isA<PtyException>().having((e) => e.errno, 'errno', 2)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Collect output until [needle] appears or [limit] elapses.
|
||||
Future<String> _readUntil(WindowsPty s, String needle, Duration limit) async {
|
||||
final buf = StringBuffer();
|
||||
final found = Completer<String>();
|
||||
final sub = s.output.listen(
|
||||
(bytes) {
|
||||
buf.write(utf8.decode(bytes, allowMalformed: true));
|
||||
if (!found.isCompleted && buf.toString().contains(needle)) {
|
||||
found.complete(buf.toString());
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!found.isCompleted) found.complete(buf.toString());
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await found.future.timeout(limit, onTimeout: () => buf.toString());
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll [buf] until it contains [needle] or [limit] elapses.
|
||||
Future<String> _waitForBuffer(StringBuffer buf, String needle, Duration limit) async {
|
||||
final deadline = DateTime.now().add(limit);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (buf.toString().contains(needle)) return buf.toString();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user