Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
| // 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)) { |
Member
There was a problem hiding this comment.
Suggested change
| if (Number.isNaN(ms)) { | |
| if (!Number.isFinite(ms)) { |
Perhaps we can check that the result is finite in general?
Comment on lines
+421
to
+425
| throw new Error( | ||
| `Background event "${event.id}" has an unusable date: "${String( | ||
| event.date, | ||
| )}".`, | ||
| ); |
Member
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
datecannot be parsed stops all background-event scheduling.#startTimercomputesNaNmilliseconds, both of its guards (ms > DAILY_TIMEOUTandms <= 0) are false againstNaN, and it reachesnew Timer(NaN), which throwsTypeError: Can't start a timer with NaN time.#reschedule's loop. Every event after the bad one in iteration order is never scheduled,initthrows, 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.null, andgetExecutionDateproduces it. A duration is valid however large it is, soISO8601DurationStructaccepts something likeP1000000Y; adding it to the current time overflows the timestamp, which marks theDateTimeinvalid, andtoISO()returnsnullfor an invalidDateTime.pluskeeps the static type valid, so nothing on that path is typed as nullable and thenullreaches state as the event's date.getExecutionDatethrows rather than returningnull, so the value is never stored.#startTimerthrows a descriptive error for a non-finite interval, and#reschedulecatches per event and reports throughlogError, so an unusable date already in state cannot strand the others.Observed in Sentry as
METAMASK-XREK,METAMASK-YQT5andMETAMASK-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
throws an error for durations past the representable date range:P1000000YandPT99999999999999Sthrow instead of returningnull.schedules the remaining events when one of them has an unusable date: two stored events, the first unusable.initdoes not throw, and the healthy event executes while the broken one does not.main, it fails withTypeError: Can't start a timer with NaN timeescapinginit.eslintandprettierclean on the changed files.coverage.jsonis unchanged.