Skip to content

Cancelling MontyRepl.feed_run_async permanently poisons the session (panic at intern.rs:916) #533

Description

@fugu-ai

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions