Skip to content

[JSC] Date setters: range-check year + floor(month / 12) as MakeDay does, not each argument - #616

Open
robobun wants to merge 1 commit into
mainfrom
robobun/7dff7f6b/date-setters-makeday
Open

[JSC] Date setters: range-check year + floor(month / 12) as MakeDay does, not each argument#616
robobun wants to merge 1 commit into
mainfrom
robobun/7dff7f6b/date-setters-makeday

Conversation

@robobun

@robobun robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • The Date year/month setters return NaN once the year argument alone (or month / 12 alone) is outside ±275760: new Date(0).setUTCFullYear(275761, -12, 1) is NaN, but Date.UTC(275761, -12, 1) is 8639977881600000. V8 returns the date.
  • MakeDay range-checks only year + floor(month / 12). fillStructuresUsingDateArgs() (runtime/DatePrototype.cpp) bounds years and months / 12 separately and stores them as int (upstream 295562@main added the bounds so that toInt32() cannot wrap).

Fix

  • BrokenDownDate fields become double. The setters store their arguments unchanged, and toTimeValue() runs the makeDay / makeTime / makeDate helpers the Date constructor and Date.UTC use, then localTimeToMS() and timeClip().
  • Correct because makeDay() adds year and floor(month / 12) as doubles before it requires an int32, so nothing wraps, and timeClip() still rejects an out-of-range sum. The day count goes into makeDay(), not into the milliseconds, so a setter equals Date.UTC exactly even when |days × msPerDay| > 2^53.
  • makeDay() also rejects a month remainder outside [0, 12). Only |month| ≥ 2^53 produces one, and it overflowed an int in dateToDaysFrom1970().
  • Verified: JSTests/stress/date-setters-year-month-range.js (new) fails unpatched, passes patched in 8 time zones and under V8. test262 Date directories: 1802 pass, 0 new failures. Self-reviewed: 2 concerns raised, 2 addressed.

Background

  • A time value is milliseconds from the epoch, at most ±8.64e15 (years -271821 to 275760). timeClip() maps anything beyond to NaN.
  • MakeDay normalises (ym = y + floor(m / 12), mn = m mod 12), then adds date - 1 days. A month of -12 or a negative day count pulls an out-of-range year back in.
  • BrokenDownDate is a setter's scratch copy of the decomposed this date, with the argument fields overwritten.
