Skip to content

Commit

Permalink
Normative: Limit duration years, months, and weeks to <2³² each
Browse files Browse the repository at this point in the history
In order to prevent having to use bigint arithmetic, limit years, months,
and weeks to 32 bits each in durations.

There are more changes to the reference code than to the spec in this
commit because the upper limit now allows us to rewrite the reference
code's RoundDuration algorithm in a way that's more similar to how it was
already specified in the spec text.
  • Loading branch information
ptomato committed Nov 16, 2023
1 parent 6ec8654 commit 0620aed
Show file tree
Hide file tree
Showing 3 changed files with 62 additions and 35 deletions.
84 changes: 54 additions & 30 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3663,6 +3663,9 @@ export function RejectDuration(y, mon, w, d, h, min, s, ms, µs, ns) {
const propSign = MathSign(prop);
if (propSign !== 0 && propSign !== sign) throw new RangeError('mixed-sign values not allowed as duration fields');
}
if (MathAbs(y) >= 2 ** 32 || MathAbs(mon) >= 2 ** 32 || MathAbs(w) >= 2 ** 32) {
throw new RangeError('years, months, and weeks must be < 2³²');
}
if (!NumberIsSafeInteger(d * 86400 + h * 3600 + min * 60 + s + MathTrunc(ms / 1e3 + µs / 1e6 + ns / 1e9))) {
throw new RangeError('total of duration time units cannot exceed 9007199254740991.999999999 s');
}
Expand Down Expand Up @@ -5032,6 +5035,49 @@ export function RoundNumberToIncrement(quantity, increment, mode) {
return quotient.multiply(increment);
}

export function RoundJSNumberToIncrement(quantity, increment, mode) {
let quotient = MathTrunc(quantity / increment);
const remainder = quantity % increment;
if (remainder === 0) return quantity;
const sign = remainder < 0 ? -1 : 1;
const tiebreaker = MathAbs(remainder * 2);
const tie = tiebreaker === increment;
const expandIsNearer = tiebreaker > increment;
switch (mode) {
case 'ceil':
if (sign > 0) quotient += sign;
break;
case 'floor':
if (sign < 0) quotient += sign;
break;
case 'expand':
// always expand if there is a remainder
quotient += sign;
break;
case 'trunc':
// no change needed, because divmod is a truncation
break;
case 'halfCeil':
if (expandIsNearer || (tie && sign > 0)) quotient += sign;
break;
case 'halfFloor':
if (expandIsNearer || (tie && sign < 0)) quotient += sign;
break;
case 'halfExpand':
// "half up away from zero"
if (expandIsNearer || tie) quotient += sign;
break;
case 'halfTrunc':
if (expandIsNearer) quotient += sign;
break;
case 'halfEven': {
if (expandIsNearer || (tie && quotient % 2 === 1)) quotient += sign;
break;
}
}
return quotient * increment;
}

