Change VM DateTime to-local-time computation to not fail for <1h changes.

The DateTime conversion to local time amounts to solving an
equation, finding an offset where the current local time is that offset.

The current approach doesn't actually do that, it just tries to simulate
the JS approach, with a tweak to avoid DST change issues, but that tweak
assumes that all time zone adjustments are one hour changes.

This CL should work correctly for time zone changes of up to one hour,
but might still fail for changes of more than one hour.
It works by trying more offsets, which obviously has a performance cost,
which depends on how efficient the operation system is at providing
the local time zone for a specific milliseconds-since-epoch.

Change-Id: I80dc6e62e0639d9966d3c5a06430787d8acc4ff1
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/182041
Commit-Queue: Lasse R.H. Nielsen <lrn@google.com>
Reviewed-by: Nate Bosch <nbosch@google.com>
This commit is contained in:
Lasse R.H. Nielsen 2021-04-08 16:51:00 +00:00 committed by commit-bot@chromium.org
parent 3131d5a6c5
commit 6af4987dc6
4 changed files with 506 additions and 39 deletions

View file

@ -1,3 +1,13 @@
## 2.14.0
### Core libraries
#### `dart:core`
* The native `DateTime` class now better handles local time around
daylight saving changes that are not precisely one hour.
(No change on the Web which uses the JavaScript `Date` object.)
## 2.13.0
### Language

View file

