Skip to content

state_exec: a time budget cut inside a step reads as the time limit - #697

Merged
transfix merged 1 commit into
masterfrom
fix/state-exec-job-timeout
Oct 18, 2026
Merged

transfix merged 1 commit into
masterfrom
fix/state-exec-job-timeout

Conversation

@transfix

Copy link
Copy Markdown
Owner

Why

A process's time cap is checked between steps, and inside a step by the step's deadline. Long loops inside one native call poll that deadline: data jobs, generator bodies that never yield, stdlib materializers and the deep compare. They threw a plain std::runtime_error ("collections.range: evaluation exceeded time budget"), not the evaluation_timeout the evaluator throws. So one time cap was reported in two ways, depending on which check saw it first:

between steps inside a step (before)
Ariadne predicate exceeded the eval budget (time cap) failed at eval (collections.range: evaluation exceeded time budget)
async scheduler exit error time_limit_exceeded collections.range: evaluation exceeded time budget
sync scheduler process killed runtime_error out of run()
pycvc evaluator pycvc.evaluation_timeout plain RuntimeError

This is what made AriadneStdlib.PredicateMaterializerIsCutByTheEvalBudget flaky on slow Debug builds (#680 had to accept both wordings). The user decided to make them match.

What

  • One poll. eval_deadline_check(what) throws evaluation_timeout("<what>: evaluation exceeded timeout"). All six polls use it: three in data_job.cpp, the deep compare, generator.h, and the stdlib's poll_deadline. evaluation_timeout and evaluation_interrupted move from evaluator.h to types.h, next to the deadline. evaluator.h includes types.h, so every user still sees them.
  • Schedulers.
    • A step that throws evaluation_timeout while the step's own deadline is spent kills the process with time_limit_exceeded, the reason check_limits() gives between steps. eval_deadline_guard::expired() checks only that guard.
    • On the async scheduler this includes the residents' per-step cap (max_step_time).
    • On the sync scheduler, a deadline armed outside the scheduler (its caller's) still propagates out of run(), and other errors still propagate as before. Ariadne's init: lane runs on the sync scheduler, so it now reports "did not complete normally" either way.
  • Evaluators.
    • stackless_evaluator::run already armed the thread's deadline for its timeout. Now the tree-walking evaluator (in its worker thread) and the sync_evaluate* entry points of async_evaluator and async_stackless_evaluator arm it too.
    • Before, a loop inside one native call was never cut in those evaluators. The tree-walking evaluator's join() then waited on it forever: interrupt() is never seen inside a native loop.
  • Docs. The developer guide's per-process limits section says how a time cut inside a step is reported. The pycvc parity doc lists eval_deadline_check and the moved classes.

Tests

  • state_exec_scheduler_test StepDeadlineTest:
    • eval_deadline_check throws evaluation_timeout naming the call;
    • all four evaluators cut (next (generator (lambda () (while t 1)))) with evaluation_timeout;
    • the sync scheduler kills such a process instead of throwing, and leaves an outer deadline to its caller;
    • the async scheduler reports time_limit_exceeded for a cut inside a step, between steps, and by the per-step cap, and still reports another error by its message.
  • ariadne_runtime_test: PredicateMaterializerIsCutByTheEvalBudget expects exceeded the eval budget only.
  • pycvc:
    • the evaluator test also cuts the never-yielding generator with pycvc.evaluation_timeout;
    • a scheduler test checks that the sync scheduler kills the process and that the async exit error is time_limit_exceeded.

Local results (MSVC 14.38 Release, deps from the cvcpkg prefix): all 77 state_exec, Ariadne, async_lane and state* suites pass (2111 tests, including the 5 new StepDeadlineTest ones; the Ariadne predicate test 5 repeat runs clean); pycvc (exec, ari, state, app, async and the three state_exec files) 120 passed; clang-format 18 clean on the changed lines.

A long loop inside one native call (a data job, a generator body that
never yields, a stdlib materializer, a deep compare) polled the step's
deadline and threw a plain runtime_error ("collections.range: evaluation
exceeded time budget"). So the same time cap read three ways: Ariadne
predicates said "failed at eval (...)" instead of "(time cap)", the async
scheduler's exit error was that message instead of time_limit_exceeded,
and the sync scheduler threw it out of run() instead of killing the
process.

- The polls go through eval_deadline_check(what), which throws
  evaluation_timeout ("<what>: evaluation exceeded timeout").
  evaluation_timeout and evaluation_interrupted move to types.h, next to
  the deadline; evaluator.h still gets them through types.h.
- Both schedulers kill a process whose step throws evaluation_timeout
  with time_limit_exceeded when the step's own deadline is spent
  (eval_deadline_guard::expired()), as check_limits() does between
  steps; that includes the async per-step cap. A deadline armed outside
  the sync scheduler still propagates out of run(); other errors are
  unchanged.
- The tree-walking evaluator and both async evaluators' sync entry
  points arm the thread's deadline for their timeout, as
  stackless_evaluator::run does. Before, a loop inside one native call
  was never cut there, and the tree-walking evaluator's join() waited
  on it for ever.
- Developer guide and pycvc parity doc updated.
@transfix
transfix merged commit f3f390c into master Oct 18, 2026
15 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