Refuse a sync overrun at guard exit even when the timer never fired - #38
Merged
Conversation
The sync deadline_guard's exit path raised only when its interrupt had actually fired. On the threading.Timer fallback — the mechanism every worker-thread run uses — the timer thread needs the GIL to run fire(), so a node that held it through the deadline (a long C call, or plain scheduling latency) returned normally, disarm() cancelled the pending timer, and the overrun's writes committed; for a last node the run then reported success past its wall-clock ceiling. Mirror the async guard's exit check: raise NodeDeadlineExceeded when the interrupt fired or the meter shows the deadline spent, and say so in the guard's docstring. The regression test monkeypatches threading.Timer with one that never fires, testing the exit contract deterministically instead of racing the timer thread. Fixes #22 Co-Authored-By: Claude Fable 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.
The gap
The sync
deadline_guard's exit path raised only whenstate["fired"]was set. On thethreading.Timer+PyThreadState_SetAsyncExcfallback — the mechanism every worker-thread run uses (threaded servers,ThreadPoolExecutorcallers) — the timer thread needs the GIL to runfire(). A node that held the GIL through the deadline (a long C-level call, or simply timer-scheduling latency) returned beforefire()ever ran;disarm()cancelled the pending timer, nothing raised, and the overrunning node's writes committed. For a last node there is no next_enterbudget check, so the run completed past its wall-clock ceiling and reported success — violating the README's "a node that overran does not get its writes into state" and the guard's own docstring. The async guard already refused this exact case at the same boundary (grapharc/runtime/graph.py:255-260).The fix
grapharc/runtime/budget.py: mirror the async guard's exit check — afterdisarm(), raiseNodeDeadlineExceeded(detail())whenstate["fired"]ormeter.remaining_seconds()is notNoneand<= 0, with a comment naming the timer-never-fired case.tests/test_budget_enforcement.py:test_an_overrun_is_refused_at_exit_even_if_the_timer_never_firedrunsdeadline_guardon a worker thread withthreading.Timermonkeypatched to a timer that never fires and a body that outlastsmax_seconds, asserting the guard raises on exit. This tests the exit contract deterministically rather than racing the timer thread (per the cautionary notes around_run_swallower). It fails on unfixed code (guard exits cleanly) and passes with the fix.Out of scope, untouched per the issue: the never-returns case, the SIGALRM mechanism, and
_async_deadline.The issue's repro (switch-interval GIL hold under
Budget(max_seconds=0.2)driven from a worker thread) now prints{'exc': NodeDeadlineExceeded("max_seconds reached while node 'slow' was running (0.6s/0.2s)")}instead of a committed result. README's "Budgets" paragraph is accurate again as written, so it is unchanged.Verification
pytest: 1648 passed, 12 deselected (full suite, no flaky reruns needed on this run)ruff check .: cleanFixes #22
🤖 Generated with Claude Code