@ -17,6 +17,7 @@ class DateTime {
static int _timeZoneOffsetInSecondsForClampedSeconds(int secondsSinceEpoch)
native "DateTime_timeZoneOffsetInSeconds";
// Daylight-savings independent adjustment for the local time zone.
static int _localTimeZoneAdjustmentInSeconds()
native "DateTime_localTimeZoneAdjustmentInSeconds";
@ -304,47 +305,19 @@ class DateTime {
millisecond * Duration.microsecondsPerMillisecond +
microsecond;
// Since [_timeZoneOffsetInSeconds] will crash if the input is far out of
// the valid range we do a preliminary test that weeds out values that can
// not become valid even with timezone adjustments.
// The timezone adjustment is always less than a day, so adding a security
// margin of one day should be enough.
if (microsecondsSinceEpoch.abs() >
_maxMillisecondsSinceEpoch * 1000 + Duration.microsecondsPerDay) {
return null;
}
if (!isUtc) {
// Note that we can't literally follow the ECMAScript spec (which this
// code is based on), because it leads to incorrect computations at
// the DST transition points.
//
// See V8's comment here:
// https://github.com/v8/v8/blob/089dd7d2447d6eaf57c8ba6d8f37957f3a269777/src/date.h#L118
// Since [_timeZoneOffsetInSeconds] will crash if the input is far out of
// the valid range we do a preliminary test that weeds out values that can
// not become valid even with timezone adjustments.
// The timezone adjustment is always less than a day, so adding a security
// margin of one day should be enough.
if (microsecondsSinceEpoch.abs() >
_maxMillisecondsSinceEpoch * Duration.microsecondsPerMillisecond +
Duration.microsecondsPerDay) {
return null;
}
// We need to remove the local timezone adjustment before asking for the
// correct zone offset.
int adjustment =
_localTimeZoneAdjustmentInSeconds() * Duration.microsecondsPerSecond;
// The adjustment is independent of the actual date and of the daylight
// saving time. It is positive east of the Prime Meridian and negative
// west of it, e.g. -28800 sec for America/Los_Angeles timezone.
// We remove one hour to ensure that we have the correct offset at
// DST transitioning points. This is a temporary solution and only
// correct in timezones that shift for exactly one hour.
adjustment += Duration.microsecondsPerHour;
int zoneOffset =
_timeZoneOffsetInSeconds(microsecondsSinceEpoch - adjustment);
// The zoneOffset depends on the actual date and reflects any daylight
// saving time and/or historical deviation relative to UTC time.
// It is positive east of the Prime Meridian and negative west of it,
// e.g. -25200 sec for America/Los_Angeles timezone during DST.
microsecondsSinceEpoch -= zoneOffset * Duration.microsecondsPerSecond;
// The resulting microsecondsSinceEpoch value is therefore the calculated
// UTC value decreased by a (positive if east of GMT) timezone adjustment
// and decreased by typically one hour if DST is in effect.
microsecondsSinceEpoch -= _toLocalTimeOffset(microsecondsSinceEpoch);
}
if (microsecondsSinceEpoch.abs() >
_maxMillisecondsSinceEpoch * Duration.microsecondsPerMillisecond) {
@ -443,4 +416,108 @@ class DateTime {
int equivalentSeconds = _equivalentSeconds(microsecondsSinceEpoch);
return _timeZoneNameForClampedSeconds(equivalentSeconds);
}
/// Finds the local time corresponding to a UTC date and time.
///
/// The [microsecondsSinceEpoch] represents a particular
/// calendar date and clock time in UTC.
/// This methods returns a (usually different) point in time
/// where the local time had the same calendar date and clock
/// time (if such a time exists, otherwise it finds the "best"
/// substitute).
///
/// A valid result is a point in time `microsecondsSinceEpoch - offset`
/// where the local time zone offset is `+offset`.
///
/// In some cases there are two valid results, due to a time zone
/// change setting the clock back (for example exiting from daylight
/// saving time). In that case, we return the *earliest* valid result.
///
/// In some cases there are no valid results, due to a time zone
/// change setting the clock forward (for example entering daylight
/// saving time). In that case, we return the time which would have
/// been correct in the earlier time zone (so asking for 2:30 AM
/// when clocks move directly from 2:00 to 3:00 will give the
/// time that *would have been* 2:30 in the earlier time zone,
/// which is now 3:30 in the local time zone).
///
/// Returns the point in time as a number of microseconds since epoch.
static int _toLocalTimeOffset(int microsecondsSinceEpoch) {
// Argument is the UTC time corresponding to the desired
// calendar date/wall time.
// We now need to find an UTC time where the difference
// from `microsecondsSinceEpoch` is the same as the
// local time offset at that time. That is, we want to
// find `adjustment` in microseconds such that:
//
// _timeZoneOffsetInSeconds(microsecondsSinceEpoch - offset)
// * Duration.microsecondsPerSecond == offset
//
// Such an offset might not exist, if that wall time
// is skipped when a time zone change moves the clock forwards.
// In that case we pick a time after the switch which would be
// correct in the previous time zone.
// Also, there might be more than one solution if a time zone
// change moves the clock backwards and the same wall clock
// time occurs twice in the same day.
// In that case we pick the one in the time zone prior to
// the switch.
// Start with the time zone at the current microseconds since
// epoch. It's within one day of the real time we're looking for.
int offset = _timeZoneOffsetInSeconds(microsecondsSinceEpoch) *
Duration.microsecondsPerSecond;
// If offset is 0 (we're right around the UTC+0, and)
// we have found one solution.
if (offset != 0) {
// If not, try to find an actual solution in the time zone
// we just discovered.
int offset2 = _timeZoneOffsetInSeconds(microsecondsSinceEpoch - offset) *
Duration.microsecondsPerSecond;
if (offset2 != offset) {
// Also not a solution. We have found a second time zone
// within the same day. We assume that's all there are.
// Try again with the new time zone.
int offset3 =
_timeZoneOffsetInSeconds(microsecondsSinceEpoch - offset2) *
Duration.microsecondsPerSecond;
// Either offset3 is a solution (equal to offset2),
// or we have found two different time zones and no solution.
// In the latter case we choose the lower offset (latter time).
return (offset2 <= offset3 ? offset2 : offset3);
}
// We have found one solution and one time zone.
offset = offset2;
}
// Try to see if there is an earlier time zone which also
// has a solution.
// Pretends time zone changes are always at most two hours.
// (Double daylight saving happened, fx, in part of Canada in 1988).
int offset4 = _timeZoneOffsetInSeconds(microsecondsSinceEpoch -
offset -
2 * Duration.microsecondsPerHour) *
Duration.microsecondsPerSecond;
if (offset4 > offset) {
// The time zone at the earlier time had a greater
// offset, so it's possible that the desired wall clock
// occurs in that time zone too.
if (offset4 == offset + 2 * Duration.microsecondsPerHour) {
// A second and earlier solution, so use that.
return offset4;
}
// The time zone differs one hour earlier, but not by one
// hour, so check again in that time zone.
int offset5 = _timeZoneOffsetInSeconds(microsecondsSinceEpoch - offset4) *
Duration.microsecondsPerSecond;
if (offset5 == offset4) {
// Found a second solution earlier than the first solution, so use that.
return offset4;
}
}
// Did not find a solution in the earlier time
// zone, so just use the original result.
return offset;
}
}

View file

@ -0,0 +1,190 @@
// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import "package:expect/expect.dart";
// Tests that local DateTime constructor works correctly around
// time zone changes.
void main() {
// Find two points in time with different time zones.
// Search linearly back from 2020-01-01 in steps of 60 days.
// Stop if reaching 1970-01-01 (epoch) without finding anything.
var time = DateTime.utc(2020, 1, 1).millisecondsSinceEpoch;
var offset =
DateTime.fromMillisecondsSinceEpoch(time).timeZoneOffset.inMilliseconds;
var time2 = time;
var offset2 = offset;
// Whether the first change found moved the clock forward.
bool changeForward = false;
// 60 days.
const delta = 60 * Duration.millisecondsPerDay;
while (time2 > 0) {
time2 -= delta;
offset2 = DateTime.fromMillisecondsSinceEpoch(time2)
.timeZoneOffset
.inMilliseconds;
if (verbose) {
print("Search: ${tz(time2, offset2)} - ${tz(time, offset)}");
}
if (offset2 != offset) {
// Two different time zones found. Now find the precise (to the minute)
// time where a change happened, and test that.
test(findChange(time2, time));
// Remeber if the change moved the clock forward or backward.
changeForward = offset2 < offset;
break;
}
}
time = time2;
// Find a change in the other direction.
// Keep iterating backwards to find another time zone
// where the change was in the other direction.
while (time > 0) {
time -= delta;
offset =
DateTime.fromMillisecondsSinceEpoch(time).timeZoneOffset.inMilliseconds;
if (verbose) {
print("Search: ${tz(time2, offset2)} - ${tz(time, offset)}");
}
if (offset != offset2) {
if ((offset < offset2) != changeForward) {
test(findChange(time, time2));
break;
} else {
// Another change in the same direction.
// Probably rare, but move use this time
// as end-point instead, so the binary search will be shorter.
time2 = time;
offset2 = offset;
}
}
}
}
/// Tests that a local time zone change is correctly represented
/// by local time [DateTime] objects created from date-time values.
void test(TimeZoneChange change) {
if (verbose) print("Test of $change");
// Sanity check. The time zones match the [change] one second
// before and after the change.
var before = DateTime.fromMillisecondsSinceEpoch(
change.msSinceEpoch - Duration.millisecondsPerSecond);
Expect.equals(change.msOffsetBefore, before.timeZoneOffset.inMilliseconds);
var after = DateTime.fromMillisecondsSinceEpoch(
change.msSinceEpoch + Duration.millisecondsPerSecond);
Expect.equals(change.msOffsetAfter, after.timeZoneOffset.inMilliseconds);
if (verbose) print("From MS : ${dtz(before)} --- ${dtz(after)}");
// Create local DateTime objects for the same YMDHMS as the
// values above. See that we pick the correct local time for them.
// One second before the change, even if clock moves backwards,
// we pick a value that is in the earlier time zone.
var localBefore = DateTime(before.year, before.month, before.day, before.hour,
before.minute, before.second);
Expect.equals(before, localBefore);
// Asking for a calendar date one second after the change.
var localAfter = DateTime(after.year, after.month, after.day, after.hour,
after.minute, after.second);
if (verbose) print("From YMDHMS: ${dtz(localBefore)} --- ${dtz(localAfter)}");
if (before.timeZoneOffset < after.timeZoneOffset) {
// Clock moved forwards.
// We're asking for a clock time which doesn't exist.
if (verbose) {
print("Forward: ${dtz(after)} vs ${dtz(localAfter)}");
}
Expect.equals(after, localAfter);
} else {
// Clock moved backwards.
// We're asking for a clock time which exists more than once.
// Should be in the former time zone.
Expect.equals(before.timeZoneOffset, localAfter.timeZoneOffset);
}
}
/// Finds a time zone change between [before] and [after].
///
/// The [before] time must be before [after],
/// and the local time zone at the two points must be different.
///
/// Finds the point in time, with one minute precision,
/// where the time zone changed, and returns this point,
/// as well as the time zone offset before and after the change.
TimeZoneChange findChange(int before, int after) {
var min = Duration.millisecondsPerMinute;
assert(before % min == 0);
assert(after % min == 0);
var offsetBefore =
DateTime.fromMillisecondsSinceEpoch(before).timeZoneOffset.inMilliseconds;
var offsetAfter =
DateTime.fromMillisecondsSinceEpoch(after).timeZoneOffset.inMilliseconds;
// Binary search for the precise (to 1 minute increments)
// time where the change happened.
while (after - before > min) {
var mid = before + (after - before) ~/ 2;
mid -= mid % min;
var offsetMid =
DateTime.fromMillisecondsSinceEpoch(mid).timeZoneOffset.inMilliseconds;
if (verbose) {
print(
"Bsearch: ${tz(before, offsetBefore)} - ${tz(mid, offsetMid)} - ${tz(after, offsetAfter)}");
}
if (offsetMid == offsetBefore) {
before = mid;
} else if (offsetMid == offsetAfter) {
after = mid;
} else {
// Third timezone in the middle. Probably rare.
// Use that as either before or after.
// Keep the direction of the time zone change.
var forwardChange = offsetAfter > offsetBefore;
if ((offsetMid > offsetBefore) == forwardChange) {
after = mid;
offsetAfter = offsetMid;
} else {
before = mid;
offsetBefore = offsetMid;
}
}
}
return TimeZoneChange(after, offsetBefore, offsetAfter);
}
/// A local time zone change.
class TimeZoneChange {
/// The point in time where the clocks were adjusted.
final int msSinceEpoch;
/// The time zone offset before the change.
final int msOffsetBefore;
/// The time zone offset since the change.
final int msOffsetAfter;
TimeZoneChange(this.msSinceEpoch, this.msOffsetBefore, this.msOffsetAfter);
String toString() {
var local = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch);
var offsetBefore = Duration(milliseconds: msOffsetBefore);
var offsetAfter = Duration(milliseconds: msOffsetAfter);
return "$local (${ltz(offsetBefore)} -> ${ltz(offsetAfter)})";
}
}
// Helpers when printing timezones.
/// Point in time in ms since epoch, and known offset in ms.
String tz(int ms, int offset) => "${DateTime.fromMillisecondsSinceEpoch(ms)}"
"${ltz(Duration(milliseconds: offset))}";
/// Time plus Zone from DateTime
String dtz(DateTime dt) => "$dt${dt.isUtc ? "" : ltz(dt.timeZoneOffset)}";
/// Time zone from duration ("+h:ss" format).
String ltz(Duration d) => "${d.isNegative ? "-" : "+"}${d.inHours}"
":${(d.inMinutes % 60).toString().padLeft(2, "0")}";
/// Set to true if debugging.
const bool verbose = false;

View file

@ -0,0 +1,190 @@
// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import "package:expect/expect.dart";
// Tests that local DateTime constructor works correctly around
// time zone changes.
void main() {
// Find two points in time with different time zones.
// Search linearly back from 2020-01-01 in steps of 60 days.
// Stop if reaching 1970-01-01 (epoch) without finding anything.
var time = DateTime.utc(2020, 1, 1).millisecondsSinceEpoch;
var offset =
DateTime.fromMillisecondsSinceEpoch(time).timeZoneOffset.inMilliseconds;
var time2 = time;
var offset2 = offset;
// Whether the first change found moved the clock forward.
bool changeForward = false;
// 60 days.
const delta = 60 * Duration.millisecondsPerDay;
while (time2 > 0) {
time2 -= delta;
offset2 = DateTime.fromMillisecondsSinceEpoch(time2)
.timeZoneOffset
.inMilliseconds;
if (verbose) {
print("Search: ${tz(time2, offset2)} - ${tz(time, offset)}");
}
if (offset2 != offset) {
// Two different time zones found. Now find the precise (to the minute)
// time where a change happened, and test that.
test(findChange(time2, time));
// Remeber if the change moved the clock forward or backward.
changeForward = offset2 < offset;
break;
}
}
time = time2;
// Find a change in the other direction.
// Keep iterating backwards to find another time zone
// where the change was in the other direction.
while (time > 0) {
time -= delta;
offset =
DateTime.fromMillisecondsSinceEpoch(time).timeZoneOffset.inMilliseconds;
if (verbose) {
print("Search: ${tz(time2, offset2)} - ${tz(time, offset)}");
}
if (offset != offset2) {
if ((offset < offset2) != changeForward) {
test(findChange(time, time2));
break;
} else {
// Another change in the same direction.
// Probably rare, but move use this time
// as end-point instead, so the binary search will be shorter.
time2 = time;
offset2 = offset;
}
}
}
}
/// Tests that a local time zone change is correctly represented
/// by local time [DateTime] objects created from date-time values.
void test(TimeZoneChange change) {
if (verbose) print("Test of $change");
// Sanity check. The time zones match the [change] one second
// before and after the change.
var before = DateTime.fromMillisecondsSinceEpoch(
change.msSinceEpoch - Duration.millisecondsPerSecond);
Expect.equals(change.msOffsetBefore, before.timeZoneOffset.inMilliseconds);
var after = DateTime.fromMillisecondsSinceEpoch(
change.msSinceEpoch + Duration.millisecondsPerSecond);
Expect.equals(change.msOffsetAfter, after.timeZoneOffset.inMilliseconds);
if (verbose) print("From MS : ${dtz(before)} --- ${dtz(after)}");
// Create local DateTime objects for the same YMDHMS as the
// values above. See that we pick the correct local time for them.
// One second before the change, even if clock moves backwards,
// we pick a value that is in the earlier time zone.
var localBefore = DateTime(before.year, before.month, before.day, before.hour,
before.minute, before.second);
Expect.equals(before, localBefore);
// Asking for a calendar date one second after the change.
var localAfter = DateTime(after.year, after.month, after.day, after.hour,
after.minute, after.second);
if (verbose) print("From YMDHMS: ${dtz(localBefore)} --- ${dtz(localAfter)}");
if (before.timeZoneOffset < after.timeZoneOffset) {
// Clock moved forwards.
// We're asking for a clock time which doesn't exist.
if (verbose) {
print("Forward: ${dtz(after)} vs ${dtz(localAfter)}");
}
Expect.equals(after, localAfter);
} else {
// Clock moved backwards.
// We're asking for a clock time which exists more than once.
// Should be in the former time zone.
Expect.equals(before.timeZoneOffset, localAfter.timeZoneOffset);
}
}
/// Finds a time zone change between [before] and [after].
///
/// The [before] time must be before [after],
/// and the local time zone at the two points must be different.
///
/// Finds the point in time, with one minute precision,
/// where the time zone changed, and returns this point,
/// as well as the time zone offset before and after the change.
TimeZoneChange findChange(int before, int after) {
var min = Duration.millisecondsPerMinute;
assert(before % min == 0);
assert(after % min == 0);
var offsetBefore =
DateTime.fromMillisecondsSinceEpoch(before).timeZoneOffset.inMilliseconds;
var offsetAfter =
DateTime.fromMillisecondsSinceEpoch(after).timeZoneOffset.inMilliseconds;
// Binary search for the precise (to 1 minute increments)
// time where the change happened.
while (after - before > min) {
var mid = before + (after - before) ~/ 2;
mid -= mid % min;
var offsetMid =
DateTime.fromMillisecondsSinceEpoch(mid).timeZoneOffset.inMilliseconds;
if (verbose) {
print(
"Bsearch: ${tz(before, offsetBefore)} - ${tz(mid, offsetMid)} - ${tz(after, offsetAfter)}");
}
if (offsetMid == offsetBefore) {
before = mid;
} else if (offsetMid == offsetAfter) {
after = mid;
} else {
// Third timezone in the middle. Probably rare.
// Use that as either before or after.
// Keep the direction of the time zone change.
var forwardChange = offsetAfter > offsetBefore;
if ((offsetMid > offsetBefore) == forwardChange) {
after = mid;
offsetAfter = offsetMid;
} else {
before = mid;
offsetBefore = offsetMid;
}
}
}
return TimeZoneChange(after, offsetBefore, offsetAfter);
}
/// A local time zone change.
class TimeZoneChange {
/// The point in time where the clocks were adjusted.
final int msSinceEpoch;
/// The time zone offset before the change.
final int msOffsetBefore;
/// The time zone offset since the change.
final int msOffsetAfter;
TimeZoneChange(this.msSinceEpoch, this.msOffsetBefore, this.msOffsetAfter);
String toString() {
var local = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch);
var offsetBefore = Duration(milliseconds: msOffsetBefore);
var offsetAfter = Duration(milliseconds: msOffsetAfter);
return "$local (${ltz(offsetBefore)} -> ${ltz(offsetAfter)})";
}
}
// Helpers when printing timezones.
/// Point in time in ms since epoch, and known offset in ms.
String tz(int ms, int offset) => "${DateTime.fromMillisecondsSinceEpoch(ms)}"
"${ltz(Duration(milliseconds: offset))}";
/// Time plus Zone from DateTime
String dtz(DateTime dt) => "$dt${dt.isUtc ? "" : ltz(dt.timeZoneOffset)}";
/// Time zone from duration ("+h:ss" format).
String ltz(Duration d) => "${d.isNegative ? "-" : "+"}${d.inHours}"
":${(d.inMinutes % 60).toString().padLeft(2, "0")}";
/// Set to true if debugging.
const bool verbose = false;