Skip to content

Commit 2ca093f

Browse files
committed
Thoroughly test the Windows timezone implementation
Fix the inconsistencies that were discovered.
1 parent e0f532c commit 2ca093f

File tree

7 files changed

+243
-41
lines changed

7 files changed

+243
-41
lines changed

core/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
66
import java.io.PrintWriter
77
import org.jetbrains.dokka.gradle.AbstractDokkaLeafTask
88
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
9+
import org.jetbrains.kotlin.konan.target.Family
910

1011
plugins {
1112
kotlin("multiplatform")
@@ -149,6 +150,14 @@ kotlin {
149150
compilations["test"].kotlinOptions {
150151
freeCompilerArgs += listOf("-trw")
151152
}
153+
if (konanTarget.family == Family.MINGW) {
154+
compilations["test"].cinterops {
155+
create("modern_api") {
156+
defFile("$projectDir/windows/test_cinterop/modern_api.def")
157+
headers("$projectDir/windows/test_cinterop/modern_api.h")
158+
}
159+
}
160+
}
152161
}
153162
sourceSets {
154163
commonMain {

core/native/src/internal/MonthDayTime.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,17 @@ internal class MonthDayTime(
129129
* Converts this [MonthDayTime] to an [Instant] in the given [year],
130130
* using the knowledge of the offset that's in effect at the resulting date-time.
131131
*/
132-
fun toInstant(year: Int, effectiveOffset: UtcOffset): Instant {
133-
val localDateTime = time.resolve(date.toLocalDate(year))
134-
return when (this.offset) {
135-
is OffsetResolver.WallClockOffset -> localDateTime.toInstant(effectiveOffset)
136-
is OffsetResolver.FixedOffset -> localDateTime.toInstant(this.offset.offset)
137-
}
132+
fun toInstant(year: Int, effectiveOffset: UtcOffset): Instant = when (this.offset) {
133+
is OffsetResolver.WallClockOffset -> toLocalDateTime(year).toInstant(effectiveOffset)
134+
is OffsetResolver.FixedOffset -> toLocalDateTime(year).toInstant(this.offset.offset)
138135
}
139136

137+
/**
138+
* Converts this [MonthDayTime] to a [LocalDateTime] in the given [year],
139+
* ignoring the UTC offset.
140+
*/
141+
fun toLocalDateTime(year: Int): LocalDateTime = time.resolve(date.toLocalDate(year))
142+
140143
/**
141144
* Describes how the offset in which the local date-time is expressed is defined.
142145
*/
@@ -160,7 +163,6 @@ internal class MonthDayTime(
160163
* The local time of day at which the transition occurs.
161164
*/
162165
class TransitionLocaltime(val seconds: Int) {
163-
constructor(time: LocalTime) : this(time.toSecondOfDay())
164166

165167
constructor(hour: Int, minute: Int, second: Int) : this(hour * 3600 + minute * 60 + second)
166168

core/native/src/internal/TimeZoneRules.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,12 @@ internal class RecurringZoneRules(
128128
val offsetBefore: UtcOffset,
129129
val offsetAfter: UtcOffset,
130130
) {
131-
override fun toString(): String = "transitioning to $offsetAfter on $transitionDateTime"
131+
override fun toString(): String = "transitioning from $offsetBefore to $offsetAfter on $transitionDateTime"
132132
}
133133

134134
// see `tzparse` in https://data.iana.org/time-zones/tzdb/localtime.c: looks like there's no guarantees about
135135
// a way to pre-sort the transitions, so we have to do it for each query separately.
136-
private fun rulesForYear(year: Int): List<Rule<Instant>> {
136+
fun rulesForYear(year: Int): List<Rule<Instant>> {
137137
return rules.map { rule ->
138138
val transitionInstant = rule.transitionDateTime.toInstant(year, rule.offsetBefore)
139139
Rule(transitionInstant, rule.offsetBefore, rule.offsetAfter)

core/windows/src/internal/TzdbInRegistry.kt

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,52 @@ internal class TzdbInRegistry: TimeZoneDatabase {
1616
// When Kotlin/Native drops support for Windows 7, we should investigate moving to the ICU.
1717
private val windowsToRules: Map<String, TimeZoneRules> = buildMap {
1818
processTimeZonesInRegistry { name, recurring, historic ->
19-
val transitions = recurring.transitions?.let { listOf(it.first, it.second) } ?: emptyList()
20-
val rules = if (historic.isEmpty()) {
21-
TimeZoneRules(recurring.standardOffset, RecurringZoneRules(transitions))
22-
} else {
19+
val recurringRules = RecurringZoneRules(recurring.transitions)
20+
val rules = run {
21+
val offsets = mutableListOf<UtcOffset>()
2322
val transitionEpochSeconds = mutableListOf<Long>()
24-
val offsets = mutableListOf(historic[0].second.standardOffset)
2523
for ((year, record) in historic) {
26-
if (record.transitions == null) continue
27-
val (trans1, trans2) = record.transitions
28-
val transTime1 = trans1.transitionDateTime.toInstant(year, trans1.offsetBefore)
29-
val transTime2 = trans2.transitionDateTime.toInstant(year, trans2.offsetBefore)
30-
if (transTime2 >= transTime1) {
31-
transitionEpochSeconds.add(transTime1.epochSeconds)
32-
offsets.add(trans1.offsetAfter)
33-
transitionEpochSeconds.add(transTime2.epochSeconds)
34-
offsets.add(trans2.offsetAfter)
35-
} else {
36-
transitionEpochSeconds.add(transTime2.epochSeconds)
37-
offsets.add(trans2.offsetAfter)
38-
transitionEpochSeconds.add(transTime1.epochSeconds)
39-
offsets.add(trans1.offsetAfter)
24+
when (record) {
25+
is PerYearZoneRulesDataWithoutTransitions -> {
26+
val lastOffset = offsets.lastOrNull()
27+
if (lastOffset != record.standardOffset) {
28+
lastOffset?.let {
29+
transitionEpochSeconds.add(START_OF_YEAR.toInstant(year, it).epochSeconds)
30+
}
31+
offsets.add(record.standardOffset)
32+
}
33+
}
34+
is PerYearZoneRulesDataWithTransitions -> {
35+
val toDstTime = record.daylightTransitionTime.toLocalDateTime(year)
36+
val toStdTime = record.standardTransitionTime.toLocalDateTime(year)
37+
val initialOffset =
38+
if (toDstTime < toStdTime) record.standardOffset else record.daylightOffset
39+
if (offsets.isEmpty()) { offsets.add(initialOffset) }
40+
val newYearTransition: RecurringZoneRules.Rule<MonthDayTime> =
41+
RecurringZoneRules.Rule(
42+
transitionDateTime = START_OF_YEAR,
43+
offsetBefore = offsets.last(),
44+
offsetAfter = initialOffset,
45+
)
46+
// The order is important: newYearTransition must be overwritten by the explicit Jan 1st transition
47+
mapOf(
48+
LocalDate(year, Month.JANUARY, 1).atTime(0, 0) to newYearTransition,
49+
toDstTime to record.daylightTransition,
50+
toStdTime to record.standardTransition
51+
).toList().sortedBy(Pair<LocalDateTime, *>::first).forEach { (time, transition) ->
52+
// occasionally, there are transitions that have no effect at all, whose purpose is
53+
// just to fulfill the contract that there are either two or zero transitions per year.
54+
// We skip such transitions entirely to simplify handling.
55+
if (offsets.last() != transition.offsetAfter) {
56+
transitionEpochSeconds.add(time.toInstant(transition.offsetBefore).epochSeconds)
57+
offsets.add(transition.offsetAfter)
58+
}
59+
}
60+
}
4061
}
4162
}
42-
TimeZoneRules(transitionEpochSeconds, offsets, RecurringZoneRules(transitions))
63+
if (offsets.isEmpty()) { offsets.add(recurring.offsetAtYearStart(2020)) }
64+
TimeZoneRules(transitionEpochSeconds, offsets, recurringRules)
4365
}
4466
put(name, rules)
4567
}
@@ -216,14 +238,14 @@ private class RegistryTimeZoneInfoBuffer private constructor(
216238
val standardDate = (buffer + 12)!!.reinterpret<SYSTEMTIME>().pointed
217239
val daylightDate = (buffer + 28)!!.reinterpret<SYSTEMTIME>().pointed
218240
// calculating the things we're interested in
219-
val standardOffset = UtcOffset(minutes = -(standardBias + bias))
220-
val daylightOffset = UtcOffset(minutes = -(daylightBias + bias))
221241
if (daylightDate.wMonth == 0.convert<WORD>()) {
222-
return PerYearZoneRulesData(standardOffset, null)
242+
return PerYearZoneRulesDataWithoutTransitions(UtcOffset(minutes = -bias))
223243
}
224-
val changeToDst = RecurringZoneRules.Rule(daylightDate.toMonthDayTime(), standardOffset, daylightOffset)
225-
val changeToStd = RecurringZoneRules.Rule(standardDate.toMonthDayTime(), daylightOffset, standardOffset)
226-
return PerYearZoneRulesData(standardOffset, changeToDst to changeToStd)
244+
val standardOffset = UtcOffset(minutes = -(standardBias + bias))
245+
val daylightOffset = UtcOffset(minutes = -(daylightBias + bias))
246+
val changeToDst = daylightDate.toMonthDayTime()
247+
val changeToStd = standardDate.toMonthDayTime()
248+
return PerYearZoneRulesDataWithTransitions(standardOffset, daylightOffset, changeToDst, changeToStd)
227249
}
228250
}
229251

@@ -270,13 +292,42 @@ private fun SYSTEMTIME.toMonthDayTime(): MonthDayTime {
270292
return MonthDayTime(MonthDayOfYear(month, transitionDay), localTime, MonthDayTime.OffsetResolver.WallClockOffset)
271293
}
272294

273-
private class PerYearZoneRulesData(
295+
private sealed interface PerYearZoneRulesData {
296+
val transitions: List<RecurringZoneRules.Rule<MonthDayTime>>
297+
fun offsetAtYearStart(year: Int): UtcOffset
298+
}
299+
300+
private class PerYearZoneRulesDataWithoutTransitions(
301+
val standardOffset: UtcOffset
302+
) : PerYearZoneRulesData {
303+
override val transitions: List<RecurringZoneRules.Rule<MonthDayTime>>
304+
get() = emptyList()
305+
306+
override fun offsetAtYearStart(year: Int): UtcOffset = standardOffset
307+
308+
override fun toString(): String = "standard offset is $standardOffset"
309+
}
310+
311+
private class PerYearZoneRulesDataWithTransitions(
274312
val standardOffset: UtcOffset,
275-
val transitions: Pair<RecurringZoneRules.Rule<MonthDayTime>, RecurringZoneRules.Rule<MonthDayTime>>?,
276-
) {
277-
override fun toString(): String = "standard offset is $standardOffset" + (transitions?.let {
278-
", the transitions: ${it.first}, ${it.second}"
279-
} ?: "")
313+
val daylightOffset: UtcOffset,
314+
val daylightTransitionTime: MonthDayTime,
315+
val standardTransitionTime: MonthDayTime,
316+
) : PerYearZoneRulesData {
317+
override val transitions: List<RecurringZoneRules.Rule<MonthDayTime>>
318+
get() = listOf(daylightTransition, standardTransition)
319+
320+
val daylightTransition get() =
321+
RecurringZoneRules.Rule(daylightTransitionTime, offsetBefore = standardOffset, offsetAfter = daylightOffset)
322+
323+
val standardTransition get() =
324+
RecurringZoneRules.Rule(standardTransitionTime, offsetBefore = daylightOffset, offsetAfter = standardOffset)
325+
326+
override fun offsetAtYearStart(year: Int): UtcOffset = standardOffset
327+
328+
override fun toString(): String = "standard offset is $standardOffset" +
329+
", daylight offset is $daylightOffset" +
330+
", the transitions: $daylightTransitionTime, $standardTransitionTime"
280331
}
281332

282333
private fun getLastWindowsError(): String = memScoped {
@@ -292,3 +343,9 @@ private fun getLastWindowsError(): String = memScoped {
292343
)
293344
buf.value!!.toKStringFromUtf16().also { LocalFree(buf.ptr) }
294345
}
346+
347+
private val START_OF_YEAR = MonthDayTime(
348+
date = JulianDayOfYear(0),
349+
time = MonthDayTime.TransitionLocaltime(0),
350+
offset = MonthDayTime.OffsetResolver.WallClockOffset,
351+
)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright 2019-2024 JetBrains s.r.o. and contributors.
3+
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
4+
*/
5+
6+
@file:OptIn(ExperimentalForeignApi::class)
7+
package kotlinx.datetime.test
8+
9+
import kotlinx.cinterop.*
10+
import kotlinx.datetime.*
11+
import kotlinx.datetime.internal.*
12+
import platform.windows.*
13+
import kotlin.test.*
14+
import kotlin.time.Duration.Companion.milliseconds
15+
16+
class TimeZoneRulesCompleteTest {
17+
18+
/** Tests that all transitions that our system recognizes are actually there. */
19+
@Test
20+
fun iterateOverAllTimezones() {
21+
val tzdb = TzdbInRegistry()
22+
memScoped {
23+
val inputSystemtime = alloc<SYSTEMTIME>()
24+
val outputSystemtime = alloc<SYSTEMTIME>()
25+
val dtzi = alloc<DYNAMIC_TIME_ZONE_INFORMATION>()
26+
var i: DWORD = 0u
27+
while (true) {
28+
when (val dwResult: Int = EnumDynamicTimeZoneInformation(i++, dtzi.ptr).toInt()) {
29+
ERROR_NO_MORE_ITEMS -> break
30+
ERROR_SUCCESS -> {
31+
val windowsName = dtzi.TimeZoneKeyName.toKString()
32+
val id = windowsToStandard[windowsName]
33+
if (id == null) {
34+
assertTrue(
35+
windowsName == "Mid-Atlantic Standard Time" ||
36+
windowsName == "Kamchatka Standard Time", windowsName
37+
)
38+
continue
39+
}
40+
val rules = tzdb.rulesForId(id)
41+
fun checkAtInstant(instant: Instant) {
42+
val ldt = instant.toLocalDateTime(dtzi, inputSystemtime.ptr, outputSystemtime.ptr)
43+
val offset = rules.infoAtInstant(instant)
44+
val ourLdt = instant.toLocalDateTime(offset)
45+
assertEquals(ldt, ourLdt, "in zone $windowsName, at $instant (our guess at the offset is $offset)")
46+
}
47+
fun checkTransition(instant: Instant) {
48+
checkAtInstant(instant - 2.milliseconds)
49+
checkAtInstant(instant)
50+
}
51+
// check historical data
52+
for (transition in rules.transitionEpochSeconds) {
53+
checkTransition(Instant.fromEpochSeconds(transition))
54+
}
55+
// check recurring rules
56+
if (windowsName != "Morocco Standard Time" && windowsName != "West Bank Standard Time") {
57+
// we skip checking these two time zones because Windows does something arbitrary with them
58+
// after 2030. For example, Morocco DST transitions are linked to the month of Ramadan,
59+
// and after 2030, Windows doesn't seem to calculate Ramadan properly, but also, it doesn't
60+
// follow the rules stored in the registry. Odd, but it doesn't seem worth it trying to
61+
// reverse engineer results that aren't even correct.
62+
val lastTransition = Instant.fromEpochSeconds(
63+
rules.transitionEpochSeconds.lastOrNull() ?: 1715000000 // arbitrary time
64+
)
65+
val lastTransitionYear = lastTransition.toLocalDateTime(TimeZone.UTC).year
66+
for (year in lastTransitionYear + 1..lastTransitionYear + 15) {
67+
val rulesForYear = rules.recurringZoneRules!!.rulesForYear(year)
68+
if (rulesForYear.isEmpty()) {
69+
checkAtInstant(
70+
LocalDate(year, 6, 1).atStartOfDayIn(TimeZone.UTC)
71+
)
72+
} else {
73+
for (rule in rulesForYear) {
74+
checkTransition(rule.transitionDateTime)
75+
}
76+
}
77+
}
78+
}
79+
}
80+
else -> error("Unexpected error code $dwResult")
81+
}
82+
}
83+
}
84+
}
85+
}
86+
87+
private fun Instant.toLocalDateTime(
88+
tzinfo: DYNAMIC_TIME_ZONE_INFORMATION,
89+
inputBuffer: CPointer<SYSTEMTIME>,
90+
outputBuffer: CPointer<SYSTEMTIME>
91+
): LocalDateTime {
92+
toLocalDateTime(TimeZone.UTC).toSystemTime(inputBuffer)
93+
SystemTimeToTzSpecificLocalTimeEx(tzinfo.ptr, inputBuffer, outputBuffer)
94+
return outputBuffer.pointed.toLocalDateTime()
95+
}
96+
97+
private fun LocalDateTime.toSystemTime(outputBuffer: CPointer<SYSTEMTIME>) {
98+
require(year in 1601..30827)
99+
outputBuffer.pointed.apply {
100+
wYear = year.convert()
101+
wMonth = monthNumber.convert()
102+
wDay = dayOfMonth.convert()
103+
wDayOfWeek = (dayOfWeek.isoDayNumber % 7).convert()
104+
wHour = hour.convert()
105+
wMinute = minute.convert()
106+
wSecond = second.convert()
107+
wMilliseconds = (nanosecond / (NANOS_PER_ONE / MILLIS_PER_ONE)).convert()
108+
}
109+
}
110+
111+
private fun SYSTEMTIME.toLocalDateTime(): LocalDateTime =
112+
LocalDateTime(
113+
year = wYear.convert(),
114+
monthNumber = wMonth.convert(),
115+
dayOfMonth = wDay.convert(),
116+
hour = wHour.convert(),
117+
minute = wMinute.convert(),
118+
second = wSecond.convert(),
119+
nanosecond = wMilliseconds.convert<Int>() * (NANOS_PER_ONE / MILLIS_PER_ONE)
120+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package = kotlinx.datetime.internal
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#define WIN32_LEAN_AND_MEAN
2+
#include <timezoneapi.h>
3+
4+
BOOL SystemTimeToTzSpecificLocalTimeEx(
5+
const DYNAMIC_TIME_ZONE_INFORMATION *lpTimeZoneInformation,
6+
const SYSTEMTIME *lpUniversalTime,
7+
LPSYSTEMTIME lpLocalTime
8+
);
9+
10+
DWORD EnumDynamicTimeZoneInformation(
11+
const DWORD dwIndex,
12+
PDYNAMIC_TIME_ZONE_INFORMATION lpTimeZoneInformation
13+
);

0 commit comments

Comments
 (0)