Skip to content

Fix DateTime.ToUniversalTime for invalid local times in the DST gap - #134871

Merged
tarekgh merged 6 commits into
dotnet:mainfrom
tarekgh:fix-touniversaltime-invalid-gap
Oct 1, 2026
Merged

tarekgh merged 6 commits into
dotnet:mainfrom
tarekgh:fix-touniversaltime-invalid-gap

Conversation

@tarekgh

@tarekgh tarekgh commented Sep 29, 2026

Copy link
Copy Markdown
Member

Fixes the DateTime.ToUniversalTime() regression for Local values that fall in the spring-forward DST gap (an invalid time).

The invalid-time fallback in TimeZoneInfo.ConvertTime added the base UTC offset instead of subtracting it, so an invalid local time was converted to a UTC value two offsets too high (for example W. Europe 02:30 became 03:30Z instead of 01:30Z). The result now subtracts the offset and agrees with TimeZoneInfo.Local.GetUtcOffset and new DateTimeOffset(local).UtcDateTime.

Added a regression test that sets the local time zone and verifies the invalid-time conversion for several European zones.

Fixes #134846

The invalid-time fallback in TimeZoneInfo.ConvertTime added the base UTC
offset instead of subtracting it, so ToUniversalTime on a Local value in
the spring-forward gap returned a UTC value two offsets too high. Subtract
the offset so the result matches GetUtcOffset and DateTimeOffset.
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-datetime
See info in area-owners.md if you want to be subscribed.

@tarekgh tarekgh added this to the 11.0.0 milestone Sep 29, 2026
@tarekgh
tarekgh requested a review from jozkee September 29, 2026 18:16
@tarekgh

tarekgh commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

CC @artl93 @jeffhandley, just FYI for awareness, I am going to port this fix to release/11.0 when completing this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Historical offset deltas remain mishandled, and negative-offset coverage is missing.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Fixes invalid local-time conversion during DST gaps.

Changes:

  • Corrects UTC offset arithmetic.
  • Adds Unix regression coverage for European zones.
File Description
TimeZoneInfo.cs Corrects invalid-time UTC conversion.
TimeZoneInfoTests.cs Adds DST-gap regression tests.

Comment thread src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs Outdated
…conversion

Resolve the applicable adjustment rule when converting an invalid (DST gap) local time to UTC so zones that changed their standard offset over time (non-zero BaseUtcOffsetDelta) convert correctly. Strengthen the regression test with a negative-offset zone, independent literal expectations, and the Europe/Lisbon 1993 historical case.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Rule selection can use the wrong same-year adjustment rule, and the historical Lisbon consistency assertions fail against the current offset fallback.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (2)

Comment thread src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs Outdated
Return the applicable standard offset (base plus BaseUtcOffsetDelta) for invalid local times in GetUtcOffset so GetUtcOffset and DateTimeOffset stay consistent with ConvertTime/ToUniversalTime for zones that changed their standard offset over time.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Rule selection remains incorrect for real historical zones such as Africa/Windhoek, and out-of-range conversion bypasses documented clamping.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Preserve raw UTC ticks before clamping out-of-range conversions

src/​libraries/​System.Private.CoreLib/​src/​System/​TimeZoneInfo.cs:670

Constructing a DateTime here makes an out-of-range conversion throw before the existing clamping path runs. DateTime.ToUniversalTime() promises MinValue/MaxValue when the UTC result is outside the representable range, and the implementation before the transition-cache rewrite carried raw UTC ticks into its clamp. Keep the raw tick value so the SafeCreateDateTimeFromTicks calls below preserve that contract.

Comment thread src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs Outdated
Resolve the adjustment rule for an invalid (DST gap) local time using the
rule adjacent to the exact timestamp rather than by calendar year. Zones that
change their standard offset between two rules within the same year (for
example southern-hemisphere zones that begin a year in daylight saving time)
could otherwise pick the wrong rule and compute the gap offset incorrectly.

The new FindRuleIndexForLocalTime binary search reuses the existing
CompareAdjustmentRuleToDateTime comparison; FindRuleForYear is left unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Shared historical time-zone rule selection has broad cross-platform compatibility impact and warrants final maintainer review.

Review effort: Balanced
Findings: None

Resolved since last review (1)

The Europe/Lisbon 1993 spring-forward gap conversion is wrong on IANA in
both .NET 10 and .NET 11 (a long-standing historical-offset bug), so it is
not part of the issue dotnet#134846 regression and does not belong with these
regression rows. The remaining rows cover the actual regression fix.
The invalid-time regression test body runs inside a RemoteExecutor child
process that exits immediately after the delegate, so restoring the TZ
environment variable and clearing the cache in a finally block is dead
work. Set TZ and clear the cache once at the top.
@tarekgh

tarekgh commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/ba-g the failures are not related.

@tarekgh
tarekgh merged commit b90c173 into dotnet:main Oct 1, 2026
140 of 142 checks passed
@tarekgh

tarekgh commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/backport to release/11.0

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Started backporting to release/11.0 (link to workflow run)

tarekgh added a commit that referenced this pull request Oct 2, 2026
…n the DST gap (#135071)

Backport of #134871 to release/11.0

/cc @tarekgh

## Customer Impact

- [x] Customer reported
- [ ] Found internally

#134846

In .NET 11, converting a local `DateTime` in the daylight-saving
spring-forward gap to UTC returns the wrong value - `ToUniversalTime()`,
`DateTimeOffset`, and `GetUtcOffset()` add the offset instead of
subtracting it, landing off by roughly twice the UTC offset. It's a
silent regression from .NET 10 (no exception, just wrong data), which
can misorder events or corrupt timestamps in scheduling, logging,
billing, and calendar scenarios.

## Regression

- [x] Yes
- [ ] No

The regression came from the `TimeZoneInfo` rewrite in .NET 11 #119662 .
That change reworked how invalid (DST-gap) local times are converted,
and in the rewritten invalid-time fallback the standard UTC offset was
added instead of subtracted - a sign error. Before the rewrite the
offset was subtracted correctly; afterward, any local time in the
spring-forward gap converts to the wrong UTC instant (off by roughly
twice the offset). The .NET 10 code path did not have this error, which
is why it's a 10→11 regression.

## Testing

Verified the failing scenario, added a new test to cover the failing
scenario, passing all regression tests.

## Risk

Low

This change touches an extremely narrow scenario: only local times that
fall inside a daylight-saving spring-forward gap - times the wall clock
skips and that rarely occur in practice. Every valid time, ambiguous
time, and modern time zone is completely unaffected; the result changes
solely for cases that were already returning wrong answers. It reuses
existing, vetted rule-comparison logic rather than new math and leaves
all hot paths untouched. The worst case is no change, and the expected
case is a correct result replacing silent corruption - so accepting it
is low risk.

Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DateTime.ToUniversalTime() returns wrong UTC for local times in the DST gap on .NET 11 (regression from .NET 10)

3 participants