Skip to content

Commit c6b30ef

Browse files
xerialclaude
andauthored
fix: Avoid POSIX localtime_r in Scala Native log formatter on Windows (#519)
## Summary - MSVC's CRT does not export POSIX `localtime_r`, so downstream Scala Native consumers of `uni-core` that build for Windows hit a linker error (`unresolved external symbol localtime_r`). wvlet caught this while producing `wvlet.dll` on `windows-2022`: https://github.com/wvlet/wvlet/actions/runs/25133797459/job/73666762135 - Gate the existing `localtime_r` + `strftime` path on `scala.scalanative.meta.LinktimeInfo.isWindows`, and fall back to a pure-Scala UTC formatter on Windows (Howard Hinnant's civil-from-days algorithm). `LinktimeInfo.isWindows` is a link-time constant, so POSIX targets keep their existing behavior unchanged via DCE. - The Windows path drops the system timezone offset (emits `Z`), which matches what the Scala.js path already does (`Date.toISOString()`). ## Test plan - [x] `coreNative/test` passes locally on macOS (`./sbt uniNative/test` — 1330 passed) - [x] `scalafmtCheckAll` - [ ] Verified end-to-end by bumping `UNI_VERSION` in [`wvlet/wvlet`](https://github.com/wvlet/wvlet/blob/main/build.sbt) after this lands and is published, and re-running the `Build native on Windows (x64)` job that originally caught it. ## Follow-up Add a `windows-latest` Scala Native job to `uni`'s `test.yml` so this class of regression is caught at the source rather than by downstream projects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ab38e07 commit c6b30ef

1 file changed

Lines changed: 67 additions & 7 deletions

File tree

uni-core/.native/src/main/scala/wvlet/uni/log/LogTimestampFormatter.scala

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,36 @@
1313
*/
1414
package wvlet.uni.log
1515

16+
import scala.scalanative.meta.LinktimeInfo
1617
import scalanative.posix.time.*
1718
import scalanative.unsafe.*
1819
import scalanative.unsigned.*
1920
import scalanative.libc.stdio.*
2021
import scalanative.libc.string.*
2122

2223
/**
23-
* Use strftime to format timestamps in Scala Native
24+
* Format timestamps in Scala Native.
25+
*
26+
* On POSIX targets we delegate to `strftime` + `localtime_r` so the system timezone offset (e.g.
27+
* `+0900`) is preserved. MSVC's CRT does not provide `localtime_r`, so on Windows we fall back to
28+
* a pure-Scala UTC formatter (suffix `Z`). `LinktimeInfo.isWindows` is resolved at link time, so
29+
* the unused branch is dead-code-eliminated and the POSIX path keeps its existing behavior.
2430
*/
2531
object LogTimestampFormatter:
2632

27-
private def format(pattern: CString, timeMillis: Long): String = Zone {
33+
def formatTimestamp(timeMillis: Long): String =
34+
if LinktimeInfo.isWindows then
35+
formatUtc(timeMillis, withSpace = true)
36+
else
37+
formatPosix(c"%Y-%m-%d %H:%M:%S.", timeMillis)
38+
39+
def formatTimestampWithoutSpace(timeMillis: Long): String =
40+
if LinktimeInfo.isWindows then
41+
formatUtc(timeMillis, withSpace = false)
42+
else
43+
formatPosix(c"%Y-%m-%dT%H:%M:%S.", timeMillis)
44+
45+
private def formatPosix(pattern: CString, timeMillis: Long): String = Zone {
2846
val ttPtr = alloc[time_t]()
2947
!ttPtr = (timeMillis / 1000).toSize
3048
val tmPtr = alloc[tm]()
@@ -48,11 +66,53 @@ object LogTimestampFormatter:
4866
fromCString(buf)
4967
}
5068

51-
def formatTimestamp(timeMillis: Long): String = format(c"%Y-%m-%d %H:%M:%S.", timeMillis)
69+
// Pure-Scala UTC formatter used on Windows. Howard Hinnant's
70+
// civil-from-days algorithm gives correct year/month/day for any epoch
71+
// millis without leaning on POSIX-only C symbols.
72+
private def formatUtc(timeMillis: Long, withSpace: Boolean): String =
73+
val seconds = Math.floorDiv(timeMillis, 1000L)
74+
val millis = Math.floorMod(timeMillis, 1000L)
75+
val daysSinceEpoch = Math.floorDiv(seconds, 86400L)
76+
val secondsInDay = Math.floorMod(seconds, 86400L)
77+
78+
val hour = (secondsInDay / 3600L).toInt
79+
val minute = ((secondsInDay % 3600L) / 60L).toInt
80+
val second = (secondsInDay % 60L).toInt
81+
82+
val z = daysSinceEpoch + 719468L
83+
val era =
84+
(
85+
if z >= 0 then
86+
z
87+
else
88+
z - 146096L
89+
) / 146097L
90+
val doe = (z - era * 146097L).toInt
91+
val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
92+
val y0 = yoe.toLong + era * 400L
93+
val doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
94+
val mp = (5 * doy + 2) / 153
95+
val day = doy - (153 * mp + 2) / 5 + 1
96+
val month =
97+
if mp < 10 then
98+
mp + 3
99+
else
100+
mp - 9
101+
val year =
102+
(
103+
if month <= 2 then
104+
y0 + 1
105+
else
106+
y0
107+
).toInt
108+
109+
val sep =
110+
if withSpace then
111+
' '
112+
else
113+
'T'
114+
f"$year%04d-$month%02d-$day%02d$sep$hour%02d:$minute%02d:$second%02d.$millis%03dZ"
52115

53-
def formatTimestampWithoutSpace(timeMillis: Long): String = format(
54-
c"%Y-%m-%dT%H:%M:%S.",
55-
timeMillis
56-
)
116+
end formatUtc
57117

58118
end LogTimestampFormatter

0 commit comments

Comments
 (0)