Skip to content

Commit 228ea1d

Browse files
committed
timeout validation and exception handling
1 parent 8f8179a commit 228ea1d

2 files changed

Lines changed: 173 additions & 12 deletions

File tree

‎rlm/environments/ipython_repl.py‎

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,18 @@ class IPythonREPL(NonIsolatedEnv):
490490
``ipykernel`` and is fully isolated from the parent's
491491
namespace, cwd, and signals.
492492
493+
Subcall attribution caveat (subprocess mode):
494+
Subcall completions and FINAL_VAR answers are tagged with the
495+
``cell_id`` that was active *at the moment the kernel issued the
496+
request*. If a cell spawns long-lived kernel-side state (a
497+
``threading.Thread``, an ``asyncio.Task`` left running, a
498+
background timer) that calls ``rlm_query`` after the spawning
499+
cell has finished, those calls will be tagged with whatever cell
500+
is active *at call time*, not the cell that spawned the worker.
501+
Practically: avoid leaving background work running across cells,
502+
or accept that its rlm_calls will be reported under a later
503+
cell's ``REPLResult``.
504+
493505
Args:
494506
lm_handler_address: (host, port) of the LM handler socket server.
495507
context_payload: Initial context to load as ``context``/``context_0``.
@@ -540,6 +552,17 @@ def __init__(
540552
raise ValueError(
541553
f"kernel_mode must be 'in_process' or 'subprocess', got {kernel_mode!r}"
542554
)
555+
if startup_timeout <= 0:
556+
raise ValueError(f"startup_timeout must be positive, got {startup_timeout!r}")
557+
if subcall_timeout is not None and subcall_timeout <= 0:
558+
# ``socket.settimeout(0)`` is non-blocking mode (every send/recv
559+
# raises immediately) — almost certainly not what a caller
560+
# asking for a "0s timeout" actually wants. ``None`` means
561+
# "no timeout"; a positive number means "this many seconds".
562+
raise ValueError(
563+
f"subcall_timeout must be positive or None (got {subcall_timeout!r}); "
564+
"use None to disable the timeout."
565+
)
543566

544567
super().__init__(
545568
persistent=persistent,
@@ -582,6 +605,19 @@ def __init__(
582605
# (cross-thread broker reentry) or corrupt this cell's tracking
583606
# state (same-thread reentry).
584607
self._subcall_threads: set[int] = set()
608+
# Tracks threads currently inside ``execute_code``. In ``in_process``
609+
# mode the LM can reach this REPL via any scaffold bound method's
610+
# ``__self__`` (e.g. ``rlm_query.__self__.execute_code(...)``); a
611+
# cell that does so would re-enter on the same thread under the
612+
# RLock and silently clobber its own ``_pending_llm_calls`` /
613+
# ``_last_final_answer`` tracking. Catching same-thread reentry
614+
# here turns that into a clear ``RuntimeError``. Subprocess mode
615+
# is already shielded by process isolation: the kernel-side
616+
# scaffold is plain functions with no ``__self__``.
617+
self._executing_threads: set[int] = set()
618+
# Single lock guarding both reentry-tracking sets. Touched only
619+
# on ``execute_code`` enter/exit and inside ``_tracked_subcall``,
620+
# so contention is negligible.
585621
self._subcall_threads_lock = threading.Lock()
586622
self._context_count: int = 0
587623
self._history_count: int = 0
@@ -619,7 +655,13 @@ def __init__(
619655
if setup_code:
620656
self.execute_code(setup_code)
621657
except BaseException:
622-
self.cleanup()
658+
# Suppress *cleanup* errors so they don't shadow the original
659+
# cause. ``raise`` (with no arg) re-raises the real exception
660+
# the user needs to see.
661+
try:
662+
self.cleanup()
663+
except Exception:
664+
pass
623665
raise
624666

625667
# -------------------------------------------------------------------------
@@ -1086,27 +1128,53 @@ def update_handler_address(self, address: tuple[str, int]) -> None:
10861128
# -------------------------------------------------------------------------
10871129

10881130
def execute_code(self, code: str) -> REPLResult:
1089-
# Reentry guard: if the caller's thread is currently inside this
1090-
# REPL's ``subcall_fn`` (in-process synchronously, or subprocess
1091-
# via the broker handler thread), calling ``execute_code`` would
1092-
# either deadlock the cell lock (cross-thread broker reentry) or
1093-
# silently corrupt the in-flight cell's tracking (same-thread
1094-
# in-process reentry). Fail fast with a clear message.
1131+
# Reentry guards:
1132+
#
1133+
# 1. ``cur in self._subcall_threads`` — the caller is currently
1134+
# inside ``subcall_fn`` (in-process synchronously, or via the
1135+
# subprocess broker handler thread). Calling ``execute_code``
1136+
# would either deadlock the cell lock (cross-thread case) or
1137+
# silently corrupt this cell's bookkeeping (same-thread).
1138+
#
1139+
# 2. ``cur in self._executing_threads`` — the caller is already
1140+
# inside an ``execute_code`` invocation on this same REPL.
1141+
# This is the path an LM-generated cell takes when it
1142+
# traverses ``rlm_query.__self__.execute_code(...)`` (or any
1143+
# scaffold bound method) to call back into the parent. The
1144+
# RLock would let it reenter, but the inner call clears
1145+
# ``_pending_llm_calls`` / resets ``_last_final_answer`` and
1146+
# leaves the outer cell with a corrupt result.
10951147
cur = threading.get_ident()
10961148
with self._subcall_threads_lock:
1097-
is_reentrant = cur in self._subcall_threads
1098-
if is_reentrant:
1149+
in_subcall = cur in self._subcall_threads
1150+
already_executing = cur in self._executing_threads
1151+
if in_subcall:
10991152
raise RuntimeError(
11001153
"Reentrant execute_code on the same instance from inside "
11011154
"subcall_fn is not supported — it would deadlock or "
11021155
"clobber the parent cell's bookkeeping. subcall_fn must "
11031156
"spawn a child REPL (with its own lock) instead of "
11041157
"calling back into its parent."
11051158
)
1159+
if already_executing:
1160+
raise RuntimeError(
1161+
"Reentrant execute_code on the same instance is not "
1162+
"supported. The currently-running cell appears to have "
1163+
"called execute_code on its own REPL (e.g. via "
1164+
"rlm_query.__self__.execute_code(...)) — that would "
1165+
"corrupt this cell's tracking state. Use a separate "
1166+
"REPL instance for nested execution."
1167+
)
11061168

1107-
if self.kernel_mode == "in_process":
1108-
return self._execute_in_process(code)
1109-
return self._execute_in_kernel(code, timeout=self.cell_timeout, drain_broker=True)
1169+
with self._subcall_threads_lock:
1170+
self._executing_threads.add(cur)
1171+
try:
1172+
if self.kernel_mode == "in_process":
1173+
return self._execute_in_process(code)
1174+
return self._execute_in_kernel(code, timeout=self.cell_timeout, drain_broker=True)
1175+
finally:
1176+
with self._subcall_threads_lock:
1177+
self._executing_threads.discard(cur)
11101178

11111179
def _execute_in_process(self, code: str) -> REPLResult:
11121180
start_time = time.perf_counter()

‎tests/test_ipython_repl.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,48 @@ def test_invalid_kernel_mode_rejected():
319319
IPythonREPL(kernel_mode="nonsense") # type: ignore[arg-type]
320320

321321

322+
@pytest.mark.parametrize("bad", [0, -1, -0.5])
323+
def test_invalid_startup_timeout_rejected(bad):
324+
with pytest.raises(ValueError, match="startup_timeout"):
325+
IPythonREPL(kernel_mode="in_process", startup_timeout=bad)
326+
327+
328+
@pytest.mark.parametrize("bad", [0, -1, -0.5])
329+
def test_invalid_subcall_timeout_rejected(bad):
330+
with pytest.raises(ValueError, match="subcall_timeout"):
331+
IPythonREPL(kernel_mode="in_process", subcall_timeout=bad)
332+
333+
334+
def test_subcall_timeout_none_is_allowed():
335+
"""``None`` is the documented "no timeout" sentinel, distinct from 0."""
336+
with IPythonREPL(kernel_mode="in_process", subcall_timeout=None) as repl:
337+
assert repl.subcall_timeout is None
338+
339+
340+
def test_init_failure_preserves_original_exception_over_cleanup_error(monkeypatch):
341+
"""If ``setup`` fails *and* cleanup would also fail, ``__init__``
342+
must surface the original cause, not the cleanup error."""
343+
344+
class _Boom(Exception):
345+
pass
346+
347+
# Fail in ``_setup_in_process`` before any IPython state is created
348+
# (so the test doesn't leave dangling atexit handlers behind).
349+
def _broken_setup(self):
350+
raise _Boom("original cause")
351+
352+
monkeypatch.setattr(IPythonREPL, "_setup_in_process", _broken_setup)
353+
354+
# Make cleanup itself raise too, so we can verify the original wins.
355+
def _broken_cleanup(self):
356+
raise RuntimeError("cleanup also failed")
357+
358+
monkeypatch.setattr(IPythonREPL, "cleanup", _broken_cleanup)
359+
360+
with pytest.raises(_Boom, match="original cause"):
361+
IPythonREPL(kernel_mode="in_process")
362+
363+
322364
@BOTH_MODES
323365
def test_cleanup_is_idempotent(kernel_mode: str):
324366
repl = IPythonREPL(kernel_mode=kernel_mode)
@@ -673,6 +715,57 @@ def slow_subcall(prompt: str, model: str | None = None) -> RLMChatCompletion:
673715
)
674716

675717

718+
# -----------------------------------------------------------------------------
719+
# Reentrant cell → execute_code (LM reaches parent via bound-method __self__)
720+
# -----------------------------------------------------------------------------
721+
722+
723+
def test_in_process_cell_cannot_reenter_via_scaffold_self():
724+
"""An in-process cell that traverses any scaffold bound method's
725+
``__self__`` to call ``execute_code`` on the parent must surface a
726+
``RuntimeError`` rather than silently corrupting the parent's
727+
tracking state.
728+
729+
Subprocess mode is naturally immune: kernel-side scaffolds are
730+
plain functions with no ``__self__`` referencing the parent.
731+
"""
732+
with IPythonREPL(kernel_mode="in_process") as repl:
733+
# ``rlm_query`` is a bound method; ``rlm_query.__self__`` is the
734+
# parent REPL. Any other scaffold (llm_query, FINAL_VAR, ...)
735+
# would work just as well as a reentry vector.
736+
result = repl.execute_code("rlm_query.__self__.execute_code('print(\"inner\")')")
737+
assert (
738+
"RuntimeError" in result.stderr
739+
or "Reentrant" in result.stderr
740+
or "Reentrant" in result.stdout
741+
), result.stderr or result.stdout
742+
743+
744+
def test_in_process_scaffold_self_reentry_preserves_outer_state():
745+
"""If the LM tries to reenter and we raise, the *outer* cell's
746+
bookkeeping (``_pending_llm_calls``, ``_last_final_answer``) must
747+
still be intact when execution resumes after the caught error."""
748+
749+
fake = _FakeSubcall(responses=["A"])
750+
with IPythonREPL(kernel_mode="in_process", subcall_fn=fake) as repl:
751+
# Cell makes a real rlm_query, then attempts reentry. The reentry
752+
# raises and is caught locally; the outer rlm_query call should
753+
# still be reflected in ``rlm_calls``.
754+
result = repl.execute_code(
755+
"real = rlm_query('outer')\n"
756+
"try:\n"
757+
" rlm_query.__self__.execute_code('inner')\n"
758+
"except RuntimeError as e:\n"
759+
" print('caught:', type(e).__name__)\n"
760+
"print('outer-result:', real)\n"
761+
)
762+
assert "caught: RuntimeError" in result.stdout
763+
assert "outer-result: A" in result.stdout
764+
assert len(result.rlm_calls) == 1, (
765+
f"outer rlm_query must remain tracked; got {len(result.rlm_calls)}"
766+
)
767+
768+
676769
# -----------------------------------------------------------------------------
677770
# Reentrant subcall_fn → execute_code (deadlock prevention)
678771
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)