Repository navigation
fix: a snippet blocked in a wait that never ends stops at its budget (Python + C#) - #10
Merged
Merged
Conversation
…(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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 1ThreadPoolExecutor(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 refiresSnippetTimeoutevery 100 ms, butPyThreadState_SetAsyncExcis 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 withEvent.wait,queue.get,Future.resultand a longtime.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)
join()andresult()return.Thread.join,Condition.waitandtime.sleepwait in 50 ms slices on the snippet's threads.Condition.waitcoversEvent,Semaphore,Barrier,QueueandFuture.SnippetThreadStopped.Condition.waitkeeps one waiter for the whole wait, so nonotifyis lost.time.sleep(60)with a 1 s budget now stops after about 1 s.C# (§3.6)
GuardWriterrewrites 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/SleepTask.Wait/WaitAll/WaitAny/Result/GetAwaiter().GetResult()SemaphoreSlim,ManualResetEventSlimandCountdownEvent.WaitMonitor.WaitWaitHandle.WaitOne/WaitAll/WaitAnyOverrun = Timeout (both runtimes). Godot wave 7 reported that
Thread.Sleep(60000); 1returned 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 asTimeout("finished only after M ms") instead of success.Not covered (documented in the spec)
Lock.acquire/RLock.acquire(C),SimpleQueue.get, I/O /select, and waits inside C or engine code.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.
Two more checks:
Quality stages:
simplify:commit, thencode 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