Skip to content

Commit 52902bd

Browse files
authored
Windows: extrapolate the earliest timezone transition record to the past (#473)
Additionally, improve our test suite so that it better describes the discrepancies between our estimates and the opinion Windows holds. Fixes #440
1 parent b289d0b commit 52902bd

File tree

2 files changed

+145
-62
lines changed

2 files changed

+145
-62
lines changed

core/windows/src/internal/TzdbInRegistry.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,11 @@ private fun HKEYVar.readHistoricDataFromRegistry(
166166
withRegistryKey(tzHKey, "Dynamic DST", { emptyList() }) { dynDstHKey ->
167167
val firstEntry = dwordBuffer.readValue(dynDstHKey, "FirstEntry")
168168
val lastEntry = dwordBuffer.readValue(dynDstHKey, "LastEntry")
169-
(firstEntry..lastEntry).map { year ->
170-
year to zoneRulesBuffer.readZoneRules(dynDstHKey, year.toString())
169+
val registryData = (firstEntry..lastEntry).map { year ->
170+
zoneRulesBuffer.readZoneRules(dynDstHKey, year.toString())
171+
}
172+
(1970..lastEntry).map { year ->
173+
year to registryData[(year - firstEntry).coerceAtLeast(0)]
171174
}
172175
}
173176

@@ -339,7 +342,7 @@ private class PerYearZoneRulesDataWithTransitions(
339342
", the transitions: $daylightTransitionTime, $standardTransitionTime"
340343
}
341344

342-
private fun getLastWindowsError(): String = memScoped {
345+
internal fun getLastWindowsError(): String = memScoped {
343346
val buf = alloc<CArrayPointerVar<WCHARVar>>()
344347
FormatMessage!!(
345348
(FORMAT_MESSAGE_ALLOCATE_BUFFER or FORMAT_MESSAGE_FROM_SYSTEM or FORMAT_MESSAGE_IGNORE_INSERTS).toUInt(),

core/windows/test/TimeZoneRulesCompleteTest.kt

Lines changed: 139 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.datetime.*
1212
import kotlinx.datetime.internal.*
1313
import platform.windows.*
1414
import kotlin.test.*
15+
import kotlin.time.Duration.Companion.hours
1516
import kotlin.time.Duration.Companion.milliseconds
1617

1718
class TimeZoneRulesCompleteTest {
@@ -25,7 +26,34 @@ class TimeZoneRulesCompleteTest {
2526
val inputSystemtime = alloc<SYSTEMTIME>()
2627
val outputSystemtime = alloc<SYSTEMTIME>()
2728
val dtzi = alloc<DYNAMIC_TIME_ZONE_INFORMATION>()
29+
fun offsetAtAccordingToWindows(instant: Instant): Int {
30+
val ldtAccordingToWindows =
31+
instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
32+
return (ldtAccordingToWindows.toInstant(UtcOffset.ZERO) - instant).inWholeSeconds.toInt()
33+
}
34+
fun transitionsAccordingToWindows(year: Int): List<OffsetInfo> = buildList {
35+
var lastInstant = LocalDate(year, Month.JANUARY, 1)
36+
.atTime(0, 0).toInstant(UtcOffset.ZERO)
37+
var lastOffsetAccordingToWindows = offsetAtAccordingToWindows(lastInstant)
38+
repeat(LocalDate(year, Month.DECEMBER, 31).dayOfYear - 1) {
39+
val instant = lastInstant + 24.hours
40+
val offset = offsetAtAccordingToWindows(instant)
41+
if (lastOffsetAccordingToWindows != offset) {
42+
add(OffsetInfo(
43+
binarySearchInstant(lastInstant, instant) {
44+
offset == offsetAtAccordingToWindows(it)
45+
},
46+
UtcOffset(seconds = lastOffsetAccordingToWindows),
47+
UtcOffset(seconds = offset)
48+
))
49+
lastOffsetAccordingToWindows = offset
50+
}
51+
lastInstant = instant
52+
}
53+
}
54+
val issues = mutableListOf<IncompatibilityWithWindowsRegistry>()
2855
var i: DWORD = 0u
56+
val currentYear = Clock.System.todayIn(TimeZone.UTC).year
2957
while (true) {
3058
when (val dwResult: Int = EnumDynamicTimeZoneInformation(i++, dtzi.ptr).toInt()) {
3159
ERROR_NO_MORE_ITEMS -> break
@@ -40,79 +68,103 @@ class TimeZoneRulesCompleteTest {
4068
continue
4169
}
4270
val rules = tzdb.rulesForId(id)
43-
fun checkAtInstant(instant: Instant) {
71+
fun MutableList<Mismatch>.checkAtInstant(instant: Instant) {
4472
val ldt = instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
4573
val offset = rules.infoAtInstant(instant)
4674
val ourLdt = instant.toLocalDateTime(offset)
47-
if (ldt != ourLdt) {
48-
val offsetsAccordingToWindows = buildList {
49-
var date = LocalDate(ldt.year, Month.JANUARY, 1)
50-
while (date.year == ldt.year) {
51-
val instant = date.atTime(0, 0).toInstant(UtcOffset.ZERO)
52-
val ldtAccordingToWindows =
53-
instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
54-
val offsetAccordingToWindows =
55-
(ldtAccordingToWindows.toInstant(UtcOffset.ZERO) - instant).inWholeSeconds
56-
add(date to offsetAccordingToWindows)
57-
date = date.plus(1, DateTimeUnit.DAY)
75+
if (ldt != ourLdt) add(Mismatch(ourLdt, ldt, instant))
76+
}
77+
fun MutableList<Mismatch>.checkTransition(instant: Instant) {
78+
checkAtInstant(instant - 2.milliseconds)
79+
checkAtInstant(instant)
80+
}
81+
val mismatches = buildList {
82+
// check historical data
83+
if (windowsName == "Central Brazilian Standard Time") {
84+
// This one reports transitions on Jan 1st for years 1970..2003, but the registry contains transitions
85+
// on the first Thursday of January.
86+
// Neither of these is correct: https://en.wikipedia.org/wiki/Daylight_saving_time_in_Brazil
87+
for (transition in rules.transitionEpochSeconds) {
88+
val instant = Instant.fromEpochSeconds(transition)
89+
if (instant.toLocalDateTime(TimeZone.UTC).year >= 2004) {
90+
checkTransition(instant)
5891
}
5992
}
60-
val rawData = memScoped {
61-
val hKey = alloc<HKEYVar>()
62-
RegOpenKeyExW(HKEY_LOCAL_MACHINE!!, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones\\$windowsName", 0u, KEY_READ.toUInt(), hKey.ptr)
63-
try {
64-
val cbDataBuffer = alloc<DWORDVar>()
65-
val SIZE_BYTES = 44
66-
val zoneInfoBuffer = allocArray<BYTEVar>(SIZE_BYTES)
67-
cbDataBuffer.value = SIZE_BYTES.convert()
68-
RegQueryValueExW(hKey.value, "TZI", null, null, zoneInfoBuffer, cbDataBuffer.ptr)
69-
zoneInfoBuffer.readBytes(SIZE_BYTES).toHexString()
70-
} finally {
71-
RegCloseKey(hKey.value)
93+
} else {
94+
for (transition in rules.transitionEpochSeconds) {
95+
checkTransition(Instant.fromEpochSeconds(transition))
96+
}
97+
}
98+
// check recurring rules
99+
if (windowsName !in timeZonesWithBrokenRecurringRules) {
100+
for (year in 1970..currentYear + 1) {
101+
val rulesForYear = rules.recurringZoneRules!!.rulesForYear(year)
102+
if (rulesForYear.isEmpty()) {
103+
checkAtInstant(
104+
LocalDate(year, 6, 1).atStartOfDayIn(TimeZone.UTC)
105+
)
106+
} else {
107+
for (rule in rulesForYear) {
108+
checkTransition(rule.transitionDateTime)
109+
}
72110
}
73111
}
74-
throw AssertionError(
75-
"Expected $ldt, got $ourLdt in zone $windowsName at $instant (our guess at the offset is $offset)." +
76-
"The rules are $rules, and the offsets throughout the year according to Windows are: $offsetsAccordingToWindows; the raw data for the recurring rules is $rawData"
77-
)
78112
}
79113
}
80-
fun checkTransition(instant: Instant) {
81-
checkAtInstant(instant - 2.milliseconds)
82-
checkAtInstant(instant)
83-
}
84-
// check historical data
85-
for (transition in rules.transitionEpochSeconds) {
86-
checkTransition(Instant.fromEpochSeconds(transition))
87-
}
88-
// check recurring rules
89-
if (windowsName !in strangeTimeZones) {
90-
// we skip checking these time zones because Windows does something arbitrary with them
91-
// after 2030. For example, Morocco DST transitions are linked to the month of Ramadan,
92-
// and after 2030, Windows doesn't seem to calculate Ramadan properly, but also, it doesn't
93-
// follow the rules stored in the registry. Odd, but it doesn't seem worth it trying to
94-
// reverse engineer results that aren't even correct.
95-
val lastTransition = Instant.fromEpochSeconds(
96-
rules.transitionEpochSeconds.lastOrNull() ?: 1715000000 // arbitrary time
97-
)
98-
val lastTransitionYear = lastTransition.toLocalDateTime(TimeZone.UTC).year
99-
for (year in lastTransitionYear + 1..lastTransitionYear + 15) {
100-
val rulesForYear = rules.recurringZoneRules!!.rulesForYear(year)
101-
if (rulesForYear.isEmpty()) {
102-
checkAtInstant(
103-
LocalDate(year, 6, 1).atStartOfDayIn(TimeZone.UTC)
104-
)
105-
} else {
106-
for (rule in rulesForYear) {
107-
checkTransition(rule.transitionDateTime)
114+
if (mismatches.isNotEmpty()) {
115+
val mismatchYears =
116+
mismatches.map { it.instant.toLocalDateTime(TimeZone.UTC).year }.distinct()
117+
val rawData = memScoped {
118+
val hKey = alloc<HKEYVar>()
119+
RegOpenKeyExW(HKEY_LOCAL_MACHINE!!, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones\\$windowsName", 0u, KEY_READ.toUInt(), hKey.ptr)
120+
try {
121+
val cbDataBuffer = alloc<DWORDVar>()
122+
val SIZE_BYTES = 44
123+
val zoneInfoBuffer = allocArray<BYTEVar>(SIZE_BYTES)
124+
cbDataBuffer.value = SIZE_BYTES.convert()
125+
RegQueryValueExW(hKey.value, "TZI", null, null, zoneInfoBuffer, cbDataBuffer.ptr)
126+
zoneInfoBuffer.readBytes(SIZE_BYTES).toHexString()
127+
} finally {
128+
RegCloseKey(hKey.value)
129+
}
130+
}
131+
val historicData = memScoped {
132+
val hKey = alloc<HKEYVar>()
133+
RegOpenKeyExW(HKEY_LOCAL_MACHINE!!, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones\\$windowsName\\Dynamic DST", 0u, KEY_READ.toUInt(), hKey.ptr)
134+
try {
135+
val dwordBuffer = alloc<DWORDVar>()
136+
val cbDataBuffer = alloc<DWORDVar>().apply { value = sizeOf<DWORDVar>().convert() }
137+
RegQueryValueExW(hKey.value!!, "FirstEntry", null, null, dwordBuffer.ptr.reinterpret(), cbDataBuffer.ptr)
138+
val firstEntry = dwordBuffer.value.toInt()
139+
RegQueryValueExW(hKey.value!!, "LastEntry", null, null, dwordBuffer.ptr.reinterpret(), cbDataBuffer.ptr)
140+
val lastEntry = dwordBuffer.value.toInt()
141+
val SIZE_BYTES = 44
142+
val zoneInfoBuffer = allocArray<BYTEVar>(SIZE_BYTES)
143+
cbDataBuffer.value = SIZE_BYTES.convert()
144+
(firstEntry..lastEntry).map { year ->
145+
RegQueryValueExW(hKey.value!!, year.toString(), null, null, zoneInfoBuffer, cbDataBuffer.ptr)
146+
year to zoneInfoBuffer.readBytes(SIZE_BYTES).toHexString()
108147
}
148+
} finally {
149+
RegCloseKey(hKey.value)
109150
}
110151
}
152+
issues.add(
153+
IncompatibilityWithWindowsRegistry(
154+
timeZoneName = windowsName,
155+
dataOnAffectedYears = mismatchYears.flatMap {
156+
transitionsAccordingToWindows(it)
157+
},
158+
recurringRules = rawData,
159+
historicData = historicData,
160+
mismatches = mismatches,
161+
))
111162
}
112163
}
113164
else -> error("Unexpected error code $dwResult")
114165
}
115166
}
167+
if (issues.isNotEmpty()) throw AssertionError(issues.toString())
116168
}
117169
}
118170
}
@@ -123,7 +175,8 @@ private fun Instant.toLocalDateTime(
123175
outputBuffer: CPointer<SYSTEMTIME>
124176
): LocalDateTime {
125177
toLocalDateTime(TimeZone.UTC).toSystemTime(inputBuffer)
126-
SystemTimeToTzSpecificLocalTimeEx(tzinfo.ptr, inputBuffer, outputBuffer)
178+
val result = SystemTimeToTzSpecificLocalTimeEx(tzinfo.ptr, inputBuffer, outputBuffer)
179+
check(result != 0) { "SystemTimeToTzSpecificLocalTimeEx failed: ${getLastWindowsError()}" }
127180
return outputBuffer.pointed.toLocalDateTime()
128181
}
129182

@@ -152,7 +205,34 @@ private fun SYSTEMTIME.toLocalDateTime(): LocalDateTime =
152205
nanosecond = wMilliseconds.convert<Int>() * (NANOS_PER_ONE / MILLIS_PER_ONE)
153206
)
154207

155-
private val strangeTimeZones = listOf(
156-
"Morocco Standard Time", "West Bank Standard Time", "Iran Standard Time", "Syria Standard Time",
208+
private val timeZonesWithBrokenRecurringRules = listOf(
157209
"Paraguay Standard Time"
158210
)
211+
212+
private fun binarySearchInstant(instant1: Instant, instant2: Instant, predicate: (Instant) -> Boolean): Instant {
213+
var low = instant1
214+
var high = instant2
215+
while (low < high) {
216+
val mid = low + (high - low) / 2
217+
if (predicate(mid)) {
218+
high = mid
219+
} else {
220+
low = mid + 1.milliseconds
221+
}
222+
}
223+
return low
224+
}
225+
226+
private data class IncompatibilityWithWindowsRegistry(
227+
val timeZoneName: String,
228+
val dataOnAffectedYears: List<OffsetInfo>,
229+
val recurringRules: String,
230+
val historicData: List<Pair<Int, String>>,
231+
val mismatches: List<Mismatch>,
232+
)
233+
234+
private data class Mismatch(
235+
val ourGuess: LocalDateTime,
236+
val windowsGuess: LocalDateTime,
237+
val instant: Instant,
238+
)

0 commit comments

Comments
 (0)