Skip to content

Refuse a sync overrun at guard exit even when the timer never fired - #38

Merged
Shashankss1205 merged 1 commit into
mainfrom
fix/issue-22
Jul 31, 2026
Merged

Refuse a sync overrun at guard exit even when the timer never fired#38
Shashankss1205 merged 1 commit into
mainfrom
fix/issue-22

Conversation

@Shashankss1205

Copy link
Copy Markdown
Collaborator

The gap

The sync deadline_guard's exit path raised only when state["fired"] was set. On the threading.Timer + PyThreadState_SetAsyncExc fallback — the mechanism every worker-thread run uses (threaded servers, ThreadPoolExecutor callers) — the timer thread needs the GIL to run fire(). A node that held the GIL through the deadline (a long C-level call, or simply timer-scheduling latency) returned before fire() ever ran; disarm() cancelled the pending timer, nothing raised, and the overrunning node's writes committed. For a last node there is no next _enter budget 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 — after disarm(), raise NodeDeadlineExceeded(detail()) when state["fired"] or meter.remaining_seconds() is not None and <= 0, with a comment naming the timer-never-fired case.
  • Docstring: the C-call caveat now reads "is not interrupted mid-call, but the guard still raises on exit", and the closing promise matches the async guard's ("Short of the never-returns case…"), covering the no-interrupt-delivered path.
  • tests/test_budget_enforcement.py: test_an_overrun_is_refused_at_exit_even_if_the_timer_never_fired runs deadline_guard on a worker thread with threading.Timer monkeypatched to a timer that never fires and a body that outlasts max_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 .: clean
  • Existing swallower / re-arm / refuses-to-start tests pass unchanged

Fixes #22

🤖 Generated with Claude Code

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>
@Shashankss1205
Shashankss1205 merged commit 99a5aca into main Jul 31, 2026
6 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.

runtime: the sync deadline guard lets a node that overran commit its writes when the timer never fired

1 participant