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

Fix Time Zones Adjustment Rules on Linux #49733

Merged
merged 6 commits into from
Mar 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ private AdjustmentRule(
_noDaylightTransitions = noDaylightTransitions;
}

internal static AdjustmentRule CreateAdjustmentRule(
DateTime dateStart,
DateTime dateEnd,
TimeSpan daylightDelta,
TransitionTime daylightTransitionStart,
TransitionTime daylightTransitionEnd,
TimeSpan baseUtcOffsetDelta)
{
return new AdjustmentRule(
dateStart,
dateEnd,
daylightDelta,
daylightTransitionStart,
daylightTransitionEnd,
baseUtcOffsetDelta,
noDaylightTransitions: false);
}

public static AdjustmentRule CreateAdjustmentRule(
DateTime dateStart,
DateTime dateEnd,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ private TimeZoneInfo(byte[] data, string id, bool dstDisabled)
ValidateTimeZoneInfo(_id, _baseUtcOffset, _adjustmentRules, out _supportsDaylightSavingTime);
}

// The TransitionTime fields are not used when AdjustmentRule.NoDaylightTransitions == true.
// However, there are some cases in the past where DST = true, and the daylight savings offset
// now equals what the current BaseUtcOffset is. In that case, the AdjustmentRule.DaylightOffset
// is going to be TimeSpan.Zero. But we still need to return 'true' from AdjustmentRule.HasDaylightSaving.
// To ensure we always return true from HasDaylightSaving, make a "special" dstStart that will make the logic
// in HasDaylightSaving return true.
private static readonly TransitionTime s_daylightRuleMarker = TransitionTime.CreateFixedDateRule(DateTime.MinValue.AddMilliseconds(2), 1, 1);

// Truncate the date and the time to Milliseconds precision
private static DateTime GetTimeOnlyInMillisecondsPrecision(DateTime input) => new DateTime((input.TimeOfDay.Ticks / TimeSpan.TicksPerMillisecond) * TimeSpan.TicksPerMillisecond);

/// <summary>
/// Returns a cloned array of AdjustmentRule objects
/// </summary>
Expand All @@ -128,11 +139,20 @@ public AdjustmentRule[] GetAdjustmentRules()
// as the rules now is public, we should fill it properly so the caller doesn't have to know how we use it internally
// and can use it as it is used in Windows

AdjustmentRule[] rules = new AdjustmentRule[_adjustmentRules.Length];
List<AdjustmentRule> rulesList = new List<AdjustmentRule>(_adjustmentRules.Length);

for (int i = 0; i < _adjustmentRules.Length; i++)
{
AdjustmentRule? rule = _adjustmentRules[i];
AdjustmentRule rule = _adjustmentRules[i];

if (rule.NoDaylightTransitions &&
tarekgh marked this conversation as resolved.
Show resolved Hide resolved
rule.DaylightTransitionStart != s_daylightRuleMarker &&
rule.DaylightDelta == TimeSpan.Zero && rule.BaseUtcOffsetDelta == TimeSpan.Zero)
{
// This rule has no time transition, ignore it.
tarekgh marked this conversation as resolved.
Show resolved Hide resolved
continue;
}

DateTime start = rule.DateStart.Kind == DateTimeKind.Utc ?
// At the daylight start we didn't start the daylight saving yet then we convert to Local time
// by adding the _baseUtcOffset to the UTC time
Expand All @@ -144,13 +164,51 @@ public AdjustmentRule[] GetAdjustmentRules()
new DateTime(rule.DateEnd.Ticks + _baseUtcOffset.Ticks + rule.DaylightDelta.Ticks, DateTimeKind.Unspecified) :
rule.DateEnd;

TransitionTime startTransition = TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, start.Hour, start.Minute, start.Second), start.Month, start.Day);
TransitionTime endTransition = TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, end.Hour, end.Minute, end.Second), end.Month, end.Day);
if (start.Year == end.Year || !rule.NoDaylightTransitions)
tarekgh marked this conversation as resolved.
Show resolved Hide resolved
{
// If the rule is covering only one year then the start and end transitions would occur in that year, we don't need to split the rule.
// Also, rule.NoDaylightTransitions be false in case the rule was created from a POSIX time zone string and having a DST transition. We can represent this in one rule too
TransitionTime startTransition = rule.NoDaylightTransitions ? TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(start), start.Month, start.Day) : rule.DaylightTransitionStart;
TransitionTime endTransition = rule.NoDaylightTransitions ? TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(end), end.Month, end.Day) : rule.DaylightTransitionEnd;
rulesList.Add(AdjustmentRule.CreateAdjustmentRule(start.Date, end.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta));
}
else
{
// For rules spanning more than one year. The time transition inside this rule would apply for the whole time spanning these years
// and not for partial time of every year.
// AdjustmentRule cannot express such rule using the DaylightTransitionStart and DaylightTransitionEnd because
// the DaylightTransitionStart and DaylightTransitionEnd express the transition for every year.
// We split the rule into more rules. The first rule will start from the start year of the original rule and ends at the end of the same year.
// The second splitted rule would cover the middle range of the original rule and ranging from the year start+1 to
// year end-1. The transition time in this rule would start from Jan 1st to end of December.
// The last splitted rule would start from the Jan 1st of the end year of the original rule and ends at the end transition time of the original rule.

// Add the first rule.
DateTime endForFirstRule = new DateTime(start.Year + 1, 1, 1).AddMilliseconds(-1); // At the end of the first year
TransitionTime startTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(start), start.Month, start.Day);
TransitionTime endTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(endForFirstRule), endForFirstRule.Month, endForFirstRule.Day);
rulesList.Add(AdjustmentRule.CreateAdjustmentRule(start.Date, endForFirstRule.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta));

