@@ -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 ()
0 commit comments