Skip to content

Inconsistency between TimeZoneInfo.ConvertTimeFromUtc and SystemTimeToTzSpecificLocalTimeEx API around year boundaries #118915

@yukawa

Description

@yukawa

Description

When Windows time zone registry entries specify different offsets for consecutive two years, TimeZoneInfo.ConvertTimeFromUtc() and SystemTimeToTzSpecificLocalTimeEx Win32 API do not seem to agree on how to address such offset changes around the year boundary.

In "Volgograd Standard Time" time zone, UTC 2020-12-31 20:30:00 is resolved to

  • 2020-12-31 23:30:00 with SystemTimeToTzSpecificLocalTimeEx Win32 API
  • 2021-01-01 00:30:00 with TimeZoneInfo.ConvertTimeFromUtc

Reproduction Steps

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
public struct SYSTEMTIME
{
    public ushort wYear;
    public ushort wMonth;
    public ushort wDayOfWeek;
    public ushort wDay;
    public ushort wHour;
    public ushort wMinute;
    public ushort wSecond;
    public ushort wMilliseconds;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct TIME_ZONE_INFORMATION
{
    public int Bias;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
    public string StandardName;
    public SYSTEMTIME StandardStart;
    public int StandardBias;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
    public string DaylightName;
    public SYSTEMTIME DaylightStart;
    public int DaylightBias;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct DYNAMIC_TIME_ZONE_INFORMATION
{
    public TIME_ZONE_INFORMATION TimeZoneInformation;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
    public string TimeZoneKeyName;
    bool DynamicDaylightTimeDisabled;
}

public class TimeZoneInfoHelper
{
    [DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
    public static extern int EnumDynamicTimeZoneInformation(
        uint index,
        out DYNAMIC_TIME_ZONE_INFORMATION pTimeZoneInformation);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool SystemTimeToTzSpecificLocalTimeEx(
        ref DYNAMIC_TIME_ZONE_INFORMATION lpTimeZoneInformation,
        ref SYSTEMTIME lpUniversalTime,
        out SYSTEMTIME lpLocalTime);

    public static bool GetDynamicTimeZoneInformation(
        string timeZoneId,
        out DYNAMIC_TIME_ZONE_INFORMATION dynamicTimeZoneInfo)
    {
        dynamicTimeZoneInfo = new DYNAMIC_TIME_ZONE_INFORMATION();
        for (uint index = 0; true; index++)
        {
            if (EnumDynamicTimeZoneInformation(index, out dynamicTimeZoneInfo) != 0)
            {
                return false;
            }
            if (dynamicTimeZoneInfo.TimeZoneKeyName == timeZoneId)
            {
                return true;
            }
        }
    }

    public static bool SystemTimeToTzSpecificLocalTimeEx(
        DYNAMIC_TIME_ZONE_INFORMATION info,
        DateTime utcTime,
        out DateTime localTime)
    {
        localTime = new DateTime();
        SYSTEMTIME systemTime = new SYSTEMTIME
        {
            wYear = (ushort)utcTime.Year,
            wMonth = (ushort)utcTime.Month,
            wDayOfWeek = 0,
            wDay = (ushort)utcTime.Day,
            wHour = (ushort)utcTime.Hour,
            wMinute = (ushort)utcTime.Minute,
            wSecond = (ushort)utcTime.Second,
            wMilliseconds = (ushort)utcTime.Millisecond,
        };
        SYSTEMTIME tmpLocalTime;
        if (!SystemTimeToTzSpecificLocalTimeEx(ref info, ref systemTime, out tmpLocalTime))
        {
            return false;
        }
        localTime = new DateTime(tmpLocalTime.wYear, tmpLocalTime.wMonth, tmpLocalTime.wDay,
                         tmpLocalTime.wHour, tmpLocalTime.wMinute, tmpLocalTime.wSecond,
                         tmpLocalTime.wMilliseconds, DateTimeKind.Local);
        return true;
    }
}

class Program
{
    static void VerifyTimeZoneInfo(string tzId, IEnumerable<DateTime> utcTimes)
    {
        Console.WriteLine($"Verify UTC to Local time conversions in \"{tzId}\".");
        var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
        var tzInfo = new DYNAMIC_TIME_ZONE_INFORMATION();

        if (!TimeZoneInfoHelper.GetDynamicTimeZoneInformation(tzId, out tzInfo))
        {
            throw new Exception($"Failed to get dynamic time zone information for {tzId}");
        }
        foreach (var utcTime in utcTimes)
        {
            var localDotnet = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcTime, DateTimeKind.Unspecified), tz);
            DateTime localWin32;
            if (!TimeZoneInfoHelper.SystemTimeToTzSpecificLocalTimeEx(tzInfo, utcTime, out localWin32))
            {
                Console.WriteLine($"Failed to convert UTC time {utcTime} to local time in {tzId}");
            }
            Console.WriteLine($"UTC:{utcTime} -> Win32:{localWin32} -- .NET:{localDotnet}");
        }
    }

    static void Main(string[] args)
    {
        Console.WriteLine($"Environment.OSVersion.Version: {Environment.OSVersion.Version}");
        Console.WriteLine($"RuntimeInformation.FrameworkDescription.: {RuntimeInformation.FrameworkDescription}");
        var utcTimes = new DateTime[]
        {
            new DateTime(2019, 12, 31, 19, 0, 0, DateTimeKind.Utc),
            new DateTime(2019, 12, 31, 19, 30, 0, DateTimeKind.Utc),
            new DateTime(2019, 12, 31, 20, 0, 0, DateTimeKind.Utc),
            new DateTime(2019, 12, 31, 20, 30, 0, DateTimeKind.Utc),
            new DateTime(2019, 12, 31, 21, 0, 0, DateTimeKind.Utc),
            new DateTime(2019, 12, 31, 21, 30, 0, DateTimeKind.Utc),
            new DateTime(2019, 12, 31, 22, 0, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 19, 0, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 19, 30, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 20, 0, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 20, 30, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 21, 0, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 21, 30, 0, DateTimeKind.Utc),
            new DateTime(2020, 12, 31, 22, 0, 0, DateTimeKind.Utc),
        };
        VerifyTimeZoneInfo("Volgograd Standard Time", utcTimes);
    }
}

Expected behavior

It's a bit hard to say which behavior is correct, as the source time zone data in Win32 registry does not look to be intentional. That said, it would be nice if both TimeZoneInfo.ConvertTimeFromUtc() and SystemTimeToTzSpecificLocalTimeEx behave in the same manner when the same data source is used.

Actual behavior

Environment.OSVersion.Version: 10.0.26100.0
RuntimeInformation.FrameworkDescription.: .NET 9.0.8
Verify UTC to Local time conversions in "Volgograd Standard Time".
UTC:2019/12/31 19:00:00 -> Win32:2019/12/31 23:00:00 -- .NET:2019/12/31 23:00:00
UTC:2019/12/31 19:30:00 -> Win32:2019/12/31 23:30:00 -- .NET:2019/12/31 23:30:00
UTC:2019/12/31 20:00:00 -> Win32:2020/01/01 1:00:00 -- .NET:2020/01/01 0:00:00
UTC:2019/12/31 20:30:00 -> Win32:2020/01/01 1:30:00 -- .NET:2020/01/01 0:30:00
UTC:2019/12/31 21:00:00 -> Win32:2020/01/01 2:00:00 -- .NET:2020/01/01 2:00:00
UTC:2019/12/31 21:30:00 -> Win32:2020/01/01 2:30:00 -- .NET:2020/01/01 2:30:00
UTC:2019/12/31 22:00:00 -> Win32:2020/01/01 3:00:00 -- .NET:2020/01/01 3:00:00
UTC:2020/12/31 19:00:00 -> Win32:2020/12/31 23:00:00 -- .NET:2020/12/31 23:00:00
UTC:2020/12/31 19:30:00 -> Win32:2020/12/31 23:30:00 -- .NET:2020/12/31 23:30:00
UTC:2020/12/31 20:00:00 -> Win32:2020/12/31 23:00:00 -- .NET:2021/01/01 0:00:00
UTC:2020/12/31 20:30:00 -> Win32:2020/12/31 23:30:00 -- .NET:2021/01/01 0:30:00
UTC:2020/12/31 21:00:00 -> Win32:2021/01/01 0:00:00 -- .NET:2021/01/01 0:00:00
UTC:2020/12/31 21:30:00 -> Win32:2021/01/01 0:30:00 -- .NET:2021/01/01 0:30:00
UTC:2020/12/31 22:00:00 -> Win32:2021/01/01 1:00:00 -- .NET:2021/01/01 1:00:00

Regression?

Haven't checked.

Known Workarounds

