fix: record a known exit status when the reader's wait() raises

The early-EOF reaper made _finish_reader return without publishing when
wait() raises, so the session stays tracked for later reconciliation.
That is right for the pipe path (_reconcile_local_exit can still reap via
session.process), but PTY sessions have no session.process: when
ptyprocess.wait raises (waitpid ECHILD after isalive() already reaped the
child) the exitstatus is known, yet poll() reported "running" forever.

Only leave the session tracked when exit_code() is still None; otherwise
record the known status and finish as before.

Review finding: PTY session whose pty.wait raises stays in _running forever (fail-open regression vs main)
This commit is contained in:
teknium1
2026-09-14 21:16:05 -07:00
committed by Teknium
parent dc4a2aeb10
commit 0ea0c53e89
2 changed files with 26 additions and 2 deletions

View File

@@ -361,6 +361,26 @@ def test_failed_reader_wait_does_not_publish_false_completion(registry, monkeypa
assert moved == []
def test_failed_reader_wait_still_records_known_exit_status(registry, monkeypatch):
"""A PTY child reaped by isalive() has its status; a raising wait must not lose it."""
session = _make_session(sid="proc_pty_wait_failed")
moved = []
monkeypatch.setattr(registry, "_move_to_finished", lambda _s: moved.append(_s.id))
registry._finish_reader(
session,
MagicMock(decode=MagicMock(return_value="")),
lambda _text: None,
"PTY",
MagicMock(side_effect=OSError("waitpid ECHILD")),
lambda: 9,
)
assert session.exited is True
assert session.exit_code == 9
assert moved == [session.id]
# =========================================================================
# Incremental UTF-8 decoding across chunk boundaries
# (ported from openclaw/openclaw#112325)

View File

@@ -1178,8 +1178,12 @@ class ProcessRegistry(ProcessCheckpointMixin):
try:
wait()
except Exception as e:
logger.warning("%s wait failed; leaving process tracked: %s", label, e)
return
# A PTY child reaped by isalive() already has its exitstatus; only an
# unknown status must stay tracked for later reconciliation.
if exit_code() is None:
logger.warning("%s wait failed; leaving process tracked: %s", label, e)
return
logger.warning("%s wait failed; recording known exit status: %s", label, e)
self._finish_exited(session, exit_code())
@staticmethod