PTY: fix resource leaks and reader-isolate races (T-76)

NativePty.close() now awaits the reader-isolate spawn, kills the
child first to drive EOF on the master fd, awaits the isolate's
EOF acknowledgement, and only then closes the fd. Previously the
fd-close racing with the polling isolate left a window where the
fd number could be reused and the isolate would briefly target the
wrong file.

Both NativePty and PtySession now surface reader-isolate spawn
errors via the output stream's addError instead of silently
swallowing them.

PtySession.spawn closes the master fd on any post-receive failure,
closes parentSock in finally (was leaking on every spawn), and
kills the ptyc process if recvFd fails.

PtySession._recvFdAsync uses try/finally to close the ReceivePort
and kill the spawn isolate even when Isolate.spawn itself throws.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-05 15:10:43 +02:00
co-authored by Claude
parent 0d333e8470
commit 1787147e82
4 changed files with 244 additions and 139 deletions
+56 -3
View File
@@ -90,6 +90,13 @@ class NativePty {
final _out = StreamController<Uint8List>.broadcast();
bool _dead = false;
/// Tracks the reader isolate's spawn — close() awaits this before
/// tearing down so we never race a still-spawning isolate.
Future<void>? _readerReady;
Isolate? _readerIsolate;
ReceivePort? _readerPort;
Completer<void>? _readerExited;
NativePty._(this._fd, this.pid);
/// Byte stream of data produced by the child.
@@ -222,13 +229,32 @@ class NativePty {
// -- I/O ------------------------------------------------------------------
void _spawnReader() async {
void _spawnReader() {
_readerReady = _spawnReaderAsync();
}
Future<void> _spawnReaderAsync() async {
final rp = ReceivePort();
await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
_readerPort = rp;
_readerExited = Completer<void>();
try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
} catch (e) {
// Surface the spawn failure instead of leaving the PTY in a
// half-alive state where output never flows but isClosed=false.
_dead = true;
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
return;
}
rp.listen((msg) {
if (msg == null) {
if (!_out.isClosed) _out.close();
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
_reap();
} else {
if (!_out.isClosed) _out.add(msg as Uint8List);
@@ -330,12 +356,39 @@ class NativePty {
}
/// Kill the child and release resources.
///
/// Order matters: kill the child first so its slave PTY closes,
/// causing the master fd to return EOF. The reader isolate sees
/// EOF and exits cleanly. Only then do we close the master fd —
/// closing it before the isolate exits creates a window where the
/// fd number could be reused and the isolate would briefly poll
/// the wrong file.
Future<void> close() async {
if (_dead) return;
_dead = true;
_nativeClose(_fd);
// Make sure the reader is fully spawned before we tear it down —
// otherwise close() racing with start() leaves an orphan isolate.
await _readerReady;
_nativeKill(pid, _kSighup);
_nativeKill(pid, 9);
// Wait for the isolate to send `null` (EOF) — confirms it has
// exited its poll loop and won't touch the fd again.
if (_readerExited != null) {
await _readerExited!.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {},
);
}
_nativeClose(_fd);
_readerIsolate?.kill(priority: Isolate.immediate);
_readerIsolate = null;
_readerPort?.close();
_readerPort = null;
final s = calloc<ffi.Int32>();
_waitpid(pid, s, 0);
calloc.free(s);