Skip to content

test(scheduler): drive the run-executor watchdog tests with injected timers - #629

Open
tnunamak wants to merge 6 commits into
mainfrom
test/run-executor-timeout-honesty-deterministic
Open

tnunamak wants to merge 6 commits into
mainfrom
test/run-executor-timeout-honesty-deterministic

Conversation

@tnunamak

@tnunamak tnunamak commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

No change for users. The scheduler's run watchdog now accepts injectable timers, defaulting to the real ones, so tests can fire it on demand.
The run-executor timeout tests armed the real watchdog with a 400 ms budget, and that budget also covered process startup and the ingest round-trip. On a loaded host the run was killed before its records were ingested, so records_emitted: N and the local-only-phase test failed.
The tests now wait for the event they assert on (records ingested, phase boundary latched), check the delay the watchdog armed, and fire it explicitly. One test uses the real timers with a hanging connector and expects run_timed_out.
The timer seam returns a canceller, so a test implementation does not have to fake a Node timer handle.
Proof: with 3 busy loops per core and 16 parallel runs, the file failed 16 of 16 times before and 0 of 48 after. The suites for the watchdog and scheduler config pass.
The non-required "mutate changed production files" job cannot sample scheduler/run-executor.ts because 12 test files import it, so it fails for any change to that file beyond a trivial one.
Assisted-by: AI

run-executor-timeout-honesty.test.ts armed the real watchdog with a 400 ms
no-progress budget. The watchdog is armed before the connector subprocess is
spawned, so that budget also had to cover node startup, the START handshake and
the ingest round-trip. On a loaded host those exceeded it: the run was killed
with run_timed_out before its records were ingested, so the records_emitted: 5
and "survives past the budget" tests failed (16 of 16 parallel runs under CPU
load; the file passes alone).

Let the attempt watchdog take its timer functions: createAttemptWatchdog and
RunExecutorDeps gain an optional `watchdogTimers`, defaulting to the global
timers, so production behaviour is unchanged. The tests now inject a manual
timer, wait for the event they assert on (records durably ingested, the
local-only boundary latched), assert the delay the watchdog armed (one budget,
or the four-budget ceiling after the boundary) and fire it explicitly. The
phase-boundary connector waits for a release marker instead of sleeping for a
multiple of the budget.

Under CPU load (3 busy loops per core, 16 parallel runs) the old file failed in
16 of 16 runs; the new one failed in 0 of 48. Replacing the ceiling with the
primary budget in run-executor.ts makes the boundary tests fail.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…default timers

WatchdogTimers.setTimeout now returns a canceller instead of a Node timer,
so a test implementation no longer has to fabricate a NodeJS.Timeout and cast
it back in clearTimeout. unref moves into the production default.

The test header now says what the suite proves with injected timers. One
real-timer test covers the default wiring: a tiny budget and a hanging
connector must end as run_timed_out.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak tnunamak changed the title test: drive the run-executor watchdog tests with injected timers fix(scheduler): validate the watchdog budget and drive its tests with injected timers Nov 6, 2026
A NaN or negative maxRunWallClockMs read as "no ceiling", so a hanging
connector was never killed and nothing recorded why. createAttemptWatchdog and
createRunExecutor now throw on it. Infinity and 0 stay the documented explicit
opt-outs.

Timer delays are clamped to 2^31 - 1 ms: budget * 4 could overflow to
Infinity (read as unbounded) and Node fires a longer setTimeout almost at once.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
markProgress latched the local-only phase and reported a suppression even when
nothing was armed: with an opt-out budget (0 or Infinity), after the watchdog
fired, or after an owner cancel. The caller then recorded a "watchdog
suppressed" event for a suppression that never happened. It now latches only
while a primary timer could still fire.

The WatchdogTimers canceller is documented as idempotent, and the cancelled
timer is dropped after the latch.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The scheduler already rejects a NaN or negative maxRunWallClockMs with
resolveNonNegativeMsOrInfinity before createRunExecutor sees it, and that is
the only production caller. The watchdog's own copy had the same message, so it
and its tests are removed. Behaviour is unchanged.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak tnunamak changed the title fix(scheduler): validate the watchdog budget and drive its tests with injected timers test(scheduler): drive the run-executor watchdog tests with injected timers Nov 6, 2026
Move the timer-delay clamp and the local-only latch guard out of this change
so the production diff stays within the mutation cohort bound. They return as
their own change with their own tests.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
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