// Check if there is range of years between the start and the end years
if (end.Year - start.Year > 1)
{
// Add the middle rule.
DateTime middleYearStart = new DateTime(start.Year + 1, 1, 1);
DateTime middleYearEnd = new DateTime(end.Year, 1, 1).AddMilliseconds(-1);
startTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(middleYearStart), middleYearStart.Month, middleYearStart.Day);
endTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(middleYearEnd), middleYearEnd.Month, middleYearEnd.Day);
rulesList.Add(AdjustmentRule.CreateAdjustmentRule(middleYearStart.Date, middleYearEnd.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta));
}

rules[i] = TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule(start.Date, end.Date, rule.DaylightDelta, startTransition, endTransition);
// Add the end rule.
DateTime endYearStart = new DateTime(end.Year, 1, 1); // At the beginning of the last year
startTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(endYearStart), endYearStart.Month, endYearStart.Day);
endTransition = TransitionTime.CreateFixedDateRule(GetTimeOnlyInMillisecondsPrecision(end), end.Month, end.Day);
rulesList.Add(AdjustmentRule.CreateAdjustmentRule(endYearStart.Date, end.Date, rule.DaylightDelta, startTransition, endTransition, rule.BaseUtcOffsetDelta));
}
}

return rules;
return rulesList.ToArray();
}

