diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs
index e45a90401f4cf3..5738bace43ce6b 100644
--- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs
+++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs
@@ -288,22 +288,21 @@ private bool IsDaylightSavingOn(DateTime localDateTime)
/// Tries to convert a local date and time to Coordinated Universal Time (UTC).
///
/// The local date and time to convert.
- /// When this method returns, contains the UTC date and time if the conversion succeeded.
+ /// When this method returns, contains the UTC ticks if the conversion succeeded. The value may be negative or exceed DateTime range.
/// True if the conversion was successful; otherwise, false.
///
/// This method attempts to convert a local time to UTC. It returns false if the local time is invalid,
/// such as during a daylight saving time transition when the local time does not exist.
///
- private bool TryLocalToUtc(DateTime localDateTime, out DateTime utcDateTime)
+ private bool TryLocalToUtc(DateTime localDateTime, out long utcTicks)
{
if (TryGetUtcOffset(localDateTime, out TimeSpan offset))
{
- long ticks = localDateTime.Ticks - offset.Ticks;
- utcDateTime = SafeCreateDateTimeFromTicks(ticks, DateTimeKind.Utc);
+ utcTicks = localDateTime.Ticks - offset.Ticks;
return true;
}
- utcDateTime = default;
+ utcTicks = 0;
return false;
}
diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs
index de2234c53d1695..f39fed35f9b46f 100644
--- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs
+++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs
@@ -649,7 +649,7 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo
throw new ArgumentException(SR.Argument_ConvertMismatch, nameof(sourceTimeZone));
}
- bool isInvalidTime = !sourceTimeZone.TryLocalToUtc(dateTime, out DateTime utcDateTime);
+ bool isInvalidTime = !sourceTimeZone.TryLocalToUtc(dateTime, out long utcTicks);
if (((flags & TimeZoneInfoOptions.NoThrowOnInvalidTime) == 0) && isInvalidTime)
{
@@ -660,7 +660,10 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo
{
// This is not logical to do but we are keeping it for app compatibility reason.
// We get here if the dateTime is invalid in the source time zone.
- utcDateTime = new DateTime(dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks, DateTimeKind.Utc);
+ // Preserve the historical behavior of throwing if the computed UTC time is
+ // outside the DateTime range, rather than silently clamping it later.
+ DateTime invalidTimeUtc = new DateTime(dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks, DateTimeKind.Utc);
+ utcTicks = invalidTimeUtc.Ticks;
}
DateTimeKind targetKind = cachedData.GetCorrespondingKind(destinationTimeZone);
@@ -671,7 +674,15 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo
return dateTime;
}
- DateTime targetConverted = destinationTimeZone.UtcToLocal(utcDateTime, out bool isDaylightSaving);
+ // Use a clamped DateTime for destination offset lookup because transition-table lookups require
+ // an in-range DateTime. This preserves the existing offset-selection behavior for the lookup,
+ // while the final local ticks are still computed from the raw utcTicks to avoid double-clamping.
+ DateTime utcForLookup = SafeCreateDateTimeFromTicks(utcTicks, DateTimeKind.Utc);
+ TimeSpan destOffset = destinationTimeZone.GetOffsetForUtcDate(utcForLookup, out bool isDaylightSaving);
+
+ // Compute the final result from raw ticks to avoid precision loss from double-clamping.
+ // The intermediate UTC ticks may be outside DateTime range, but the final local ticks may be valid.
+ DateTime targetConverted = SafeCreateDateTimeFromTicks(utcTicks + destOffset.Ticks);
if (targetKind == DateTimeKind.Local)
{
diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs
index 0e0c488454283a..0067c38562b376 100644
--- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs
+++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs
@@ -290,6 +290,45 @@ public static void ConvertTime_DateTimeOffset_NearMinMaxValue()
VerifyConvert(DateTime.MinValue.AddHours(0.5) + earlyTimesDifference, s_strSydney, s_strPacific, DateTime.MinValue.AddHours(0.5));
}
+ [Theory]
+ [InlineData(4, 3, 1, 13, 15, 0, 13, 15)] // UTC+4 to UTC+3: subtract 1 hour
+ [InlineData(5, 3, 2, 30, 0, 0, 30, 0)] // UTC+5 to UTC+3: subtract 2 hours
+ [InlineData(3, 4, 0, 30, 0, 1, 30, 0)] // UTC+3 to UTC+4: add 1 hour
+ [InlineData(8, 3, 4, 0, 0, 0, 0, 0)] // UTC+8 to UTC+3: subtract 5 hours, result is MinValue boundary
+ public static void ConvertTime_DateTime_NearMinValue_PositiveOffsetZones(
+ int sourceOffsetHours, int destOffsetHours,
+ int inputHour, int inputMinute, int inputSecond,
+ int expectedHour, int expectedMinute, int expectedSecond)
+ {
+ TimeZoneInfo sourceTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC+{sourceOffsetHours}", TimeSpan.FromHours(sourceOffsetHours), $"UTC+{sourceOffsetHours}", $"UTC+{sourceOffsetHours}");
+ TimeZoneInfo destTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC+{destOffsetHours}", TimeSpan.FromHours(destOffsetHours), $"UTC+{destOffsetHours}", $"UTC+{destOffsetHours}");
+
+ DateTime earlyDate = new DateTime(0001, 01, 01, inputHour, inputMinute, inputSecond);
+ DateTime converted = TimeZoneInfo.ConvertTime(earlyDate, sourceTimeZone, destTimeZone);
+
+ DateTime expected = new DateTime(0001, 01, 01, expectedHour, expectedMinute, expectedSecond);
+ Assert.Equal(expected, converted);
+ }
+
+ [Theory]
+ [InlineData(-4, -3, 22, 46, 45, 23, 46, 45)] // UTC-4 to UTC-3: add 1 hour
+ [InlineData(-3, -4, 23, 46, 45, 22, 46, 45)] // UTC-3 to UTC-4: subtract 1 hour
+ [InlineData(-3, -8, 23, 0, 0, 18, 0, 0)] // UTC-3 to UTC-8: subtract 5 hours
+ public static void ConvertTime_DateTime_NearMaxValue_NegativeOffsetZones(
+ int sourceOffsetHours, int destOffsetHours,
+ int inputHour, int inputMinute, int inputSecond,
+ int expectedHour, int expectedMinute, int expectedSecond)
+ {
+ TimeZoneInfo sourceTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC{sourceOffsetHours}", TimeSpan.FromHours(sourceOffsetHours), $"UTC{sourceOffsetHours}", $"UTC{sourceOffsetHours}");
+ TimeZoneInfo destTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC{destOffsetHours}", TimeSpan.FromHours(destOffsetHours), $"UTC{destOffsetHours}", $"UTC{destOffsetHours}");
+
+ DateTime lateDate = new DateTime(9999, 12, 31, inputHour, inputMinute, inputSecond);
+ DateTime converted = TimeZoneInfo.ConvertTime(lateDate, sourceTimeZone, destTimeZone);
+
+ DateTime expected = new DateTime(9999, 12, 31, expectedHour, expectedMinute, expectedSecond);
+ Assert.Equal(expected, converted);
+ }
+
[Fact]
public static void ConvertTime_DateTimeOffset_VariousSystemTimeZones()
{