Skip to content

Commit

Permalink
Normative: Limit time portion of durations to <2⁵³ seconds
Browse files Browse the repository at this point in the history
In order to avoid unbounded integer arithmetic, we place an upper bound on
the total of the time portion of a duration (days through nanoseconds).

For the purpose of determining the limits, days are always 24 hours, even
if a calendar day may be a different number of hours relative to a
particular ZonedDateTime.

It's now no longer possible to make Duration.prototype.total() come up
with an infinite result; removing the loops in UnbalanceDurationRelative
made it so that the duration's calendar units must be able to be added to
a relativeTo date without overflowing, and this change makes it so that
the duration's time units are too small to overflow to infinity either.
  • Loading branch information
ptomato committed Jan 22, 2024
1 parent 591de66 commit d92a492
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 173 deletions.
15 changes: 4 additions & 11 deletions polyfill/lib/duration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,6 @@ export class Duration {
calendarRec
));
// If the unit we're totalling is smaller than `days`, convert days down to that unit.
let balanceResult;
if (zonedRelativeTo) {
const intermediate = ES.MoveRelativeZonedDateTime(
zonedRelativeTo,
Expand All @@ -493,7 +492,7 @@ export class Duration {
0,
precalculatedPlainDateTime
);
balanceResult = ES.BalancePossiblyInfiniteTimeDurationRelative(
({ days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = ES.BalanceTimeDurationRelative(
days,
hours,
minutes,
Expand All @@ -504,9 +503,9 @@ export class Duration {
unit,
intermediate,
timeZoneRec
);
));
} else {
balanceResult = ES.BalancePossiblyInfiniteTimeDuration(
({ days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = ES.BalanceTimeDuration(
days,
hours,
minutes,
Expand All @@ -515,14 +514,8 @@ export class Duration {
microseconds,
nanoseconds,
unit
);
}
if (balanceResult === 'positive overflow') {
return Infinity;
} else if (balanceResult === 'negative overflow') {
return -Infinity;
));
}
({ days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = balanceResult);
// Finally, truncate to the correct unit and calculate remainder
const { total } = ES.RoundDuration(
years,
Expand Down
115 changes: 29 additions & 86 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const MathSign = Math.sign;
const MathTrunc = Math.trunc;
const NumberIsFinite = Number.isFinite;
const NumberIsNaN = Number.isNaN;
const NumberIsSafeInteger = Number.isSafeInteger;
const NumberMaxSafeInteger = Number.MAX_SAFE_INTEGER;
const ObjectCreate = Object.create;
const ObjectDefineProperty = Object.defineProperty;
Expand Down Expand Up @@ -58,6 +59,7 @@ import OwnPropertyKeys from 'es-abstract/helpers/OwnPropertyKeys.js';
import some from 'es-abstract/helpers/some.js';

import { GetIntrinsic } from './intrinsicclass.mjs';
import { TruncatingDivModByPowerOf10 } from './math.mjs';
import { CalendarMethodRecord, TimeZoneMethodRecord } from './methodrecord.mjs';
import {
CreateSlots,
Expand Down Expand Up @@ -3294,11 +3296,10 @@ export function NanosecondsToDays(nanoseconds, zonedRelativeTo, timeZoneRec, pre
// back inside the period where it belongs. Note that this case only can
// happen for positive durations because the only direction that
// `disambiguation: 'compatible'` can change clock time is forwards.
days = bigInt(days);
if (sign === 1) {
while (days.greater(0) && relativeResult.epochNs.greater(endNs)) {
days = days.prev();
relativeResult = AddDaysToZonedDateTime(start, dtStart, timeZoneRec, calendar, days.toJSNumber());
while (days > 0 && relativeResult.epochNs.greater(endNs)) {
days--;
relativeResult = AddDaysToZonedDateTime(start, dtStart, timeZoneRec, calendar, days);
// may do disambiguation
}
}
Expand All @@ -3321,10 +3322,10 @@ export function NanosecondsToDays(nanoseconds, zonedRelativeTo, timeZoneRec, pre
if (isOverflow) {
nanoseconds = nanoseconds.subtract(dayLengthNs);
relativeResult = oneDayFarther;
days = days.add(sign);
days += sign;
}
} while (isOverflow);
if (!days.isZero() && MathSign(days.toJSNumber()) != sign) {
if (days !== 0 && MathSign(days) != sign) {
throw new RangeError('Time zone or calendar converted nanoseconds into a number of days with the opposite sign');
}
if (!nanoseconds.isZero() && MathSign(nanoseconds.toJSNumber()) != sign) {
Expand All @@ -3336,7 +3337,7 @@ export function NanosecondsToDays(nanoseconds, zonedRelativeTo, timeZoneRec, pre
if (nanoseconds.abs().geq(MathAbs(dayLengthNs))) {
throw new Error('assert not reached');
}
return { days: days.toJSNumber(), nanoseconds, dayLengthNs: MathAbs(dayLengthNs) };
return { days, nanoseconds, dayLengthNs: MathAbs(dayLengthNs) };
}

export function BalanceTimeDuration(
Expand All @@ -3348,33 +3349,6 @@ export function BalanceTimeDuration(
microseconds,
nanoseconds,
largestUnit
) {
let result = BalancePossiblyInfiniteTimeDuration(
days,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
largestUnit
);
if (result === 'positive overflow' || result === 'negative overflow') {
throw new RangeError('Duration out of range');
} else {
return result;
}
}

export function BalancePossiblyInfiniteTimeDuration(
days,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
largestUnit
) {
hours = bigInt(hours).add(bigInt(days).multiply(24));
nanoseconds = TotalDurationNanoseconds(hours, minutes, seconds, milliseconds, microseconds, nanoseconds);
Expand Down Expand Up @@ -3434,21 +3408,7 @@ export function BalancePossiblyInfiniteTimeDuration(
microseconds = microseconds.toJSNumber() * sign;
nanoseconds = nanoseconds.toJSNumber() * sign;

if (
!NumberIsFinite(days) ||
!NumberIsFinite(hours) ||
!NumberIsFinite(minutes) ||
!NumberIsFinite(seconds) ||
!NumberIsFinite(milliseconds) ||
!NumberIsFinite(microseconds) ||
!NumberIsFinite(nanoseconds)
) {
if (sign === 1) {
return 'positive overflow';
} else if (sign === -1) {
return 'negative overflow';
}
}
RejectDuration(0, 0, 0, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds);
return { days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds };
}

Expand All @@ -3464,38 +3424,6 @@ export function BalanceTimeDurationRelative(
zonedRelativeTo,
timeZoneRec,
precalculatedPlainDateTime
) {
let result = BalancePossiblyInfiniteTimeDurationRelative(
days,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
largestUnit,
zonedRelativeTo,
timeZoneRec,
precalculatedPlainDateTime
);
if (result === 'positive overflow' || result === 'negative overflow') {
throw new RangeError('Duration out of range');
}
return result;
}

export function BalancePossiblyInfiniteTimeDurationRelative(
days,
hours,
minutes,
seconds,
milliseconds,
microseconds,
nanoseconds,
largestUnit,
zonedRelativeTo,
timeZoneRec,
precalculatedPlainDateTime
) {
const startNs = GetSlot(zonedRelativeTo, EPOCHNANOSECONDS);
const startInstant = GetSlot(zonedRelativeTo, INSTANT);
Expand Down Expand Up @@ -3526,11 +3454,17 @@ export function BalancePossiblyInfiniteTimeDurationRelative(
days = 0;
}

const result = BalancePossiblyInfiniteTimeDuration(0, 0, 0, 0, 0, 0, nanoseconds, largestUnit);
if (result === 'positive overflow' || result === 'negative overflow') {
return result;
}
({ hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = result);
({ hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = BalanceTimeDuration(
0,
0,
0,
0,
0,
0,
nanoseconds,
largestUnit
));

return { days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds };
}

Expand Down Expand Up @@ -3761,6 +3695,14 @@ 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');
}
const msResult = TruncatingDivModByPowerOf10(ms, 3);
const µsResult = TruncatingDivModByPowerOf10(µs, 6);
const nsResult = TruncatingDivModByPowerOf10(ns, 9);
const remainderSec = TruncatingDivModByPowerOf10(msResult.mod * 1e6 + µsResult.mod * 1e3 + nsResult.mod, 9).div;
const totalSec = d * 86400 + h * 3600 + min * 60 + s + msResult.div + µsResult.div + nsResult.div + remainderSec;
if (!NumberIsSafeInteger(totalSec)) {
throw new RangeError('total of duration time units cannot exceed 9007199254740991.999999999 s');
}
}

export function DifferenceISODate(y1, m1, d1, y2, m2, d2, largestUnit = 'days') {
Expand Down Expand Up @@ -5867,6 +5809,7 @@ export function RoundDuration(
break;
}
}
RejectDuration(years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds);
return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, total };
}

Expand Down
32 changes: 32 additions & 0 deletions polyfill/lib/math.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const MathAbs = Math.abs;
const MathLog10 = Math.log10;
const MathSign = Math.sign;
const MathTrunc = Math.trunc;
const NumberParseInt = Number.parseInt;
const NumberPrototypeToPrecision = Number.prototype.toPrecision;
const StringPrototypeSlice = String.prototype.slice;

import Call from 'es-abstract/2022/Call.js';

// Computes trunc(x / 10**p) and x % 10**p, returning { div, mod }, with
// precision loss only once in the quotient, by string manipulation. If the
// quotient and remainder are safe integers, then they are exact. x must be an
// integer. p must be a non-negative integer. Both div and mod have the sign of
// x.
export function TruncatingDivModByPowerOf10(x, p) {
if (x === 0) return { div: x, mod: x }; // preserves signed zero

const sign = MathSign(x);
x = MathAbs(x);

const xDigits = MathTrunc(1 + MathLog10(x));
if (p >= xDigits) return { div: sign * 0, mod: sign * x };
if (p === 0) return { div: sign * x, mod: sign * 0 };

// would perform nearest rounding if x was not an integer:
const xStr = Call(NumberPrototypeToPrecision, x, [xDigits]);
const div = sign * NumberParseInt(Call(StringPrototypeSlice, xStr, [0, xDigits - p]), 10);
const mod = sign * NumberParseInt(Call(StringPrototypeSlice, xStr, [xDigits - p]), 10);

return { div, mod };
}
3 changes: 3 additions & 0 deletions polyfill/test/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import './datemath.mjs';
// tests of internals, not suitable for test262
import './ecmascript.mjs';

// Power-of-10 math
import './math.mjs';

Promise.resolve()
.then(() => {
return Demitasse.report(Pretty.reporter);
Expand Down
69 changes: 69 additions & 0 deletions polyfill/test/math.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Demitasse from '@pipobscure/demitasse';
const { describe, it, report } = Demitasse;

import Pretty from '@pipobscure/demitasse-pretty';
const { reporter } = Pretty;

import { strict as assert } from 'assert';
const { deepEqual } = assert;

import { TruncatingDivModByPowerOf10 as div } from '../lib/math.mjs';

describe('Math', () => {
describe('TruncatingDivModByPowerOf10', () => {
it('12345/10**0 = 12345, 0', () => deepEqual(div(12345, 0), { div: 12345, mod: 0 }));
it('12345/10**1 = 1234, 5', () => deepEqual(div(12345, 1), { div: 1234, mod: 5 }));
it('12345/10**2 = 123, 45', () => deepEqual(div(12345, 2), { div: 123, mod: 45 }));
it('12345/10**3 = 12, 345', () => deepEqual(div(12345, 3), { div: 12, mod: 345 }));
it('12345/10**4 = 1, 2345', () => deepEqual(div(12345, 4), { div: 1, mod: 2345 }));
it('12345/10**5 = 0, 12345', () => deepEqual(div(12345, 5), { div: 0, mod: 12345 }));
it('12345/10**6 = 0, 12345', () => deepEqual(div(12345, 6), { div: 0, mod: 12345 }));

it('-12345/10**0 = -12345, -0', () => deepEqual(div(-12345, 0), { div: -12345, mod: -0 }));
it('-12345/10**1 = -1234, -5', () => deepEqual(div(-12345, 1), { div: -1234, mod: -5 }));
it('-12345/10**2 = -123, -45', () => deepEqual(div(-12345, 2), { div: -123, mod: -45 }));
it('-12345/10**3 = -12, -345', () => deepEqual(div(-12345, 3), { div: -12, mod: -345 }));
it('-12345/10**4 = -1, -2345', () => deepEqual(div(-12345, 4), { div: -1, mod: -2345 }));
it('-12345/10**5 = -0, -12345', () => deepEqual(div(-12345, 5), { div: -0, mod: -12345 }));
it('-12345/10**6 = -0, -12345', () => deepEqual(div(-12345, 6), { div: -0, mod: -12345 }));

it('0/10**27 = 0, 0', () => deepEqual(div(0, 27), { div: 0, mod: 0 }));
it('-0/10**27 = -0, -0', () => deepEqual(div(-0, 27), { div: -0, mod: -0 }));

it('1001/10**3 = 1, 1', () => deepEqual(div(1001, 3), { div: 1, mod: 1 }));
it('-1001/10**3 = -1, -1', () => deepEqual(div(-1001, 3), { div: -1, mod: -1 }));

it('4019125567429664768/10**3 = 4019125567429664, 768', () =>
deepEqual(div(4019125567429664768, 3), { div: 4019125567429664, mod: 768 }));
it('-4019125567429664768/10**3 = -4019125567429664, -768', () =>
deepEqual(div(-4019125567429664768, 3), { div: -4019125567429664, mod: -768 }));
it('3294477463410151260160/10**6 = 3294477463410151, 260160', () =>
deepEqual(div(3294477463410151260160, 6), { div: 3294477463410151, mod: 260160 }));
it('-3294477463410151260160/10**6 = -3294477463410151, -260160', () =>
deepEqual(div(-3294477463410151260160, 6), { div: -3294477463410151, mod: -260160 }));
it('7770017954545649059889152/10**9 = 7770017954545649, 59889152', () =>
deepEqual(div(7770017954545649059889152, 9), { div: 7770017954545649, mod: 59889152 }));
it('-7770017954545649059889152/-10**9 = -7770017954545649, -59889152', () =>
deepEqual(div(-7770017954545649059889152, 9), { div: -7770017954545649, mod: -59889152 }));

// Largest/smallest representable float that will result in a safe quotient,
// for each of the divisors 10**3, 10**6, 10**9
it('9007199254740990976/10**3 = MAX_SAFE_INTEGER-1, 976', () =>
deepEqual(div(9007199254740990976, 3), { div: Number.MAX_SAFE_INTEGER - 1, mod: 976 }));
it('-9007199254740990976/10**3 = -MAX_SAFE_INTEGER+1, -976', () =>
deepEqual(div(-9007199254740990976, 3), { div: -Number.MAX_SAFE_INTEGER + 1, mod: -976 }));
it('9007199254740990951424/10**6 = MAX_SAFE_INTEGER-1, 951424', () =>
deepEqual(div(9007199254740990951424, 6), { div: Number.MAX_SAFE_INTEGER - 1, mod: 951424 }));
it('-9007199254740990951424/10**6 = -MAX_SAFE_INTEGER+1, -951424', () =>
deepEqual(div(-9007199254740990951424, 6), { div: -Number.MAX_SAFE_INTEGER + 1, mod: -951424 }));
it('9007199254740990926258176/10**9 = MAX_SAFE_INTEGER-1, 926258176', () =>
deepEqual(div(9007199254740990926258176, 9), { div: Number.MAX_SAFE_INTEGER - 1, mod: 926258176 }));
it('-9007199254740990926258176/10**9 = -MAX_SAFE_INTEGER+1, -926258176', () =>
deepEqual(div(-9007199254740990926258176, 9), { div: -Number.MAX_SAFE_INTEGER + 1, mod: -926258176 }));
});
});

import { normalize } from 'path';
if (normalize(import.meta.url.slice(8)) === normalize(process.argv[1])) {
report(reporter).then((failed) => process.exit(failed ? 1 : 0));
}
12 changes: 8 additions & 4 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -364,22 +364,26 @@ const durationHoursFraction = withCode(fraction, (data, result) => {
const digitsNotInfinite = withSyntaxConstraints(oneOrMore(digit()), (result) => {
if (!Number.isFinite(+result)) throw new SyntaxError('try again on infinity');
});
const timeDurationDigits = (factor) =>
withSyntaxConstraints(between(1, 16, digit()), (result) => {
if (!Number.isSafeInteger(+result * factor)) throw new SyntaxError('try again on unsafe integer');
});
const durationSeconds = seq(
withCode(digitsNotInfinite, (data, result) => (data.seconds = +result * data.factor)),
withCode(timeDurationDigits(1), (data, result) => (data.seconds = +result * data.factor)),
[durationSecondsFraction],
secondsDesignator
);
const durationMinutes = seq(
withCode(digitsNotInfinite, (data, result) => (data.minutes = +result * data.factor)),
withCode(timeDurationDigits(60), (data, result) => (data.minutes = +result * data.factor)),
choice(seq(minutesDesignator, [durationSeconds]), seq(durationMinutesFraction, minutesDesignator))
);
const durationHours = seq(
withCode(digitsNotInfinite, (data, result) => (data.hours = +result * data.factor)),
withCode(timeDurationDigits(3600), (data, result) => (data.hours = +result * data.factor)),
choice(seq(hoursDesignator, [choice(durationMinutes, durationSeconds)]), seq(durationHoursFraction, hoursDesignator))
);
const durationTime = seq(timeDesignator, choice(durationHours, durationMinutes, durationSeconds));
const durationDays = seq(
withCode(digitsNotInfinite, (data, result) => (data.days = +result * data.factor)),
withCode(timeDurationDigits(86400), (data, result) => (data.days = +result * data.factor)),
daysDesignator
);
const durationWeeks = seq(
Expand Down
Loading

0 comments on commit d92a492

Please sign in to comment.