  • Use P/Invoke to call SystemTimeToTzSpecificLocalTimeEx if the consistency with OS behavior is critical.
  • Use IANA database (e.g. with Noda Time) if the data accuracy is critical and the consistency with OS is not so important.

Configuration

  • OS: Windows 11 24H2
  • .NET 9.0.8
  • HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones\TzVersion: 0x7e70010

Other information

I'm mostly sure that there is a data issue in Windows Time Zone database regarding 2020 Time zone updates for Volgograd.
https://techcommunity.microsoft.com/blog/dstblog/2020-time-zone-updates-for-volgograd-russia-now-available/2234995

  • HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones\TzVersion: 0x7e70010
  • Time Zone ID: Volgograd Standard Time
Year Bias StandardBias DaylightBias DaylightStart StandardStart
2015 -180 0 -60 N/A N/A
2016 -180 0 -60 N/A N/A
2017 -180 0 -60 N/A N/A
2018 -240 0 60 01-01 00:00:00 10-05 02:00:00
2019 -240 0 -60 N/A N/A
2020 -240 0 -60 01-01 00:00:00 12-05 02:00:00
2021 -180 0 -60 N/A N/A

As you can see, UTC-offsets are not the same in the following period, which do not look to be intentional.

  • 2019-12-31 23:59:59 -> 2020-1-1 00:00:00
  • 2020-12-31 23:59:59 -> 2021-1-1 00:00:00

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions