Skip to content

fix(pulse): validate cron schedules at config load (crash loop + hang) - #1644

Open
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:fix/cron-validation
Open

fix(pulse): validate cron schedules at config load (crash loop + hang)#1644
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:fix/cron-validation

Conversation

@elhoim

@elhoim elhoim commented Jul 26, 2026

Copy link
Copy Markdown

The problem

Two ways a single typo in the user-editable PULSE.toml takes the dashboard down. Nothing validates cron strings anywhere — jobsFromToml only casts j.schedule as string.

1. Crash loop. isDue() is called at pulse.ts:925, outside the per-job try at 937 which wraps only execution. isDue('0 9 * *') throws Invalid cron (need 5 fields), the throw escapes the scheduling loop into main().catch, and that calls process.exit(1). Both supervisors restart it — launchd KeepAlive with a 30s throttle, systemd Restart=on-failure with RestartSec=30 — so a four-field schedule turns the daemon into a 30-second crash cycle instead of one skipped job. msUntilNextDue at line 401 makes the same unguarded call.

2. Hang. parseField in lib.ts:156 accepts a zero step. matchesCron('*/0 * * * *', …) never terminates: the loop counter never advances while the values array grows without bound, so the event loop is blocked and the process OOMs. Reproduced by a run that had to be killed by timeout.

Neither requires an attacker. Both are what a mistyped schedule does.

The fix

Validate at config load. validateCron() checks field count, ranges, list and range forms, and step values. A job whose schedule fails validation is force-disabled at load with a message naming the job, the expression and the specific reason:

Disabling cron job typo-schedule: invalid schedule "0 9 * *" — need 5 fields (minute hour day-of-month month day-of-week), got 4
Disabling cron job zero-step: invalid schedule "*/0 * * * *" — minute field: step must be 1 or more in "*/0"

The daemon keeps running and keeps serving every other job.

Reject the pathological class, not the two reported cases. Zero and negative steps, out-of-range values, inverted ranges, wrong field counts, and empty schedules are all refused, including when they appear inside a list or attached to a range (*/0,*/0 * * * *, 1-5/0 * * * *).

Guard both evaluation call sites as a backstop. matchesCron/isDue still throw on an invalid expression, which is deliberate: an unvalidated expression reaching them is a programming error, not user input, and silently returning false would hide it. Both live call sites are wrapped so that if one ever does slip through, the job is disabled once and named, rather than taking the process out. The scheduler tracks those jobs so the message appears once instead of every tick, and the sleep-calculation path treats an unusable schedule as never due without re-reporting.

Tests

test/pulse/lib.test.ts, 53 cases:

53 pass
0 fail
107 expect() calls

Independently verified with a separate bounded probe — the previously-hanging expression is exercised first, so a regression would show as a timeout kill rather than a pass. It completed normally:

  • 11 pathological expressions all rejected with specific reasons, including zero step, negative step, out-of-range minute and hour, inverted range, 4 and 6 field counts, empty and whitespace-only, and zero step nested in a list or range
  • 7 valid forms still accepted: daily, step, monthly, weekday range, list, every-minute, every-N-hours
  • Firing semantics unchanged: matches on the right minute, does not match on the wrong one, matches on a step multiple and not off-step

Note for review

pulse-old.ts and pulse-unified.ts contain the same unguarded call sites and are deliberately left alone here — they are superseded dead code, and #1630 proposes deleting them. Worth confirming that is still the intent rather than my patching files that are on their way out.

A typo in the user-editable PULSE.toml could take the whole daemon down
in two different ways:

- Wrong field count ("0 9 * *") threw out of isDue(), outside the
  per-job try/catch that only wraps execution. The throw escaped the
  scheduler loop into main().catch -> process.exit(1), and both
  supervisors restart on a 30s throttle, so one bad job definition
  turned the dashboard into a 30-second crash cycle.
- Zero step ("*/0 * * * *") spun forever inside parseField: the loop
  counter never advanced while the values array grew without bound,
  blocking the event loop until the process ran out of memory.

Schedules are now validated by validateCron() as the config is read,
once, instead of throwing on every scheduler tick. An expression that
does not parse disables exactly one job and is reported with the job
name, the expression and the reason; the job stays in the list carrying
scheduleError so the dashboard can show what needs fixing. Everything
else keeps running.

The parser is now total and rejects the pathological class rather than
individual cases: zero and negative steps, out-of-range values,
inverted ranges, unparseable terms, oversized expressions and wrong
field counts. parseField can no longer loop. Day-of-week 7 and month
names, which used to parse into values that could never match, now fail
loudly at load instead of silently never firing. Both scheduler call
sites are guarded as a backstop: isDue() in the tick loop and
matchesCron() in msUntilNextDue().

Tests cover both failure modes plus the step, range, list and wildcard
forms the shipped config uses; the pathological-expression test runs in
a subprocess with a hard kill, so a regression fails the run instead of
hanging it.
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