Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mark BalancePossiblyInfiniteTimeDuration and CreateTimeDurationRecord as infallible #2611

Merged
merged 3 commits into from
Aug 10, 2023

Conversation

anba
Copy link
Contributor

@anba anba commented Jun 19, 2023

4edfed1 can be seen as either normative or editorial. For actual implementations it's definitely just an editorial change.

Copy link
Collaborator

@ptomato ptomato left a comment

Choose a reason for hiding this comment

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

Thanks. I am trying to get rid of all possibly-infinite loops as part of the last revision putting an upper bound on duration times, but I didn't realize the loops in NanosecondsToDays could go infinitely.

Do you have any suggestions for limiting these?

Comment on lines 1356 to 1341
1. If 𝔽(_days_) is not finite, then
1. If _days_ > 0, return ~positive overflow~.
1. Else, return ~negative overflow~.
1. Let _balanceResult_ be ! BalancePossiblyInfiniteTimeDuration(0, 0, 0, 0, 0, 0, _result_.[[Nanoseconds]], _largestUnit_).
1. If _balanceResult_ is ~positive overflow~ or ~negative overflow~, return _balanceResult_.
1. Return ? CreateTimeDurationRecord(_days_, _balanceResult_.[[Hours]], _balanceResult_.[[Minutes]], _balanceResult_.[[Seconds]], _balanceResult_.[[Milliseconds]], _balanceResult_.[[Microseconds]], _balanceResult_.[[Nanoseconds]]).
1. Return ! CreateTimeDurationRecord(_days_, _balanceResult_.[[Hours]], _balanceResult_.[[Minutes]], _balanceResult_.[[Seconds]], _balanceResult_.[[Milliseconds]], _balanceResult_.[[Microseconds]], _balanceResult_.[[Nanoseconds]]).
Copy link
Collaborator

Choose a reason for hiding this comment

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

Oof, I didn't clock that the loop above this could be made infinite with a special calendar/time zone combination.

I think that in built-in time zones and calendars it's only possible for the loop body to execute once, because days would be at most one day longer if start was at, say, 23:59 and end was at 00:00. Is that your understanding as well?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I did a bit more digging into this. In the existing test suite, the above loop is only ever executed at most once, except in tests that you wrote which were contrived specifically for this loop. However, the comment in the polyfill code suggests that it could loop twice for built-ins under some circumstances:

If clock time after addition was in the middle of a skipped period, the endpoint was disambiguated to a later clock time. So it's possible that the resulting disambiguated result is later than endNs. If so, then back up one day and try again. Repeat if necessary (some transitions are > 24 hours) until either there's zero days left or the date duration is 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.

So it seems that either the comment is wrong, or we don't have coverage in test262 for that case. I wonder if it's safe to throw after 2 iterations or there could still be some situation we don't know about with built-in time zones / calendars, or non-contrived use cases with custom time zones or calendars. I believe this code was originally written by @justingrant who may have some insight?

The second loop, in step 13, is executed at most twice in test262 (also not counting the contrived tests). Here also I wonder if we could cut it off after 2 iterations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The test case for 4edfed1 iterates over the second loop in NanosecondsToDays (step 17).

The comment you've mentioned from the polyfill implementation of the first loop of NanosecondsToDays (step 13) mentions that transitions >24h are possible:

// If clock time after addition was in the middle of a skipped period, the
// endpoint was disambiguated to a later clock time. So it's possible that
// the resulting disambiguated result is later than endNs. If so, then back
// up one day and try again. Repeat if necessary (some transitions are
// > 24 hours) until either there's zero days left or the date duration is
// 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.

This doesn't seem to be the case for built-in time zones. Executing this test shows a couple of 24h transitions, but no transitions exceeding 24 hours. (Note: The polyfill implementation shows fewer transitions than the SM/V8 implementations, because transitions before 1847-01-01 are ignored in the polyfill, cf. BEFORE_FIRST_DST variable.)

const start = Temporal.Instant.from("1800-01-01T00:00:00Z");
const end = Temporal.Instant.from("2100-01-01T00:00:00Z");
const nanosPerDay = Temporal.Duration.from("P1D").total("nanoseconds");
const oneWeek = Temporal.Duration.from("P7D").round({largestUnit: "hours"});