export function RoundInstant(epochNs, increment, unit, roundingMode) {
let { remainder } = NonNegativeBigIntDivmod(epochNs, DAY_NANOS);
const wholeDays = epochNs.minus(remainder);
Expand Down Expand Up @@ -5345,20 +5391,10 @@ export function RoundDuration(
const oneYear = new TemporalDuration(days < 0 ? -1 : 1);
let { days: oneYearDays } = MoveRelativeDate(calendarRec, plainRelativeTo, oneYear);

// Note that `nanoseconds` below (here and in similar code for months,
// weeks, and days further below) isn't actually nanoseconds for the
// full date range. Instead, it's a BigInt representation of total
// days multiplied by the number of nanoseconds in the last day of
// the duration. This lets us do days-or-larger rounding using BigInt
// math which reduces precision loss.
oneYearDays = MathAbs(oneYearDays);
if (oneYearDays === 0) throw new RangeError('custom calendar reported that a year is 0 days long');
const divisor = bigInt(oneYearDays).multiply(dayLengthNs);
const nanoseconds = divisor.multiply(years).plus(bigInt(days).multiply(dayLengthNs)).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment).toJSNumber(), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
years = rounded.divide(divisor).toJSNumber();
total = years + (days + norm.fdiv(dayLengthNs)) / oneYearDays;
years = RoundJSNumberToIncrement(total, increment, roundingMode);
months = weeks = days = 0;
norm = TimeDuration.ZERO;
break;
Expand Down Expand Up @@ -5402,12 +5438,8 @@ export function RoundDuration(

oneMonthDays = MathAbs(oneMonthDays);
if (oneMonthDays === 0) throw new RangeError('custom calendar reported that a month is 0 days long');
const divisor = bigInt(oneMonthDays).multiply(dayLengthNs);
const nanoseconds = divisor.multiply(months).plus(bigInt(days).multiply(dayLengthNs)).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
months = rounded.divide(divisor).toJSNumber();
total = months + (days + norm.fdiv(dayLengthNs)) / oneMonthDays;
months = RoundJSNumberToIncrement(total, increment, roundingMode);
weeks = days = 0;
norm = TimeDuration.ZERO;
break;
Expand Down Expand Up @@ -5441,23 +5473,15 @@ export function RoundDuration(

oneWeekDays = MathAbs(oneWeekDays);
if (oneWeekDays === 0) throw new RangeError('custom calendar reported that a week is 0 days long');
const divisor = bigInt(oneWeekDays).multiply(dayLengthNs);
const nanoseconds = divisor.multiply(weeks).plus(bigInt(days).multiply(dayLengthNs)).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
weeks = rounded.divide(divisor).toJSNumber();
total = weeks + (days + norm.fdiv(dayLengthNs)) / oneWeekDays;
weeks = RoundJSNumberToIncrement(total, increment, roundingMode);
days = 0;
norm = TimeDuration.ZERO;
break;
}
case 'day': {
const divisor = bigInt(dayLengthNs);
const nanoseconds = divisor.multiply(days).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
days = rounded.divide(divisor).toJSNumber();
total = days + norm.fdiv(dayLengthNs);
days = RoundJSNumberToIncrement(total, increment, roundingMode);
norm = TimeDuration.ZERO;
break;
}
Expand Down
10 changes: 5 additions & 5 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ const durationHoursFraction = withCode(fraction, (data, result) => {
data.nanoseconds = Math.trunc(ns % 1e3) * data.factor;
});

const digitsNotInfinite = withSyntaxConstraints(oneOrMore(digit()), (result) => {
if (!Number.isFinite(+result)) throw new SyntaxError('try again on infinity');
const uint32Digits = withSyntaxConstraints(between(1, 10, digit()), (result) => {
if (+result >= 2 ** 32) throw new SyntaxError('try again for an uint32');
});
const timeDurationDigits = (factor) =>
withSyntaxConstraints(between(1, 16, digit()), (result) => {
Expand All @@ -387,17 +387,17 @@ const durationDays = seq(
daysDesignator
);
const durationWeeks = seq(
withCode(digitsNotInfinite, (data, result) => (data.weeks = +result * data.factor)),
withCode(uint32Digits, (data, result) => (data.weeks = +result * data.factor)),
weeksDesignator,
[durationDays]
);
const durationMonths = seq(
withCode(digitsNotInfinite, (data, result) => (data.months = +result * data.factor)),
withCode(uint32Digits, (data, result) => (data.months = +result * data.factor)),
monthsDesignator,
[choice(durationWeeks, durationDays)]
);
const durationYears = seq(
withCode(digitsNotInfinite, (data, result) => (data.years = +result * data.factor)),
withCode(uint32Digits, (data, result) => (data.years = +result * data.factor)),
yearsDesignator,
[choice(durationMonths, durationWeeks, durationDays)]
);
Expand Down
3 changes: 3 additions & 0 deletions spec/duration.html
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,9 @@ <h1>
1. If 𝔽(_v_) is not finite, return *false*.
1. If _v_ &lt; 0 and _sign_ &gt; 0, return *false*.
1. If _v_ &gt; 0 and _sign_ &lt; 0, return *false*.
1. If abs(_years_) &ge; 2<sup>32</sup>, return *false*.
1. If abs(_months_) &ge; 2<sup>32</sup>, return *false*.
1. If abs(_weeks_) &ge; 2<sup>32</sup>, return *false*.
1. Let _normalizedSeconds_ be _days_ &times; 86,400 + _hours_ &times; 3600 + _minutes_ &times; 60 + _seconds_ + _milliseconds_ &times; 10<sup>-3</sup> + _microseconds_ &times; 10<sup>-6</sup> + _nanoseconds_ &times; 10<sup>-9</sup>.
1. If abs(_normalizedSeconds_) &ge; 2<sup>53</sup>, return *false*.
1. Return *true*.
Expand Down

0 comments on commit 0620aed

Please sign in to comment.