Skip to content

Commit b3a8d0d

Browse files
authored
Fix GetSystemTimeZone to return filtered list (#121095)
Fixes #117422 We maintain an internal cache, `_systemTimeZones`, that maps time zone IDs to their corresponding `TimeZoneInfo` objects. Entries are added when either `TimeZoneInfo.GetSystemTimeZones(...)` or `TimeZoneInfo.FindSystemTimeZoneById(...)` is called. On Linux and macOS, `TimeZoneInfo.FindSystemTimeZoneById(...)` attempts to retrieve the time zone object by reading the appropriate time zone file (for example, `/usr/share/zoneinfo/America/Los_Angeles` for Pacific Time). When `TimeZoneInfo.GetSystemTimeZones()` is called, it reads the list of available time zones from the `/usr/share/zoneinfo/zone.tab` file. This file provides a filtered list that excludes legacy time zones and avoids duplicate entries with different IDs. The issue arises when `TimeZoneInfo.FindSystemTimeZoneById(...)` is called with a legacy time zone ID that is not listed in `zone.tab`. In that case, the corresponding time zone object is still created and added to the `_systemTimeZones` cache. Later, when `TimeZoneInfo.GetSystemTimeZones(...)` is invoked, the list populated from `zone.tab` may include a duplicate time zone (same display name but different ID), such as both `UTC` and `UCT`. This can cause problems for UI applications that display time zone names, as duplicates appear in the list. The fix ensures that `GetSystemTimeZones()` returns only the filtered list derived from `zone.tab`, excluding any cached entries added from legacy or non-standard sources. This approach preserves cache consistency and performance, while preventing duplicates from appearing in the final list. Windows systems are not affected by this issue, so the change will have minimal impact there aside from shared code adjustments.
1 parent 2234a3b commit b3a8d0d

File tree

4 files changed

+118
-35
lines changed

4 files changed

+118
-35
lines changed

src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ public AdjustmentRule[] GetAdjustmentRules()
284284
return daylightDisplayName;
285285
}
286286

287-
private static void PopulateAllSystemTimeZones(CachedData cachedData)
287+
private static Dictionary<string, TimeZoneInfo> PopulateAllSystemTimeZones(CachedData cachedData)
288288
{
289289
Debug.Assert(Monitor.IsEntered(cachedData));
290290

@@ -295,13 +295,27 @@ private static void PopulateAllSystemTimeZones(CachedData cachedData)
295295

296296
if (Invariant)
297297
{
298-
return;
298+
return cachedData._systemTimeZones;
299299
}
300300

301+
const int initialCapacity = 430; // Should be enough for all time zones
302+
303+
// The filtered list that shouldn't have any duplicates.
304+
Dictionary<string, TimeZoneInfo> filteredTimeZones = new Dictionary<string, TimeZoneInfo>(capacity: initialCapacity, comparer: StringComparer.OrdinalIgnoreCase)
305+
{
306+
{ UtcId, s_utcTimeZone }
307+
};
308+
301309
foreach (string timeZoneId in GetTimeZoneIds())
302310
{
303-
TryGetTimeZone(timeZoneId, false, out _, out _, cachedData, alwaysFallbackToLocalMachine: true); // populate the cache
311+
if (TryGetTimeZone(timeZoneId, false, out TimeZoneInfo? timeZone, out _, cachedData, alwaysFallbackToLocalMachine: true) == TimeZoneInfoResult.Success &&
312+
timeZone is not null)
313+
{
314+
filteredTimeZones[timeZoneId] = timeZone;
315+
}
304316
}
317+
318+
return filteredTimeZones;
305319
}
306320

307321
/// <summary>

src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Win32.cs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,30 +69,32 @@ public AdjustmentRule[] GetAdjustmentRules()
6969
return null;
7070
}
7171

72-
private static void PopulateAllSystemTimeZones(CachedData cachedData)
72+
private static Dictionary<string, TimeZoneInfo> PopulateAllSystemTimeZones(CachedData cachedData)
7373
{
7474
Debug.Assert(Monitor.IsEntered(cachedData));
7575

76+
// Ensure _systemTimeZones is initialized. TryGetTimeZone with Invariant mode depend on that.
7677
cachedData._systemTimeZones ??= new Dictionary<string, TimeZoneInfo>(StringComparer.OrdinalIgnoreCase)
7778
{
7879
{ UtcId, s_utcTimeZone }
7980
};
8081

81-
if (Invariant)
82-
{
83-
return;
84-
}
85-
86-
using (RegistryKey? reg = Registry.LocalMachine.OpenSubKey(TimeZonesRegistryHive, writable: false))
82+
if (!Invariant)
8783
{
88-
if (reg != null)
84+
using (RegistryKey? reg = Registry.LocalMachine.OpenSubKey(TimeZonesRegistryHive, writable: false))
8985
{
90-
foreach (string keyName in reg.GetSubKeyNames())
86+
if (reg != null)
9187
{
92-
TryGetTimeZone(keyName, false, out _, out _, cachedData); // populate the cache
88+
foreach (string keyName in reg.GetSubKeyNames())
89+
{
90+
TryGetTimeZone(keyName, false, out _, out _, cachedData); // should update cache._systemTimeZones
91+
}
9392
}
9493
}
9594
}
95+
96+
// On Windows, there is no filtered list as _systemTimeZones always not having any duplicates.
97+
return cachedData._systemTimeZones;
9698
}
9799

