Skip to content

fix: a snippet blocked in a wait that never ends stops at its budget (Python + C#) - #10

Merged
IvanMurzak merged 4 commits into
mainfrom
fix/sliced-blocking-waits
Oct 29, 2026
Merged

IvanMurzak merged 4 commits into
mainfrom
fix/sliced-blocking-waits

Conversation

@IvanMurzak

@IvanMurzak IvanMurzak commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Problem (Unreal wave 7, python-execute)

A snippet that waits on a thread it started which never ends hangs the host's main/game thread for good: the timeout never fires at any budget.

  • import threading / t = threading.Thread(target=spin); t.start(); t.join() with timeout 1
  • ThreadPoolExecutor(2).submit(lambda: [0 for _ in iter(int,1)]).result()

Root cause (confirmed): a faulthandler dump of the hang shows the main thread in threading.py _wait_for_tstate_lock (lock.acquire). The watchdog refires SnippetTimeout every 100 ms, but PyThreadState_SetAsyncExc is only delivered between bytecodes, never inside a C lock wait. The spinning thread was only stopped at run end, and the run could not end. The same happens with Event.wait, queue.get, Future.result and a long time.sleep.

C#: work the snippet starts already shares the budget, so the spin repros stopped. But a wait that nothing would ever end hung the host. These were reproduced in child processes: SemaphoreSlim.Wait, ManualResetEventSlim.Wait, Monitor.Wait, Thread.Sleep(long), a join on work that is itself blocked, TaskCompletionSource.Task.Wait/Result/GetResult.

Fix

Python (§4)

  • At the deadline, the watchdog now stops the snippet's started threads immediately. This uses the existing stop-on-agent-code mechanism, so join() and result() return.
  • While a snippet runs, Thread.join, Condition.wait and time.sleep wait in 50 ms slices on the snippet's threads. Condition.wait covers Event, Semaphore, Barrier, Queue and Future.
    • Between slices, the snippet's thread raises its timeout and a started thread raises SnippetThreadStopped.
    • The watchdog sends no asynchronous stop into a sliced wait. A sliced Condition.wait keeps one waiter for the whole wait, so no notify is lost.
  • time.sleep(60) with a 1 s budget now stops after about 1 s.
  • The stand-ins are scoped to runs and restored afterwards. Host threads are untouched.

C# (§3.6)

  • GuardWriter rewrites blocking waits in the agent's code into guard stand-ins with the same parameters. The stand-ins wait in 50 ms slices with a guard check between slices. Covered:
    • Thread.Join / Sleep
    • Task.Wait / WaitAll / WaitAny / Result / GetAwaiter().GetResult()
    • SemaphoreSlim, ManualResetEventSlim and CountdownEvent .Wait
    • Monitor.Wait
    • WaitHandle.WaitOne / WaitAll / WaitAny
  • Values, exceptions, timeouts and wake-ups stay the same.

Overrun = Timeout (both runtimes). Godot wave 7 reported that Thread.Sleep(60000); 1 returned success after 60 s. That case is now sliced. A run that only returns after its budget, blocked in a call nothing can interrupt (a lock, a host or engine call, a C call), is now reported as Timeout ("finished only after M ms") instead of success.

Not covered (documented in the spec)

  • Python: Lock.acquire / RLock.acquire (C), SimpleQueue.get, I/O / select, and waits inside C or engine code.
  • C#: lock / Monitor.Enter, BlockingCollection, waits inside host, engine or framework code, ?. chains, named arguments, and native threads.

Tests

Child-process tests for every repro, checking Timeout within budget + slack and that the process stays usable afterwards.

  • Python 3.10–3.14 (also passed locally on 3.15).
  • C# on Roslyn 3.8 / 5.9 (net8, net10) and net48.

Two more checks:

  • The new Python tests hang (and fail) against main.
  • The C# hang cases hung against main (killed at 20 s).

Quality stages: simplify: commit, then code review: commit (report-only reviewer agents). That review found a lost-notify race and several C# rewrite cases, all fixed here.

No release, no version bump, no workflow changes.

🤖 Generated with Claude Code

IvanMurzak and others added 4 commits October 29, 2026 15:09
…(Python + C#)

Python: a snippet that joined a thread it started which never ends (or waited
on Future.result, Event.wait, queue.get, a long time.sleep) hung the host's
thread for good: the wait blocks in a C lock acquire, where the watchdog's
asynchronous SnippetTimeout is never delivered, and the snippet's threads were
only stopped at run end, which could not come.

- At the deadline the watchdog now stops the snippet's threads at once (the
  existing stop-on-agent-code mechanism), so join() / result() return.
- While a snippet runs, Thread.join, Condition.wait (Event, Semaphore, Barrier,
  Queue, Future) and time.sleep wait in 50 ms slices on the snippet's threads:
  between two the snippet's thread raises its timeout (the watchdog sends no
  asynchronous stop into a sliced wait, so the wait's lock is held again) and a
  thread it started raises SnippetThreadStopped once the run is stopping.
  time.sleep(60) with a 1 s budget now stops after about 1 s.
- A SnippetThreadStopped handed to the snippet by stopped work is the Timeout.

C#: work the snippet starts already shared the budget, but a wait nothing
would ever end (SemaphoreSlim/ManualResetEventSlim/CountdownEvent.Wait,
Monitor.Wait, WaitHandle waits, Task.Wait/Result/GetResult of a task that never
completes, Thread.Sleep, a join on blocked work) hung the host. GuardWriter
now rewrites those waits in the agent's code into guard stand-ins of the same
parameters that wait in 50 ms slices with a guard check between two.

Child-process tests for every repro (Python 3.10-3.14, C# on the Roslyn
matrix) check Timeout within budget + slack and that the process stays usable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g wait overloads

- Python: _sliced decides itself whether to slice (no None sentinel repeated
  by each stand-in); the snippet's thread of a run without a budget is not
  sliced (nothing could stop it); one _agent_frame helper for both stack walks.
- C#: shorter wait overloads forward to the fullest one; WaitNames derives from
  the target table; cheap syntactic checks before the symbol lookup; the
  ReadOnlySpan overload is matched by its exact key.

Skipped: Thread.Interrupt instead of call-site rewriting (it would throw into
host/engine waits on the host's thread and can leave a pending interrupt for
the host); per-slice Task.Wait allocations (~20/s while blocked, acceptable);
QualifiedName in the older constants (cosmetic); sharing the old recursion
child-process test helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python:
- A sliced Condition.wait re-entered Python's own wait per slice, so a notify
  landing as a slice timed out was lost (Queue.get / Event.wait could hang
  until the budget). It now keeps ONE waiter for the whole wait and acquires
  only the waiter lock in slices; a due stop is raised after the lock is held
  again.
- The host's value converter runs unbudgeted (the run is disarmed first).
- Finite timeouts of any size are sliced (was capped at 1e9 s).
- Regression tests: notify across a slice boundary (Condition, Queue), the
  converter, Event.wait(1e10); the timing assertion is a range.

C#:
- Waits inside a conditional access (a?.Task.Result) and property pattern
  paths (x is { Task.Result: 3 }) are left alone: the rewrite broke them.
- .NET 9+ Task.WaitAll(IEnumerable<Task>, CancellationToken = default) is
  matched (the table key lacked the token, so it hung).
- A wait that succeeded is never followed by a stop (it may hold a semaphore
  or mutex); deadlines use Stopwatch (TickCount let sleeps end early).
- A wait without bound where nothing could stop it is the framework's own, so
  it still runs a queued task inline; the remaining difference under a budget
  is documented.
- ConfigureAwait(b).GetAwaiter().GetResult() is sliced too.
- Spec: the Python time-budget paragraph is whole again; coverage updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is a Timeout

Godot wave 7: `Thread.Sleep(60000); 1` returned success after 60 s at any
budget (now sliced, see the earlier commits). For waits no slicing can reach
(a lock, a host or engine call, a C call in Python), a run that returns after
its budget is no longer a success:

- C#: the synchronous part of a guarded run is timed from Arm to SetResult;
  past the budget it is `Timeout` ("it finished only after M ms, blocked in a
  call the guards cannot interrupt"). Pending tasks keep RunAsync's own
  budget, and a faulted task still reports its own error.
- Python: when the watchdog fired while the snippet still ran (it swallowed
  the stop or returned before it was raised again), the run is `Timeout` with
  the same kind of message.
- Tests: Thread.Sleep(60000)/(2000) and Task.Delay(60000).Wait() with values
  after them, an unsliced named-argument Sleep past the budget (C#), a raw
  lock acquire past the budget whose stop is swallowed (Python); sliced cases
  assert they were stopped in the wait. A log assertion that raced is gone.

Reviewed by a report-only reviewer agent; its findings (async false Timeouts,
a test that passed without the change, a weak message) are fixed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IvanMurzak
IvanMurzak merged commit affe6ef into main Oct 29, 2026
13 checks passed
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