fix(process_registry): release handles on prune paths too (salvage follow-up)

Widen #75162: _prune_if_needed() drops finished sessions (TTL expiry and
oldest-finished eviction at MAX_PROCESSES) — release their Popen/PTY
handles there too, covering sessions inserted into _finished without
passing through _move_to_finished(). The release helper is idempotent,
so double-close on the normal path is a no-op. Adds two tests: prune
releases handles of dropped sessions, and a still-running session's
pipe stays open.
This commit is contained in:
teknium
2026-08-30 16:18:24 -07:00
committed by Teknium
parent 333733163e
commit b3a8734d43
2 changed files with 56 additions and 0 deletions
+49
View File
@@ -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
+7
View File
@@ -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.