Summary
If an in-flight feed_run_async is cancelled (e.g. via asyncio.wait_for timeout), the REPL session can be left permanently broken: the next feed_run_async panics on a tokio worker at crates/monty/src/intern.rs:916 (index out of bounds: the len is 2 but the index is 2) and raises RuntimeError: Async REPL transition was cancelled before completion — and every subsequent feed on that instance fails the same way.
The failure is deterministic, not a race: it happens whenever the cancelled snippet stored a new string literal of 2+ chars into state that survives the cancellation. Values that (presumably) aren't interned — 1-char strings, ints — recover fine, which is what makes the bug easy to miss in stress tests.
Repro
# uv run --with pydantic-monty==0.0.18 python repro.py
import asyncio
from pydantic_monty import MontyRepl
async def main():
repl = MontyRepl()
await repl.feed_run_async("items = []")
async def slow():
await asyncio.sleep(5)
try:
await asyncio.wait_for(
repl.feed_run_async(
"items.append('side-effect')\nawait slow()",
external_functions={"slow": slow},
),
timeout=0.3,
)
except asyncio.TimeoutError:
pass
print("recovered:", await repl.feed_run_async("items")) # panics here
asyncio.run(main())
Output:
thread 'tokio-rt-worker' panicked at crates/monty/src/intern.rs:916:9:
index out of bounds: the len is 2 but the index is 2
...
RuntimeError: Async REPL transition was cancelled before completion
What determines panic vs. recovery
Same scenario, varying only the appended literal (fresh process each trial, 3–5 trials per row, 100% consistent):
| cancelled run stored |
next feed |
items.append('x') |
recovers, partial mutation visible |
items.append(12345) |
recovers, partial mutation visible |
items.append('ab') |
panic |
items.append('abcd') |
panic |
items.append('side-effect') |
panic |
The 1-char/2-char boundary and the intern.rs location suggest: cancellation rolls back the intern table's additions from the aborted transition, but heap objects mutated before the cancel still reference the newly-interned entry, so the next heap access indexes past the rolled-back table (len 2, index 2 — exactly one new interned string on top of the two baseline entries). That's a hypothesis from black-box behavior; we haven't read the interning code.
Environment
- pydantic-monty 0.0.18 (current latest), macOS 15 arm64
- Reproduced on CPython 3.12.8 and 3.13.14
- 25/25 failures in fresh processes with the repro above; also reproduces mid-session in long-lived processes
Context and workaround
We hit this building an agent code-execution runtime (toolplane) that enforces a per-run timeout with asyncio.wait_for around feed_run_async. Sessions whose timed-out snippet had touched any realistic string data were bricked afterwards.
Workaround that holds up: dump() before each feed; on cancellation, discard the instance entirely and MontyRepl.load(snapshot) a fresh one. (That also gives run-level atomicity, since completed statements of a cancelled run otherwise persist — see the recovery rows above — which may or may not be intended semantics, but the panic rows clearly aren't.)
Happy to test a fix branch.
Summary
If an in-flight
feed_run_asyncis cancelled (e.g. viaasyncio.wait_fortimeout), the REPL session can be left permanently broken: the nextfeed_run_asyncpanics on a tokio worker atcrates/monty/src/intern.rs:916(index out of bounds: the len is 2 but the index is 2) and raisesRuntimeError: Async REPL transition was cancelled before completion— and every subsequent feed on that instance fails the same way.The failure is deterministic, not a race: it happens whenever the cancelled snippet stored a new string literal of 2+ chars into state that survives the cancellation. Values that (presumably) aren't interned — 1-char strings, ints — recover fine, which is what makes the bug easy to miss in stress tests.
Repro
Output:
What determines panic vs. recovery
Same scenario, varying only the appended literal (fresh process each trial, 3–5 trials per row, 100% consistent):
items.append('x')items.append(12345)items.append('ab')items.append('abcd')items.append('side-effect')The 1-char/2-char boundary and the
intern.rslocation suggest: cancellation rolls back the intern table's additions from the aborted transition, but heap objects mutated before the cancel still reference the newly-interned entry, so the next heap access indexes past the rolled-back table (len 2, index 2— exactly one new interned string on top of the two baseline entries). That's a hypothesis from black-box behavior; we haven't read the interning code.Environment
Context and workaround
We hit this building an agent code-execution runtime (toolplane) that enforces a per-run timeout with
asyncio.wait_foraroundfeed_run_async. Sessions whose timed-out snippet had touched any realistic string data were bricked afterwards.Workaround that holds up:
dump()before each feed; on cancellation, discard the instance entirely andMontyRepl.load(snapshot)a fresh one. (That also gives run-level atomicity, since completed statements of a cancelled run otherwise persist — see the recovery rows above — which may or may not be intended semantics, but the panic rows clearly aren't.)Happy to test a fix branch.