Skip to content

fix: execution guards stop runaway recursion and endless loops (C# + Python) - #7

Merged
IvanMurzak merged 5 commits into
mainfrom
fix/execution-guards
Oct 28, 2026
Merged

IvanMurzak merged 5 commits into
mainfrom
fix/execution-guards

Conversation

@IvanMurzak

@IvanMurzak IvanMurzak commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Fixes two Code-Exec robustness findings from wave-5 testing. A snippet with deep recursion used to end the Godot editor with a stack overflow, and a synchronous endless loop froze the editor forever. This PR does not release anything or bump the version.

Execution guards (C#, spec §3.6)

The Roslyn adapter (GuardWriter) inserts guard checks into the snippet, full-source code and includes. The guard type is compiled into the snippet's own assembly, so the snippet never binds to a copy of Core.

  • Recursion: methods, local functions, accessors, operators, constructors, block lambdas and class field/property initializers check the stack every 8 calls. They check against the host's allowance (CodeRunnerOptions.MaxStackBytes, 1 MiB by default, on the snippet's thread) and against EnsureSufficientExecutionStack. The allowance exists because .NET 8 on Linux took minutes to unwind an exception thrown near the end of an 8 MB stack. Close to the limit the result is a Runtime error, System.InsufficientExecutionStackException: Recursion too deep: ..., not a stack overflow that kills the process. This works on the host's main thread.
  • Time: every loop iteration counts down a local counter and every goto a shared one; calls count down too. They read the clock after 1, 2, 4 … up to 256 iterations, and on every iteration while iterations are slow. Once timeoutMs is spent the run stops with Timeout (System.TimeoutException: The snippet ran longer than its time budget of N ms (timeoutMs) and was stopped.), with the snippet's line in the error.
  • Catch clauses: clauses that could catch a stop (no type, Exception, SystemException, or the stop's own type) get a when (Running()) filter, so a catch-all inside the loop cannot swallow the stop.
  • Coordinates: inserted code sits on its own lines behind #line directives plus padding, so diagnostics and stack frames keep the agent's line and column.
  • Options: CodeRunnerOptions.DefaultTimeoutMs (120 000) is used when a request leaves timeoutMs at 0. ExecutionGuards (default true) turns the guards off. A negative timeoutMs is InvalidRequest.
  • After the run: a finished run lifts its time budget, so callbacks it left behind keep only the stack check. A timed-out run keeps its stop. On Mono a timed-out assembly is never reused.

Python time budget (spec §4)

  • When timeoutMs is spent (or set_default_timeout_ms, default 120 s), a watchdog thread raises SnippetTimeout, a BaseException, in the snippet's thread with PyThreadState_SetAsyncExc, and raises it again every 100 ms while the snippet catches it. The result is errorKind Timeout.
  • The stop is disarmed without taking any lock in the snippet thread: on Python 3.14 a stop delivered right after acquiring a lock leaves that lock held. A 1,500-run race stress test on 3.11 and 3.14 had no stop leaking into the host and no leftover threads.
  • Recursion: the default limit gives a clean RecursionError. One limit is documented: a snippet that raises sys.setrecursionlimit can still overflow the C stack on 3.11 and older through C-level recursion (repr or json.dumps of a deeply nested list).

Also

  • Full-source mode now gets the request's usings and auto-import. The host's default usings are still not added, because adding them could make working code ambiguous. A new CE0003 diagnostic says so and names the unresolved names.
  • Unity analyzer warnings UAC0005/0007/0020 are suppressed with narrow pragmas. I checked this against Unity 6000.6.3f1's Unity.Analyzers.Common.dll.

Overhead

Measured on .NET 8 (snippet code is compiled in Debug); the baseline is the same loop without guards:

Case Overhead
Empty for loop about +0.8 ns per iteration (+77–150%)
foreach over a List with a sum +15–39%
Recursive fib(32), non-collectible host (Mono-like) +1.3 ns per call (+60%)
Recursive fib(32), collectible AssemblyLoadContext +5.7 ns per call (+250%)

Each call pays more on a collectible load context because statics there are slow to reach.

Python watchdog: about 65 µs per run (one timer thread started per run). A 3M-iteration loop showed no measurable per-bytecode cost (+2%, within noise) on 3.11 and 3.14.

Python 3.10 limit: a loop whose body begins with try or with never receives the stop on 3.10 (the interpreter does not check for pending exceptions there). This is documented, and the test skips that case on 3.10. Unreal uses 3.11, where it works.

Tests

  • New GuardTests cover recursion in each guarded form, endless loops of each kind including catch-alls and goto, async, full-source and include code, kept coordinates and kept meaning. Without the guards the recursion tests crash the test host with a stack overflow and the loop tests hang (checked locally).
  • New golden cases: endless-loop-timeout and deep-recursion for both runtimes, and full-source-auto-import.
  • Python TimeBudgetTests.
  • Local runs:
    • .NET tests on Roslyn 3.8, 4.8 and 5.9 (net8.0 and net10.0), plus net48.
    • SourceConsumer builds on Roslyn 3.8 and 4.8.
    • pack.sh plus the package consumer on Roslyn 3.8 and 5.9.
    • Python tests on 3.11, 3.12 and 3.14.

Quality stages: simplify: and code review: commits (two report-only reviewers; their findings were fixed in the code review: commit).

🤖 Generated with Claude Code

IvanMurzak and others added 5 commits October 27, 2026 23:07
C#: the Roslyn adapter compiles guard checks into snippets, full-source code and
includes: a sampled EnsureSufficientExecutionStack check at every method / local
function / accessor / block-lambda entry (Runtime "Recursion too deep" instead of
a process-killing stack overflow), a local countdown per loop iteration and a
check before every goto (Timeout once timeoutMs is spent, also for synchronous
code on the main thread), and a filter on every catch so a stop is not swallowed.
Inserted code sits on its own lines behind #line directives: the agent's lines
and columns are unchanged. CodeRunnerOptions.DefaultTimeoutMs (120 s) applies
when a request has no timeoutMs; ExecutionGuards turns the guards off.

Python: a watchdog raises SnippetTimeout in the snippet's thread when timeoutMs
(or set_default_timeout_ms, 120 s) is spent; it is disarmed before run() returns.

Also: full-source mode gets the request's usings and auto-import, plus CE0003
when names stay unresolved; Unity analyzer warnings UAC0005/0007/0020 are
suppressed where the engine-agnostic core needs those APIs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SON int fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C#: field/property initializers run the call check (a first guard field per
class); async expression bodies return a value only for generic task types
(aliases, UniTask); a finished run lifts the time budget for callbacks it left
behind, a timed-out one keeps its stop and is not reused on Mono; full source
starting with #define stays verbatim; only catch clauses that can catch a stop
get the filter; #line file names are written verbatim.

Python: the snippet thread never takes a lock while a stop can be pending (on
3.14 a stop delivered after acquiring one left it held): stop() clears a flag,
waits for the timer thread when it may have fired, then drops a pending stop;
ctypes is bound at import; each run raises its own SnippetTimeout subclass;
a lowered recursion limit no longer fails a successful run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…test limits

.NET 8 on Linux took minutes to unwind a recursion stopped near the end of an
8 MB stack (CI hang): the guard also stops once a snippet used MaxStackBytes
(1 MiB default) on a thread. Python 3.10 never delivers the stop in a loop whose
body is only a try statement: that case is skipped there and documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, docs

Thread statics cost ~60 ns per sampled check on a collectible context: the base
is recorded in Arm (same thread, right before the invocation) and compared only
on that thread; other threads keep the runtime's margin. Contract: allowance
wording, the 3.10 try/with loop limit, README; ICSharpCompiler note on unsafe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IvanMurzak
IvanMurzak merged commit 21dd065 into main Oct 28, 2026
13 checks passed
IvanMurzak added a commit that referenced this pull request Oct 30, 2026
* chore: release 0.4.0 (execution guards, sliced waits)

Version 0.3.1 -> 0.4.0 in dotnet/Directory.Build.props, the README snippet and the
Python module; the contract's Unreleased changelog entry becomes v0.4.0 (title v0.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* simplify: no changes

Report-only review: the bump covers every hard-coded version; the blank line before v0.3.1 is pre-existing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* code review: no findings

Report-only reviewer (high): every version reference is 0.4.0, the v0.4.0 changelog matches #7-#11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant