Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions changelog.d/10722-cron-cronjob-barrier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
**`test_gap_cron_cronjob` no longer races the wall clock inside `pr-gate`'s
scope.** The fixture started a `* * * * * *` CronJob, then waited with
`while ((ticks < 2 || autoTicks < 2) && Date.now() < tickDeadline)` against a fixed
`Date.now() + 10_000`. That deadline is a *timeout*, not a barrier: when it expired
first the loop exited and the fixture printed `false` on two lines that are expected
to read `true`, and dropped the `tick 1` / `tick 2` lines entirely — a four-line
output divergence the harness classifies as a `parity_fail`, i.e. reports as a
compiler regression. The header comment's claim that the output was "deterministic
despite the timing" held only while two ticks of a one-per-second schedule landed
inside ten seconds. It cost real work: the fixture failed in a merge-queue
validation and #10530 was held out of a train on the strength of it, after which a
`--trace llvm` A/B showed byte-identical IR.

The wait is now a **barrier with no deadline**. The printed text becomes a function
of CronJob's behaviour alone: either the ticks arrive and the fixture prints its one
expected output, or nothing dispatches and the harness's own `PERRY_RUN_TIMEOUT`
kills the run — which it classifies as a CRASH/timeout, distinctly from a parity
mismatch, so a contended runner can no longer make this look like a miscompile.
There is deliberately no fallback bound: any bound that prints, throws or exits
differently on expiry reintroduces the same defect at a different threshold, and a
`false`-printing 30s deadline is the identical bug with a longer fuse. The old
number was in any case unreachable — `PERRY_RUN_TIMEOUT` is itself 10s, so the
fixture's deadline could only ever fire in a photo finish with the kill.

Nothing is weakened. The assertion moved from a printed comparison into the loop's
exit condition, which the program cannot pass without satisfying; the four-arg
`start=true` form, the non-auto-starting two-arg form and `start()`/`stop()`
dispatch are all still exercised, and the `tick 1` / `tick 2` lines remain in the
diff as positive evidence that the manual job fired. The never-started job's line
got *stronger*: it printed a hardcoded `true`, and now prints `neverTicks === 0`
from a real counter, checked after the barrier — i.e. after at least two cron
seconds have demonstrably elapsed with that job unstarted. Output bytes are
unchanged.

Verified by injecting an identical 11-second synchronous event-loop stall into the
old and new fixtures at the same point (a deterministic stand-in for the loaded
runner). Under Node 26.5.1 and under Perry v0.5.1598 alike, the old fixture prints
the four-line divergence and the new one is byte-identical to the unstalled oracle.
The real fixture passes the harness (`run_parity_tests.sh --filter
test_gap_cron_cronjob`, exit 0, journal `status: pass`), and eight consecutive Node
runs gave one distinct output in 1.86–2.04 s — roughly a fifth of the run budget.

Two sibling fixtures have the same shape and are *not* touched here:
`test_gap_9592_child_timeout_threads` (a 1 s deadline whose expiry prints
`timeout threads released: false`; Linux-only, short-circuited elsewhere) and
`test_gap_9493_child_stdin_backpressure` (a watchdog that `resolve(false)`s). Both
are in gate scope. (#10581)
28 changes: 22 additions & 6 deletions test-files/test_gap_cron_cronjob.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
// Gap test: the npm `cron` package's CronJob class (distinct from
// node-cron's schedule() factory). `new CronJob(expr, fn)` must NOT
// auto-start; the 4-arg form with start=true must; start()/stop() must
// dispatch. Tick counts are asserted as booleans and only the first two
// manual ticks print, so output is deterministic despite the timing.
// dispatch. Only the first two manual ticks print, so the output does not
// depend on how many ticks land.
//
// #10581: the wait below is a BARRIER, not a deadline. It used to race a fixed
// 10-second wall clock against a one-per-second schedule and print `false` when
// the clock won -- classified as a `parity_fail`, i.e. read as a miscompile, on
// a loaded runner. (`PERRY_RUN_TIMEOUT` is itself 10s, so that deadline could
// only ever fire in a photo finish with the harness's own kill.) With no
// deadline the printed text is a function of CronJob's behaviour alone: either
// the ticks arrive and the output below is produced, or nothing dispatches and
// the harness kills the run -- which it classifies as a CRASH/timeout,
// distinctly from a parity mismatch. Deliberately no fallback bound: any bound
// that prints, throws or exits differently on expiry reintroduces exactly this
// defect at a different threshold. The fixture is not permitted to decide it
// has waited long enough; two ticks of `* * * * * *` take ~2s.

import { CronJob } from "cron";

Expand All @@ -16,8 +29,12 @@ async function main() {
});
console.log("constructed, ticks now:", ticks);

// A never-started job must not fire (would print below and break the diff).
// A never-started job must not fire (the log below would break the diff, and
// the counter is asserted after the barrier, i.e. after >= 2 cron seconds
// have demonstrably elapsed).
let neverTicks = 0;
const never = new CronJob("* * * * * *", () => {
neverTicks++;
console.log("SHOULD-NOT-RUN");
});

Expand All @@ -33,16 +50,15 @@ async function main() {
);

job.start();
const tickDeadline = Date.now() + 10_000;
while ((ticks < 2 || autoTicks < 2) && Date.now() < tickDeadline) {
while (ticks < 2 || autoTicks < 2) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
job.stop();
auto.stop();

console.log("manual ticked at least twice:", ticks >= 2);
console.log("auto ticked at least twice:", autoTicks >= 2);
console.log("never-started stayed quiet:", true);
console.log("never-started stayed quiet:", neverTicks === 0);
console.log("done");
}

Expand Down
Loading