Skip to content

Fix previousRuns() for months shorter than 31 days - #409

Merged
Hexagon merged 1 commit into
Hexagon:devfrom
cristianchiriac:fix-previous-runs-short-months
Oct 17, 2026
Merged

Hexagon merged 1 commit into
Hexagon:devfrom
cristianchiriac:fix-previous-runs-short-months

Conversation

@cristianchiriac

Copy link
Copy Markdown
Contributor

Fixes #408.

previousRuns() threw TypeError: Cannot read properties of undefined (reading '0') for patterns such as 0 9 * 4 *, 0 0 * 11 * and 0 0 29 2 * when the reference date was outside the allowed months. Two things in recurseBackward / apply caused it:

  • When the month moved back, the lower levels were reset to their pattern maximum, so the day became 31. apply() then rolled April 31 over into May 1 and returned true, and the recursion continued with doing - 1, which is -1 at the month level. The day is now capped at the last day of the current month during that reset.
  • apply() compared the day with DaysOfMonth[month], which is 28 for February, so a valid February 29 in a leap year was reported as out of bounds and took the same path. It now uses getLastDayOfMonth(year, month).

With the change the repro from the issue gives 2026-04-30T09:00, 2024-02-29T00:00 and 2025-11-30T00:00, the expected values.

Tests:

  • Added two cases to test/previous-runs.test.ts (April/November with * as the day, and leap days). They throw on dev and pass with this change.
  • I don't have Deno installed, so I ran the suite with the Node setup from AGENTS.md (npx jsr add @cross/test @std/assert, TZ=UTC npx tsx --test test/*.test.ts): 623 passed. I did not run deno fmt / deno lint / deno check.
  • As an extra check I ran previousRuns(1, ref) for 4320 combinations of month, day and weekday fields with random reference dates, and verified that nextRun(prev) is never before the reference (no skipped run). Valid patterns no longer throw.

Patterns that can never match (e.g. 0 0 31 4 *, 0 0 30 2 *) still throw from previousRuns(), as they did before. The backward search walks back year by year until the call stack runs out, while nextRun() returns null for them. I left that out of this PR, but I can look at it separately if you want.

When the backward search moved to an earlier month, the day was reset to
the pattern maximum (31), so apply() rolled e.g. April 31 over into May
and the recursion continued at level -1. Cap the day at the last day of
the month. apply() also reported a valid February 29 in a leap year as
out of bounds, which hit the same path for leap day patterns.

Fixes #408

@Hexagon Hexagon left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work - clean fix, good root-cause breakdown!

The call-stack exhaustion for impossible patterns you mentioned - would definitely appreciate a follow-up PR for that 👍


Update: Merged and published in 11.0.0-dev.5

