diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 9ead6f0445..da3a12f8da 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -795,6 +795,55 @@ class TestFinishedHandleRelease: assert result["status"] == "exited" assert "hello-finish" in result["output_preview"] + def test_prune_releases_handles_of_dropped_sessions(self, registry): + """TTL-prune must release handles of sessions that landed in + _finished without passing through _move_to_finished (direct inserts). + The release is idempotent, so double-close on the normal path is safe. + """ + import time as _time + + pty_closed = {"closed": False} + + class _FakePty: + def close(self): + pty_closed["closed"] = True + + session = _make_session(sid="proc_prune_release", exited=True) + session._pty = _FakePty() + # Force TTL expiry. + session.started_at = _time.time() - (FINISHED_TTL_SECONDS + 60) + registry._finished[session.id] = session + + with registry._lock: + registry._prune_if_needed() + + assert session.id not in registry._finished + assert pty_closed["closed"], "pruned session must release its PTY handle" + + def test_release_does_not_touch_running_sessions(self, registry): + """A still-running session's handles must remain open: _move_to_finished + is only ever invoked with exited sessions, and prune only walks + _finished — a live session in _running keeps its pipe.""" + proc = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(5)"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + ) + try: + session = _make_session(sid="proc_still_running", exited=False) + session.process = proc + registry._running[session.id] = session + + with registry._lock: + registry._prune_if_needed() + + assert session.id in registry._running + assert proc.stdout is not None and not proc.stdout.closed + finally: + proc.kill() + proc.wait(timeout=5) + # ========================================================================= # Spawn env sanitization diff --git a/tools/process_registry.py b/tools/process_registry.py index 8a86f0a01e..36c0e6cb7e 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -2136,6 +2136,13 @@ class ProcessRegistry(ProcessCheckpointMixin): if over_cap and (survivors := [sid for sid in self._finished if sid not in expired]): expired.append(min(survivors, key=lambda sid: self._finished[sid].started_at)) for sid in expired: + # Belt-and-suspenders handle release: sessions normally arrive in + # _finished via _move_to_finished(), which already released their + # Popen/PTY handles — but any session inserted into _finished + # directly (defensive paths, historical checkpoints) would + # otherwise carry its OS handles to the grave unreleased. The + # release is idempotent, so double-closing is safe. + self._release_finished_handles(self._finished[sid]) del self._finished[sid] # Belt-and-suspenders against module-lifetime growth: forget consumed / # poll-observed marks for any session no longer tracked at all.