private static void PopulateAllSystemTimeZones(CachedData cachedData)
Expand Down Expand Up @@ -957,7 +1015,7 @@ private static void TZif_GenerateAdjustmentRule(ref int index, TimeSpan timeZone
// is going to be TimeSpan.Zero. But we still need to return 'true' from AdjustmentRule.HasDaylightSaving.
// To ensure we always return true from HasDaylightSaving, make a "special" dstStart that will make the logic
// in HasDaylightSaving return true.
dstStart = TransitionTime.CreateFixedDateRule(DateTime.MinValue.AddMilliseconds(2), 1, 1);
dstStart = s_daylightRuleMarker;
}
else
{
Expand Down Expand Up @@ -1068,7 +1126,7 @@ private static TZifType TZif_GetEarlyDateTransitionType(TZifType[] transitionTyp
/// Creates an AdjustmentRule given the POSIX TZ environment variable string.
/// </summary>
/// <remarks>
/// See http://man7.org/linux/man-pages/man3/tzset.3.html for the format and semantics of this POSX string.
/// See http://man7.org/linux/man-pages/man3/tzset.3.html for the format and semantics of this POSIX string.
/// </remarks>
private static AdjustmentRule? TZif_CreateAdjustmentRuleForPosixFormat(string posixFormat, DateTime startTransitionDate, TimeSpan timeZoneBaseUtcOffset)
{
Expand Down
63 changes: 63 additions & 0 deletions src/libraries/System.Runtime/tests/System/TimeZoneInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,69 @@ public static void IsDaylightSavingTime_CasablancaMultiYearDaylightSavings(strin
Assert.Equal(offset, s_casablancaTz.GetUtcOffset(dt));
}

[Fact]
[PlatformSpecific(~TestPlatforms.Windows)]
public static void TestSplittingRulesWhenReported()
{
// This test confirm we are splitting the rules which span multiple years on Linux
// we use "America/Los_Angeles" which has the rule covering 2/9/1942 to 8/14/1945
// with daylight transition by 01:00:00. This rule should be split into 3 rules:
// - rule 1 from 2/9/1942 to 12/31/1942
// - rule 2 from 1/1/1943 to 12/31/1944
// - rule 3 from 1/1/1945 to 8/14/1945
TimeZoneInfo.AdjustmentRule[] rules = TimeZoneInfo.FindSystemTimeZoneById(s_strPacific).GetAdjustmentRules();

bool ruleEncountered = false;
for (int i = 0; i < rules.Length; i++)
{
if (rules[i].DateStart == new DateTime(1942, 2, 9))
tarekgh marked this conversation as resolved.
Show resolved Hide resolved
{
Assert.True(i + 2 <= rules.Length - 1);
TimeSpan daylightDelta = TimeSpan.FromHours(1);

// DateStart : 2/9/1942 12:00:00 AM (Unspecified)
// DateEnd : 12/31/1942 12:00:00 AM (Unspecified)
// DaylightDelta : 01:00:00
// DaylightTransitionStart : ToD:02:00:00 M:2, D:9, W:1, DoW:Sunday, FixedDate:True
// DaylightTransitionEnd : ToD:23:59:59.9990000 M:12, D:31, W:1, DoW:Sunday, FixedDate:True

Assert.Equal(new DateTime(1942, 12, 31), rules[i].DateEnd);
Assert.Equal(daylightDelta, rules[i].DaylightDelta);
Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 2, 0, 0), 2, 9), rules[i].DaylightTransitionStart);
Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 23, 59, 59, 999), 12, 31), rules[i].DaylightTransitionEnd);

// DateStart : 1/1/1943 12:00:00 AM (Unspecified)
// DateEnd : 12/31/1944 12:00:00 AM (Unspecified)
// DaylightDelta : 01:00:00
// DaylightTransitionStart : ToD:00:00:00 M:1, D:1, W:1, DoW:Sunday, FixedDate:True
// DaylightTransitionEnd : ToD:23:59:59.9990000 M:12, D:31, W:1, DoW:Sunday, FixedDate:True

Assert.Equal(new DateTime(1943, 1, 1), rules[i + 1].DateStart);
Assert.Equal(new DateTime(1944, 12, 31), rules[i + 1].DateEnd);
Assert.Equal(daylightDelta, rules[i + 1].DaylightDelta);
Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 0, 0, 0), 1, 1), rules[i + 1].DaylightTransitionStart);
Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 23, 59, 59, 999), 12, 31), rules[i + 1].DaylightTransitionEnd);

// DateStart : 1/1/1945 12:00:00 AM (Unspecified)
// DateEnd : 8/14/1945 12:00:00 AM (Unspecified)
// DaylightDelta : 01:00:00
// DaylightTransitionStart : ToD:00:00:00 M:1, D:1, W:1, DoW:Sunday, FixedDate:True
// DaylightTransitionEnd : ToD:15:59:59.9990000 M:8, D:14, W:1, DoW:Sunday, FixedDate:True

Assert.Equal(new DateTime(1945, 1, 1), rules[i + 2].DateStart);
Assert.Equal(new DateTime(1945, 8, 14), rules[i + 2].DateEnd);
Assert.Equal(daylightDelta, rules[i + 2].DaylightDelta);
Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 0, 0, 0), 1, 1), rules[i + 2].DaylightTransitionStart);
Assert.Equal(TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 15, 59, 59, 999), 8, 14), rules[i + 2].DaylightTransitionEnd);

ruleEncountered = true;
break;
}
tarekgh marked this conversation as resolved.
Show resolved Hide resolved
}

Assert.True(ruleEncountered, "The 1942 rule of America/Los_Angeles not found.");
}

[Theory]
[PlatformSpecific(TestPlatforms.AnyUnix)] // Linux will use local mean time for DateTimes before standard time came into effect.
// in 1996 Europe/Lisbon changed from standard time to DST without changing the UTC offset
Expand Down