Notes
  • Ledger cases, patched jsc vs. before: new Date(0).setUTCFullYear(275761, -12, 1) 8639977881600000 (was NaN); new Date(0).setFullYear(300000, -300000, 1) = new Date(300000, -300000, 1) (was NaN); new Date(Date.UTC(-1, 0, 1)).setUTCMonth(12 * 275761) 8639977881600000 (was NaN); new Date(NaN).setUTCFullYear(-271821, 3600000) 827076182400000 (was NaN); controls unchanged.
  • setYear loses its copy of the per-argument bound too. No observable change: |year| > 275760 with an in-range month and day is always out of range.
  • Precision: the old code folded the day argument into the milliseconds as days * msPerDay. With a this that has a milliseconds part and |days| > ~1.04e8 that sum rounds, e.g. new Date(8.64e15 - 999).setUTCDate(-199000000) was -8553601036801000 instead of -8553601036800999. Passing days as MakeDay's date keeps every intermediate an exact integer.
  • For every input the old code accepted, the result is bit-identical otherwise: the day number and the h/m/s part are the same exact integers in both formulas, and JSC builds with -ffp-contract=off.
  • Differential check: 20,000 seeded random setter calls (all 15 setters, bases at and around ±8.64e15, arguments up to 1e300, ±Infinity, NaN, fractions) give byte-identical output to V8 (Node 26) under TZ=UTC, Asia/Kathmandu and Australia/Lord_Howe. Under America/New_York and Pacific/Apia the only differences are pre-existing ones within a day of -8.64e15 (last note), identical on the unpatched build. Every UTC setter result also equals Date.UTC() of the resulting fields.
  • The whole JSTests/stress collection plus LayoutTests/jsc-layout-tests.yaml ran in all configurations against the patched shell: 27,176 of 27,319 runs pass. The 143 failures (24 files, e.g. eval-func-decl-*, function-toString-native-one-line.js, intl-datetimeformat-default-timezone-change.js) fail identically on the unpatched jsc built from the same tree, so they are pre-existing in this configuration.
  • The new stress test also passes with --useJIT=0, --useDFGJIT=0 and eager tier-up thresholds, and runs in about 10 ms.
  • DateCache::gregorianDateTimeToMS() has no caller left in this tree but stays: Bun calls it from src/jsc/bindings/bindings.cpp (Bun__gregorianDateTimeToMS).
  • JSC accepts any normalised year that fits an int32 and lets timeClip() decide, as its Date.UTC always has. V8 caps |year| at 1e6 and |month| at 1e7 first, so new Date(0).setUTCFullYear(2e9, 0, -730484269514) is 2000-01-01 here (and by the spec's exact arithmetic) but NaN in V8.
  • The makeDay() month check: for |month| < 2^53, floor(month / 12) and the remainder are exact, so the remainder is always in [0, 11] and the check never fires. Beyond that the remainder can come out as e.g. 16 or -8, which dateToDaysFrom1970() folds into the year again in int arithmetic (Date.UTC(-9007197107257351, 108086391056891980, 784353026671) returned a year-2000 date through an INT32_MAX + 1 wrap). The Temporal callers always pass a month in [0, 11].
  • Pre-existing and not touched here: in zones west of UTC, a local-time setter on a this within hours of the minimum time value returns NaN where V8 keeps the date (TZ=America/New_York, new Date(-8.64e15).setMilliseconds(10)), because the local-to-UTC conversion of an out-of-range local time uses an "equivalent" modern year's offset. That is [JSC] Date: convert a local time to UTC with the zone's own offset when the local time is outside the time value range #615, which touches JSDateMath.cpp / WTF/DateMath.cpp only, so the two PRs do not overlap.
  • Upstream WebKit main has the same fillStructuresUsingDateArgs(), so the patch applies there unchanged.

…oes, not each argument

Date.prototype.setFullYear, setUTCFullYear, setMonth and setUTCMonth build
their result with MakeDate(MakeDay(y, m, dt), TimeWithinDay(t)). MakeDay
range-checks ym = y + floor(m / 12). It never looks at y or m alone. So
new Date(0).setUTCFullYear(275761, -12, 1) is 275760-01-01T00:00:00Z, the
value Date.UTC(275761, -12, 1) gives, and so is
new Date(Date.UTC(-1, 0, 1)).setUTCMonth(12 * 275761).

fillStructuresUsingDateArgs() instead required |years| <= 275760 and
|months / 12| <= 275760 separately (295562@main and 296644@main added those
bounds so that toInt32() of a huge argument could not wrap into range) and
stored both as int. Every call above returned NaN, while the Date constructor
and Date.UTC in the same engine, which go through makeDay() with doubles,
returned the in-range time value. V8 and SpiderMonkey agree with the
constructor.

Make every BrokenDownDate field a double, store the setter arguments in it as
they are (finite or not in range), and compute the result with the same
makeDate(makeDay(), makeTime()) -> localTimeToMS() -> timeClip() steps the
constructor and Date.UTC use. makeDay() combines year and month in double
arithmetic and only then requires the normalized year to be an int32, so
nothing wraps, and a normalized year that is really out of range still ends up
NaN through timeClip(). The day argument now goes into makeDay() as MakeDay's
date instead of being pre-multiplied by msPerDay into the milliseconds, so a
setter is exact to the millisecond (and identical to Date.UTC / the
constructor) even when |days * msPerDay| exceeds 2^53. Before,
new Date(8.64e15 - 999).setUTCDate(-199000000) was 1 ms off. setYear drops its
copy of the per-argument bound too. That changes nothing observable, because a
|year| > 275760 with an in-range month and day is always out of range.

makeDay() additionally rejects a month remainder outside [0, 12). That can only
happen when |month| >= 2^53 makes the year/month split inexact. It used to hand
dateToDaysFrom1970() such a month, which folds it into the year once more in
int arithmetic: a wrong date at best, and a signed overflow when the year is
already INT32_MAX. The setters could not reach that before because of the bound
above. Date.UTC always could. The Temporal callers pass a month in [0, 11].

* JSTests/stress/date-setters-year-month-range.js: Added.
* Source/JavaScriptCore/runtime/DateConstructor.h:
(JSC::makeDay):
* Source/JavaScriptCore/runtime/DatePrototype.cpp:
(JSC::BrokenDownDate::toTimeValue const):
(JSC::fillStructuresUsingTimeArgs):
(JSC::fillStructuresUsingDateArgs):
(JSC::setNewValueFromTimeArgs):
(JSC::setNewValueFromDateArgs):
(JSC::JSC_DEFINE_HOST_FUNCTION):
@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction (any time zone for the UTC lines):

// repro.js: TZ=UTC bun repro.js / node repro.js / jsc repro.js
const p = typeof print === "function" ? print : console.log;
p(new Date(0).setUTCFullYear(275761, -12, 1), Date.UTC(275761, -12, 1));      // bun 1.4.3: NaN 8639977881600000   patched, node: 8639977881600000 twice
p(new Date(0).setFullYear(300000, -300000, 1));                               // bun 1.4.3: NaN   patched, node: 8615994624000000 (TZ=UTC)
p(new Date(Date.UTC(-1, 0, 1)).setUTCMonth(12 * 275761));                     // bun 1.4.3: NaN   patched, node: 8639977881600000
p(new Date(NaN).setUTCFullYear(-271821, 3600000));                            // bun 1.4.3: NaN   patched, node: 827076182400000
p(new Date(8.64e15 - 999).setUTCDate(-199000000));                            // bun 1.4.3: -8553601036801000   patched, node: -8553601036800999
p(new Date(0).setUTCFullYear(275760, -12, 1), new Date(0).setUTCFullYear(275761, 0, 1)); // controls, unchanged: 8639946345600000 NaN

How I verified, with a Release JSCOnly build of this branch (clang 21, x86_64) and a jsc built from the same tree without the patch:

  • JSTests/stress/date-setters-year-month-range.js: unpatched fails at its first setter assertion (expected 8639977881600000 but got NaN), patched passes under TZ=UTC, America/New_York, America/Los_Angeles, Pacific/Apia, Asia/Kathmandu, Australia/Lord_Howe, Pacific/Chatham and Africa/Casablanca, with --useJIT=0, --useDFGJIT=0 and eager thresholds. Node 26 passes it too.
  • test262 built-ins/Date, annexB/built-ins/Date, intl402/Date, intl402/DateTimeFormat, staging/sm/Date: 1802 expected passes, 0 new failures.
  • run-jsc-stress-tests over JSTests/stress and LayoutTests/jsc-layout-tests.yaml: 27,176 of 27,319 pass. The 143 failing runs (24 files) fail identically on the unpatched shell.
  • 20,000-call seeded differential against Node 26 over all 15 setters: identical output under TZ=UTC, Asia/Kathmandu and Australia/Lord_Howe. The New York and Apia differences are the pre-existing [JSC] Date: convert a local time to UTC with the zone's own offset when the local time is outside the time value range #615 cases.

Bun side: oven-sh/bun#42193 pins autobuild-preview-pr-616-5e00ef59 and adds test/js/bun/jsc/date-setters-year-month-range.test.ts (fails 3 of 3 on bun 1.4.3, passes with the preview).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. Because it changes spec-conformance numerics for all Date setters and tightens the shared makeDay() helper (also used by Date.UTC, the Date constructor, and Temporal), and Source/JavaScriptCore / JSTests are CODEOWNER-covered, a human look is still worthwhile.

What was reviewed:

  • BrokenDownDate field widening to double and the new toTimeValue() path — recomposition now goes through makeDay/makeTime/makeDatelocalTimeToMStimeClip, matching the constructor path in DateConstructor.cpp.
  • Per-argument std::isfinite checks in fillStructuresUsingTimeArgs/DateArgs — each argument is still individually gated, so Infinity, -Infinity combinations still yield NaN as the spec requires.
  • The added monthInt32 < 0 || monthInt32 > 11 guard in makeDay() — for |month| < 2^53 the remainder is exact and in [0,11], so no regression for existing in-range callers; Temporal call sites pass a normalized month.
  • The new stress test throws on failure, does no logging, and covers int32-wrap, 2^53 day-count precision, and non-finite arguments.
Extended reasoning...

Overview

This PR reworks how Date.prototype.set{FullYear,Month,Date,Hours,Minutes,Seconds,Milliseconds,Year} (and UTC variants) rebuild the time value in Source/JavaScriptCore/runtime/DatePrototype.cpp. BrokenDownDate fields become double, the two fillStructuresUsing*Args helpers now just store arguments and check finiteness, and a new BrokenDownDate::toTimeValue() runs the spec's MakeDate(MakeDay(...), MakeTime(...))localTimeToMStimeClip sequence. DateConstructor.h's makeDay() gains a guard rejecting a month remainder outside [0, 11], which only occurs when |month| ≥ 2^53. A new JSTests/stress/date-setters-year-month-range.js exercises boundary years, huge months/days, int32-wrap cases, and non-finite arguments.

Security risks

None identified. This is pure numeric/spec-conformance work in Date runtime host functions. The int32 truncations that were removed were previously guarded by ad-hoc range checks; the replacement path keeps everything in double until makeDay()'s existing yearInt32 != ym check, and timeClip() still bounds the final result. No memory, auth, or injection surface is touched.

Level of scrutiny

Moderate-to-high. The change is small in line count and the recomposition now literally reuses the same helpers the Date constructor and Date.UTC already use, which is a strong correctness argument. However, it is a behavioral change to a widely used built-in, the makeDay() tweak is shared with Date.UTC/new Date()/Temporal callers, and floating-point edge-case reasoning (2^53 splits, day×msPerDay precision) is subtle enough that a JSC reviewer should sign off. .github/CODEOWNERS routes both Source/JavaScriptCore and JSTests to WebKit/jsc-reviewers.

Other factors

The PR description reports no new test262 failures, differential fuzzing against V8 across several time zones, and that the new stress test fails unpatched and passes patched under all JIT tiers in ~10 ms — consistent with JSTests conventions. DateCache::gregorianDateTimeToMS is intentionally left in place for the external Bun binding. Nothing in the timeline indicates outstanding third-party objections.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c85ddd43-d597-4e23-b42c-e208688a428a

📥 Commits

Reviewing files that changed from the base of the PR and between dfd6964 and 5e00ef5.

📒 Files selected for processing (3)
  • JSTests/stress/date-setters-year-month-range.js
  • Source/JavaScriptCore/runtime/DateConstructor.h
  • Source/JavaScriptCore/runtime/DatePrototype.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

Date setters now retain numeric date fields through shared MakeDay/MakeTime normalization and time clipping. makeDay rejects invalid converted months. Stress tests cover range handling, precision, overflow, non-finite inputs, and setter results.

Date setter range handling

Layer / File(s) Summary
Shared date normalization
Source/JavaScriptCore/runtime/DateConstructor.h, Source/JavaScriptCore/runtime/DatePrototype.cpp
Date fields now retain double values, validate finite inputs, normalize through MakeDay and MakeTime, and support local or UTC conversion with timeClip.
Setter conversion integration
Source/JavaScriptCore/runtime/DatePrototype.cpp
Date setters and setYear use the shared conversion path. They preserve fractional values and reject non-finite arguments.
Range behavior validation
JSTests/stress/date-setters-year-month-range.js
Stress tests cover boundary normalization, constructor equivalence, precision, overflow, large values, non-finite arguments, setYear, and stored setter results.

Merge Risk: ⚪ Minimal · up to 5e00e

Date setters now normalize and clip values consistently with the Date constructor and Date.UTC, with expanded boundary and overflow coverage. The change is ready to merge.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives a detailed problem statement, fix explanation, affected behavior, and validation results. However, it does not follow the repository template because it omits the required Bugzil… Add the associated Bugzilla URL, include the required review line such as "Reviewed by NOBODY (OOPS!).", and list each changed path with the relevant functions or classes using the repository template.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the JavaScriptCore Date setter change and the shared MakeDay-based year/month range handling.
Full details: Description check

Explanation

The description gives a detailed problem statement, fix explanation, affected behavior, and validation results. However, it does not follow the repository template because it omits the required Bugzilla bug link, Reviewed by NOBODY line, and explicit changed-file/function list.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
5e00ef59 autobuild-preview-pr-616-5e00ef59 2026-09-10 10:20:13 UTC

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