98100
private static string? GetAlternativeId(string id, out bool idIsIana)

src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,20 @@ internal long GetLocalDateTimeNowTicks(DateTime utcNow, out bool isAmbiguous)
149149
return localTicks;
150150
}
151151

152+
153+
// System time zones list. This could be a superset of the list we return from TimeZoneInfo.GetSystemTimeZones on Linux and macOS.
154+
// This list can contain duplicate time zones with different legacy IDs.
152155
public Dictionary<string, TimeZoneInfo>? _systemTimeZones;
156+
157+
// The sorted readonly collection created after populating _systemTimeZones.
158+
// This collection returned to the callers of TimeZoneInfo.GetSystemTimeZones(skipSorting: false).
153159
public ReadOnlyCollection<TimeZoneInfo>? _readOnlySystemTimeZones;
160+
161+
// The unsorted readonly collection created after populating _systemTimeZones.
162+
// This collection returned to the callers of TimeZoneInfo.GetSystemTimeZones(skipSorting: true).
154163
public ReadOnlyCollection<TimeZoneInfo>? _readOnlyUnsortedSystemTimeZones;
164+
165+
// Alternative IDs usually be IANA names when running on Windows and will be Windows TZ IDs on Linux/macOS.
155166
public Dictionary<string, TimeZoneInfo>? _timeZonesUsingAlternativeIds;
156167
public bool _allSystemTimeZonesRead;
157168
public volatile DateTimeNowCache? _dateTimeNowCache;
@@ -757,17 +768,14 @@ public static ReadOnlyCollection<TimeZoneInfo> GetSystemTimeZones(bool skipSorti
757768
{
758769
if ((skipSorting ? cachedData._readOnlyUnsortedSystemTimeZones : cachedData._readOnlySystemTimeZones) is null)
759770
{
760-
if (!cachedData._allSystemTimeZonesRead)
761-
{
762-
PopulateAllSystemTimeZones(cachedData);
763-
cachedData._allSystemTimeZonesRead = true;
764-
}
771+
Dictionary<string, TimeZoneInfo>? filteredTimeZones = PopulateAllSystemTimeZones(cachedData);
772+
cachedData._allSystemTimeZonesRead = true;
765773

766-
if (cachedData._systemTimeZones != null)
774+
if (filteredTimeZones is not null)
767775
{
768-
// return a collection of the cached system time zones
769-
TimeZoneInfo[] array = new TimeZoneInfo[cachedData._systemTimeZones.Count];
770-
cachedData._systemTimeZones.Values.CopyTo(array, 0);
776+
// return a collection of the filtered cached system time zones
777+
TimeZoneInfo[] array = new TimeZoneInfo[filteredTimeZones.Count];
778+
filteredTimeZones.Values.CopyTo(array, 0);
771779

772780
if (!skipSorting)
773781
{
@@ -1205,18 +1213,15 @@ private static TimeZoneInfoResult TryGetTimeZoneUsingId(string id, bool dstDisab
12051213
}
12061214

12071215
// check the cache
1208-
if (cachedData._systemTimeZones != null)
1216+
if (cachedData._systemTimeZones?.TryGetValue(id, out value) is true)
12091217
{
1210-
if (cachedData._systemTimeZones.TryGetValue(id, out value))
1218+
if (dstDisabled && value._supportsDaylightSavingTime)
12111219
{
1212-
if (dstDisabled && value._supportsDaylightSavingTime)
1213-
{
1214-
// we found a cache hit but we want a time zone without DST and this one has DST data
1215-
value = CreateCustomTimeZone(value._id, value._baseUtcOffset, value._displayName, value._standardDisplayName);
1216-
}
1217-
1218-
return result;
1220+
// we found a cache hit but we want a time zone without DST and this one has DST data
1221+
value = CreateCustomTimeZone(value._id, value._baseUtcOffset, value._displayName, value._standardDisplayName);
12191222
}
1223+
1224+
return result;
12201225
}
12211226

12221227
if (Invariant)

src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2767,7 +2767,6 @@ public static void FijiTimeZoneTest()
27672767
}
27682768

27692769
[ConditionalFact]
2770-
[ActiveIssue("https://github.com/dotnet/runtime/issues/64111", TestPlatforms.Linux)]
27712770
[ActiveIssue("https://github.com/dotnet/runtime/issues/117731", TestPlatforms.Android)]
27722771
public static void NoBackwardTimeZones()
27732772
{
@@ -2776,14 +2775,77 @@ public static void NoBackwardTimeZones()
27762775
throw new SkipTestException("This test won't work on API level < 26");
27772776
}
27782777

2778+
// Clear cached data to always ensure predictable results
2779+
TimeZoneInfo.ClearCachedData();
2780+
if (SupportLegacyTimeZoneNames)
2781+
{
2782+
// Get legacy time zone "UCT" and ensure is not included in list returned by TimeZoneInfo.GetSystemTimeZones
2783+
// to ensure no duplicates display names as we should have "UTC" in the list.
2784+
TimeZoneInfo uct = TimeZoneInfo.FindSystemTimeZoneById("UCT");
2785+
}
2786+
27792787
ReadOnlyCollection<TimeZoneInfo> tzCollection = TimeZoneInfo.GetSystemTimeZones();
2780-
HashSet<String> tzDisplayNames = new HashSet<String>();
2788+
Dictionary<String, List<String>> displayNameToIds = new Dictionary<String, List<String>>();
27812789

27822790
foreach (TimeZoneInfo timezone in tzCollection)
27832791
{
2784-
tzDisplayNames.Add(timezone.DisplayName);
2792+
if (!displayNameToIds.ContainsKey(timezone.DisplayName))
2793+
{
2794+
displayNameToIds[timezone.DisplayName] = new List<String>();
2795+
}
2796+
displayNameToIds[timezone.DisplayName].Add(timezone.Id);
2797+
}
2798+
2799+
var duplicates = displayNameToIds.Where(kvp => kvp.Value.Count > 1).ToList();
2800+
if (duplicates.Count > 0)
2801+
{
2802+
var duplicateInfo = string.Join(", ", duplicates.Select(kvp =>
2803+
$"'{kvp.Key}' -> [{string.Join(", ", kvp.Value)}]"));
2804+
Assert.Fail($"Found {duplicates.Count} duplicate display name(s): {duplicateInfo}");
2805+
}
2806+
2807+
Assert.Equal(tzCollection.Count, displayNameToIds.Count);
2808+
}
2809+
2810+
[Fact]
2811+
public static void TestGetSystemTimeZones()
2812+
{
2813+
TimeZoneInfo.ClearCachedData(); // Start clean
2814+
ReadOnlyCollection<TimeZoneInfo> tzCollection1 = TimeZoneInfo.GetSystemTimeZones(skipSorting: true);
2815+
ReadOnlyCollection<TimeZoneInfo> tzCollection2 = TimeZoneInfo.GetSystemTimeZones(skipSorting: false);
2816+
Assert.Equal(tzCollection1.Count, tzCollection2.Count);
2817+
foreach (TimeZoneInfo timezone in tzCollection1)
2818+
{
2819+
Assert.Contains(timezone, tzCollection2);
2820+
}
2821+
2822+
TimeZoneInfo.ClearCachedData(); // Start again as clean
2823+
tzCollection1 = TimeZoneInfo.GetSystemTimeZones(skipSorting: false);
2824+
tzCollection2 = TimeZoneInfo.GetSystemTimeZones(skipSorting: true);
2825+
Assert.Equal(tzCollection1.Count, tzCollection2.Count);
2826+
foreach (TimeZoneInfo timezone in tzCollection1)
2827+
{
2828+
Assert.Contains(timezone, tzCollection2);
2829+
}
2830+
2831+
TimeZoneInfo.ClearCachedData(); // Start clean
2832+
if (SupportLegacyTimeZoneNames)
2833+
{
2834+
// Get legacy time zone "UCT" and ensure is not included in list returned by TimeZoneInfo.GetSystemTimeZones
2835+
// to ensure no duplicates display names as we should have "UTC" in the list.
2836+
TimeZoneInfo uct = TimeZoneInfo.FindSystemTimeZoneById("UCT");
27852837
}
2786-
Assert.Equal(tzCollection.Count, tzDisplayNames.Count);
2838+
2839+
ReadOnlyCollection<TimeZoneInfo> tzCollection3 = TimeZoneInfo.GetSystemTimeZones(skipSorting: true);
2840+
ReadOnlyCollection<TimeZoneInfo> tzCollection4 = TimeZoneInfo.GetSystemTimeZones(skipSorting: false);
2841+
Assert.Equal(tzCollection3.Count, tzCollection4.Count);
2842+
Assert.Equal(tzCollection1.Count, tzCollection4.Count);
2843+
foreach (TimeZoneInfo timezone in tzCollection3)
2844+
{
2845+
Assert.Contains(timezone, tzCollection4);
2846+
}
2847+
2848+
Assert.DoesNotContain(tzCollection4, t => t.Id == "UCT");
27872849
}
27882850

27892851
[Fact]

0 commit comments

Comments
 (0)