Skip to content

fix: Keep one unusable event date from stopping all background events - #4120

Draft
MajorLift wants to merge 7 commits into
mainfrom
fix/cronjob-isolate-unusable-dates
Draft

MajorLift wants to merge 7 commits into
mainfrom
fix/cronjob-isolate-unusable-dates

Conversation

@MajorLift

@MajorLift MajorLift commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • A background event whose date cannot be parsed stops all background-event scheduling. #startTimer computes NaN milliseconds, both of its guards (ms > DAILY_TIMEOUT and ms <= 0) are false against NaN, and it reaches new Timer(NaN), which throws TypeError: Can't start a timer with NaN time.
  • That throw escapes #reschedule's loop. Every event after the bad one in iteration order is never scheduled, init throws, and because the daily timer's callback is #reschedule(); #start();, the re-arm is skipped too, so scheduling stops for the rest of the session.
  • In production that date is null, and getExecutionDate produces it. A duration is valid however large it is, so ISO8601DurationStruct accepts something like P1000000Y; adding it to the current time overflows the timestamp, which marks the DateTime invalid, and toISO() returns null for an invalid DateTime. plus keeps the static type valid, so nothing on that path is typed as nullable and the null reaches state as the event's date.
  • Two changes. getExecutionDate throws rather than returning null, so the value is never stored. #startTimer throws a descriptive error for a non-finite interval, and #reschedule catches per event and reports through logError, so an unusable date already in state cannot strand the others.

Observed in Sentry as METAMASK-XREK, METAMASK-YQT5 and METAMASK-ZXFV: 15 events in the 90 days to 2026-09-16, on extension 13.35.1 through 13.47.0, Chrome MV3 and Firefox MV2.

MetaMask/snaps#3373 (properly catch cronjob errors during initialization) handled the asynchronous case, a Snap rejecting during the daily check-in. This is the synchronous one: a throw from scheduling itself, which that catch does not reach.

MetaMask/snaps#4107 (reduce cronjob write churn by persisting dates separately) carries the containment changes alongside a breaking addition to CronjobControllerStateManager. They are independent of that addition and non-breaking, so this lands them without it; #4107 can drop them once this merges.

Test plan

  • New test throws an error for durations past the representable date range: P1000000Y and PT99999999999999S throw instead of returning null.
  • Negative control for it: with the guard reverted, the test fails on the missing throw; restoring the guard passes it, so the assertion is load-bearing.
  • Existing test schedules the remaining events when one of them has an unusable date: two stored events, the first unusable. init does not throw, and the healthy event executes while the broken one does not.
  • Negative control for that one: with the source reverted to main, it fails with TypeError: Can't start a timer with NaN time escaping init.
  • 35 tests pass across both cronjob suites. eslint and prettier clean on the changed files.
  • Package suite run with coverage: no changes in coverage percentages detected, so coverage.json is unchanged.

An unparseable `date` makes `#startTimer` compute `NaN` milliseconds. Both of
its guards are false against `NaN`, so it reaches `new Timer(NaN)`, which
throws, and the throw escapes `#reschedule`'s loop: every event after it goes
unscheduled, `init` throws, and the daily timer's re-arm is skipped.

`#startTimer` now throws a descriptive error for an unusable date, and
`#reschedule` catches per event and reports through `logError`.
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.60%. Comparing base (7e3d201) to head (7ea8e3b).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4120   +/-   ##
=======================================
  Coverage   98.59%   98.60%           
=======================================
  Files         429      429           
  Lines       12497    12503    +6     
  Branches     1976     1977    +1     
=======================================
+ Hits        12322    12328    +6     
  Misses        175      175           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

// one, so a single bad date takes down all scheduling rather than itself.
// A client is expected to repair dates before handing state over; this is
// the backstop for one that does not.
if (Number.isNaN(ms)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if (Number.isNaN(ms)) {
if (!Number.isFinite(ms)) {

Perhaps we can check that the result is finite in general?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed here: fc52dcc

Comment on lines +421 to +425
throw new Error(
`Background event "${event.id}" has an unusable date: "${String(
event.date,
)}".`,
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of throwing should we just return;?

`DateTime.plus` is typed `(duration) => this`, so adding a duration to a valid
`DateTime` yields a statically valid one even where the sum lands past the
representable range and `toISO()` returns `null` at runtime. Routing the sum
through a helper whose return type names that possibility turns the missing
check into a type error at the call site rather than a `null` in state.
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