for (let id of Intl.supportedValuesOf("timeZone")) {
  let tz = new Temporal.TimeZone(id);

  let t = start;
  while (true) {
    let next = tz.getNextTransition(t);
    if (next === null || Temporal.Instant.compare(next, end) >= 0) {
      break;
    }
    t = next;

    // Don't test directly at the transition point.
    let before = tz.getOffsetNanosecondsFor(t.subtract(oneWeek));
    let at = tz.getOffsetNanosecondsFor(t.add(oneWeek));

    let diff = before - at;
    if (diff === nanosPerDay || diff === -nanosPerDay) {
      console.log("24h:", id, next, before, at);
    }
    if (diff > nanosPerDay || diff < -nanosPerDay) {
      console.log(">24h:", id, next, before, at);
    }
  }
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess the stock exchange time zone use case would require the ability to have a transition >24h.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess the stock exchange time zone use case would require the ability to have a transition >24h.

This is correct. I think it'd be fine to impose some kind of limit on transitions, but 24 hours seems too short for a limit because it forecloses cases like the stock exchange zone. I could possibly imagine time zones being used for similar cases like school schedules, e.g. grades are assigned to an "instructional day" which can't exist during summer break in the school calendar.

So if we do impose a limit (which I agree would be a reasonable thing to do), then maybe be conservative and set the limit to something like a year (maybe 500 days to accommodate hypothetical weird lunisolar calendars)?

Copy link
Collaborator

Choose a reason for hiding this comment

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

In theory I agree, but now I'm not sure that it really forecloses these cases! I made a local modification which would throw if either of these loops iterated more than once, and the stock exchange time zone continued to work fine. Even when I added more arithmetic:

More stock exchange tests
// Monday lasts 24 hours
const monday = Temporal.ZonedDateTime.from('2022-08-22T09:30[America/New_York]').withTimeZone(tzNYSE);
assert.equal(monday.hoursInDay, 24);

// Friday lasts 72 hours, until Monday starts again
const friday = monday.add({ days: 4 });
assert.equal(friday.hoursInDay, 72);

// Adding 1 day to Friday gets you the next Monday (disambiguates forward)
assert.equal(friday.add({ days: 1 }).toString(), '2022-08-29T09:30:00-04:00[NYSE]');
// Adding 3 days to Friday also gets you the next Monday
assert.equal(friday.add({ days: 3 }).toString(), '2022-08-29T09:30:00-04:00[NYSE]');

const nextMonday = monday.add({ weeks: 1 });

// Subtracting 1 day from Monday gets you the same day (disambiguates forward)
assert.equal(nextMonday.subtract({ days: 1 }).toString(), '2022-08-29T09:30:00-04:00[NYSE]');
// Subtracting 3 days from Monday gets you the previous Friday
assert.equal(nextMonday.subtract({ days: 3 }).toString(), '2022-08-26T09:30:00-04:00[NYSE]');

// Difference between Friday and Monday is 72 hours or 3 days
const fridayUntilMonday = friday.until(nextMonday);
assert.equal(fridayUntilMonday.toString(), 'PT72H');
assert.equal(fridayUntilMonday.total('hours'), 72);
assert.equal(fridayUntilMonday.total('days'), 3);

const mondaySinceFriday = nextMonday.since(friday);
assert.equal(mondaySinceFriday.toString(), 'PT72H');
assert.equal(mondaySinceFriday.total('hours'), 72);
assert.equal(mondaySinceFriday.total('days'), 3);

// One week is still 7 days
const oneWeek = Temporal.Duration.from({ weeks: 1 });
assert.equal(oneWeek.total({ unit: 'days', relativeTo: monday }), 7);
assert.equal(oneWeek.total({ unit: 'days', relativeTo: friday }), 7);

So how about this. I'd be in favour of accepting this PR for now, including the check for infinite days which I'd assume is not normative since as Anba points out, it doesn't affect implementations.

Then, in my normative PR for removing loops and BigInt arithmetic, which I'm planning to open today, I'll put upper limits on these loops that throw if iterated more than once, which is a normative change. If we find a non-contrived use case that throws there, or find that it precludes one of our existing use cases after all, then we can always withdraw that commit from the PR, or put a limit of 365 or 500 instead. Hopefully the public code review gets us many eyes on it 😄

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, weird. Not sure why those tests work (and can't check right now) but if they work then I agree with you!

Copy link
Collaborator

Choose a reason for hiding this comment

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

I added it to the agenda for the next Temporal meeting - I won't be there though.

spec/duration.html Outdated Show resolved Hide resolved
spec/duration.html Outdated Show resolved Hide resolved
spec/duration.html Outdated Show resolved Hide resolved
@codecov
Copy link

codecov bot commented Jun 19, 2023

Codecov Report

Merging #2611 (d11b2de) into main (c895534) will decrease coverage by 0.08%.
The diff coverage is n/a.

❗ Current head d11b2de differs from pull request most recent head 1bd6156. Consider uploading reports for the commit 1bd6156 to get more accurate results

@@            Coverage Diff             @@
##             main    #2611      +/-   ##
==========================================
- Coverage   96.06%   95.98%   -0.08%     
==========================================
  Files          20       20              
  Lines       11553    11573      +20     
  Branches     2195     2198       +3     
==========================================
+ Hits        11098    11108      +10     
- Misses        391      401      +10     
  Partials       64       64              

see 8 files with indirect coverage changes

Copy link
Collaborator

@ptomato ptomato left a comment

Choose a reason for hiding this comment

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

If the second commit is indeed not normative, could you remove the "Normative:" from the commit message before merging?

@anba
Copy link
Contributor Author

anba commented Jun 20, 2023

If the second commit is indeed not normative, could you remove the "Normative:" from the commit message before merging?

It's normative when reading the spec text with a pure mathematical mindset, because just based on the math, it's possible to iterate from 0 to 10292. Physically it's impossible to do so, though.

@justingrant
Copy link
Collaborator

What's the latest on this PR? It's been open for a while.

…finiteTimeDuration

Infinite values are handled explicitly and return an overflow indicator
and all components have the same sign. Therefore it's not possible for
CreateTimeDurationRecord to throw.
…nRelative

Return an overflow marker when `𝔽(days)` is infinite. Strictly speaking
a normative change, but can only happen when there are over 1e292
iterations in NanosecondsToDays.

When passing a finite `𝔽(days)` to CreateTimeDurationRecord,
CreateTimeDurationRecord can't fail anymore, so it's now marked as
infallible.

Example how to trigger infinite days in theory:
```js
let cal = new class extends Temporal.Calendar {
  #dateUntil = 0;

  dateUntil(one, two, options) {
    if (this.#dateUntil++ === 0) {
      return Temporal.Duration.from({days: Number.MAX_VALUE})
    }
    return super.dateUntil(one, two, options);
  }

  #dateAdd = 0;

  dateAdd(date, duration, options) {
    if (this.#dateAdd++ === 0) {
      return date;
    }
    if (duration.days > 1) {
      return date;
    }
    return super.dateAdd(date, duration, options)
  }
}("iso8601");

let tz = new class extends Temporal.TimeZone {
  #getPossibleInstantsFor = 0n;

  getPossibleInstantsFor(dateTime) {
    if (this.#getPossibleInstantsFor++ < 10n**292n) {
      return [new Temporal.Instant(0n)];
    }
    return super.getPossibleInstantsFor(dateTime);
  }
}("UTC");

let zdt = new Temporal.ZonedDateTime(0n, tz, cal);
let d = Temporal.Duration.from({nanoseconds: 1});
let r = d.total({unit: "days", relativeTo: zdt})
```
…ion values

All callers to CreateTimeDurationRecord pass valid duration values, so
we can assert this in CreateTimeDurationRecord instead of throwing an
error.
@ptomato
Copy link
Collaborator

ptomato commented Aug 10, 2023

I made the change to the commit message and will now merge it.

@ptomato ptomato merged commit 768bc99 into tc39:main Aug 10, 2023
5 checks passed
moz-v2v-gh pushed a commit to mozilla/gecko-dev that referenced this pull request Nov 7, 2023
vinnydiehl pushed a commit to vinnydiehl/mozilla-unified that referenced this pull request Nov 7, 2023
gecko-dev-updater pushed a commit to marco-c/gecko-dev-wordified-and-comments-removed that referenced this pull request Nov 8, 2023
Implement the changes from <tc39/proposal-temporal#2611>.

Differential Revision: https://phabricator.services.mozilla.com/D189774

UltraBlame original commit: e7645dffd1704374adb6c2eab5b2e46523b99104
gecko-dev-updater pushed a commit to marco-c/gecko-dev-wordified that referenced this pull request Nov 8, 2023
Implement the changes from <tc39/proposal-temporal#2611>.

Differential Revision: https://phabricator.services.mozilla.com/D189774

UltraBlame original commit: e7645dffd1704374adb6c2eab5b2e46523b99104
gecko-dev-updater pushed a commit to marco-c/gecko-dev-comments-removed that referenced this pull request Nov 8, 2023
Implement the changes from <tc39/proposal-temporal#2611>.

Differential Revision: https://phabricator.services.mozilla.com/D189774

UltraBlame original commit: e7645dffd1704374adb6c2eab5b2e46523b99104
@anba anba deleted the time-duration branch September 27, 2024 08:06
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.

3 participants