@Hexagon
Hexagon merged commit a7332f3 into Hexagon:dev Oct 17, 2026
8 checks passed
dadezzz pushed a commit to dadezzz/events-cash-register that referenced this pull request Oct 30, 2026
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [croner](https://croner.56k.guru) ([source](https://github.com/Hexagon/croner)) | [`10.0.1` → `11.0.0`](https://renovatebot.com/diffs/npm/croner/10.0.1/11.0.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/croner/11.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/croner/10.0.1/11.0.0?slim=true) |

---

### Release Notes

<details>
<summary>Hexagon/croner (croner)</summary>

### [`v11.0.0`](https://github.com/Hexagon/croner/blob/HEAD/CHANGELOG.md#1100---2026-10-25)

[Compare Source](Hexagon/croner@10.0.1...11.0.0)

##### Breaking changes

- **DST handling aligned with croner-rust and Vixie cron, deviating from OCPS 1.4 §4.3.1**: jobs
  are either fixed-time jobs (one value each for seconds, minutes and hours) or interval jobs.
  - A fixed-time job in a DST gap (spring forward) runs at the first valid time after the gap. In
    10.x it ran as much later as the gap was long (`0 30 2 * * *` ran at 3:30 AM, now 3:00 AM).
  - An interval job runs on both occurrences of a time repeated by a DST overlap (fall back), so
    `* * * * *` keeps running through the repeated hour ([#&#8203;343](Hexagon/croner#343), [#&#8203;407](Hexagon/croner#407)). Patterns with more than one
    hour, minute or second, like `0 30 1,2 * * *`, are interval jobs.
  - Fixed-time jobs still run once, at the first occurrence, in a DST overlap.
- **`startAt` is inclusive**: a pattern match exactly at `startAt` now runs. Previously the first
  run was the following match. This does not apply with `interval` set, which works as in 10.x.
  A `startAt` between two whole seconds counts from the next whole second, and a run in the same
  second before a fractional `stopAt` (00:00:00 with `stopAt` 00:00:00.500) now runs. 10.x
  excluded it.
- **With `dayOffset`, `nextRun()`, `nextRuns()`, `previousRuns()`, `msToNext()` and `enumerate()`
  return actual run times**, searched from the reference, the same way the scheduler and
  `isRunning()` do. Previously they shifted the next pattern match after the reference, so a
  negative offset could return a time before the reference, and `msToNext()` ignored the offset.
  `startAt` and `stopAt` also apply to the shifted run time. In 10.x they applied to the unshifted
  pattern match, so a job could run before `startAt` or after `stopAt`.
- **`previousRun()` returns when the previous run started**, as documented. Previously it returned
  when the run finished.

##### Added

- `enumerate(startAt?)` method returning a stateful `CronIterator` that supports iterator/iterable
  protocols (`for...of`, destructuring, `next()`, `peek()`, `reset()`) and honors `maxRuns`
  ([#&#8203;365](Hexagon/croner#365)). The `CronIterator` class is exported.
- `allowPast` option for explicitly allowing date-based jobs scheduled more than 1 second in the
  past to fire immediately ([#&#8203;349](Hexagon/croner#349)).
- `CatchCallbackFn`, `CronMode` and `ProtectCallbackFn` are exported from the source entry point
  (JSR). The npm type declarations already exposed them.

##### Changed

- Cached `Intl.DateTimeFormat` instances per timezone, making timezone-aware scheduling noticeably
  faster (based on [#&#8203;389](Hexagon/croner#389)).
- Date-based jobs dated up to 1 second in the past now fire immediately, so a job created for
  "now" is not lost to the time it took to create it. Older dates fire only with `allowPast: true`.
  Previously a date-based job in the past never fired ([#&#8203;348](Hexagon/croner#348), [#&#8203;349](Hexagon/croner#349)).
- Clarified `dayOffset` JSDoc and completed missing option documentation, including iterator usage,
  `allowPast`, and DST behavior.
- Updated the copyright year to 2026.

##### Fixed

- Fixed timers being re-armed with negative delays throughout the repeated hour of a DST fall back
  ([#&#8203;399](Hexagon/croner#399)). `nextRun()` and `msToNext()` no longer return a time in the past when the reference is
  inside the repeated hour, and searches move in real time in both directions.
- Fixed `nextRuns()` going backwards or repeating times around a DST spring forward.
- Fixed DST handling in timezones with a 30-minute overlap (Australia/Lord_Howe) ([#&#8203;404](Hexagon/croner#404)).
- Fixed `msToNext()` returning a wrong (often negative) value when passed an ISO 8601 string on a
  job with `timezone` set. The string was read in system local time ([#&#8203;401](Hexagon/croner#401), [#&#8203;402](Hexagon/croner#402)).
- Fixed ISO 8601 strings being read in system local time on jobs with `utcOffset`, in `nextRun()`,
  `msToNext()`, `match()`, `startAt`, `stopAt` and one-off dates. Strings with `Z` or an offset,
  like `2026-01-01T00:00:00Z`, are read as that exact instant ([#&#8203;401](Hexagon/croner#401)).
- Fixed `startAt` given as a `Date` being searched in system local time on jobs with `utcOffset`.
- Fixed `previousRuns()` throwing for patterns with month-specific wildcards (e.g., `0 9 * 4 *`)
  when the reference date falls outside allowed months, and for leap-day patterns (`0 0 29 2 *`)
  in leap years ([#&#8203;408](Hexagon/croner#408), [#&#8203;409](Hexagon/croner#409)).
- Fixed patterns that can never match: `previousRuns()` threw a `TypeError` for patterns like
  `0 0 30 2 *`, and `nextRun()`, `nextRuns()` and `previousRuns()` threw `Maximum call stack size
  exceeded` for `0 0 16 * 5#L` with `domAndDow: true`. They now return `null` and an empty list.
- Fixed scheduling after system clock changes: occurrences silently skipped after forward clock
  steps ([#&#8203;388](Hexagon/croner#388), [#&#8203;393](Hexagon/croner#393)), timer and target disagreeing after backward clock steps ([#&#8203;417](Hexagon/croner#417)), and timer
  targets for `dayOffset` schedules and protected jobs ([#&#8203;395](Hexagon/croner#395)).
- Fixed `isRunning()`, `nextRun()` and `msToNext()` disagreeing with the scheduler when
  `dayOffset` is set.
- Fixed `TimeoutNegativeWarning` when a timer delay was negative. Negative delays are now clamped
  to 0 ([#&#8203;370](Hexagon/croner#370), [#&#8203;369](Hexagon/croner#369)).
- Fixed `?` in day-of-month or day-of-week behaving differently from `*`: `0 0 ? * MON` ran every
  day instead of every Monday ([#&#8203;392](Hexagon/croner#392)).
- Fixed `nextRun()`, `nextRuns()`, `msToNext()` and scheduling throwing a `TypeError` when a
  schedule with `startAt` and `interval` has no runs left ([#&#8203;390](Hexagon/croner#390)).
- Fixed `RangeError` when using stepping with year field wildcard. `*/2` in the year field matches
  even years ([#&#8203;373](Hexagon/croner#373), [#&#8203;374](Hexagon/croner#374)).
- Fixed the `@deprecated` JSDoc tag being on `dayOffset` instead of `legacyMode`.
- Fixed ISO 8601 strings losing their milliseconds on jobs without `timezone`: a one-off date like
  `2030-01-01T00:00:00.700Z` ran at `.000`, and strings and `Date`s could differ in `stopAt`,
  one-off dates, `nextRun()` and `msToNext()`. Also fixed a one-off date given as a string, later
  in the same second as `startAt`, never running. Jobs with a named `timezone` still work in whole
  seconds, as in 10.x.

##### CI / Build

- Updated workflow and packaging metadata around publish/test setup. The published package
  metadata now comes from the committed `package.json`; `build/package.template.json` is removed.
- Switched declaration bundling from esbuild to rollup with rollup-plugin-dts, restoring dist
  parity with previous releases.
- Development is now Node.js-first: `package.json` is committed and is the source of truth for
  version, scripts and dev dependencies, the build script runs on Node.js (`npm run build`), and
  tests run with `npm test`. Deno is still supported (`deno task test`, fmt/lint, JSR publishing),
  and `npm version` keeps `deno.json` in sync.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xOTIuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE5Mi4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
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.

2 participants