close the PTY master fd when the child exits naturally (T-360)

_reap() flipped _dead without releasing the master fd, and close()
short-circuits on _dead — so every naturally-exited child leaked its
fd and pty device for the life of the app. The reader isolate sends
EOF only after leaving its poll loop, so releasing the fd inside
_reap() cannot race the reader. Regression test counts /dev/ptmx
entries in /proc/self/fd across a natural exit; verified to fail
against the unfixed code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:18:47 +02:00
co-authored by Claude Fable 5
parent 647c22d32a
commit 466383671d
5 changed files with 53 additions and 0 deletions
+36
View File
@@ -154,6 +154,42 @@ void main() {
expect(s.pid, greaterThan(0));
});
test('master fd is released after natural child exit', tags: ['pty'], () async {
// Linux-only: counts open fds resolving to /dev/ptmx via /proc.
// A naturally-exited child must not leave the master fd open —
// _reap() owns the release because close() short-circuits on
// _dead (T-360).
if (!Platform.isLinux) return;
int ptmxCount() => Directory('/proc/self/fd').listSync().where((e) {
try {
return Link(e.path).targetSync() == '/dev/ptmx';
} on FileSystemException {
return false; // fd vanished between list and readlink
}
}).length;
final baseline = ptmxCount();
final s = NativePty.start(
executable: '/bin/sh',
arguments: ['-c', 'exit 0'],
columns: 80,
rows: 24,
workingDirectory: '/',
environment: {...Platform.environment, 'TERM': 'xterm-256color'},
);
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 after child exit'));
// EOF closes the output stream from the same listener callback
// that runs _reap(), so the fd is already released here.
expect(s.isClosed, isTrue);
expect(ptmxCount(), baseline);
});
test('resize on a live PTY does not throw', () async {
final s = NativePty.start(
executable: '/bin/sh',