ReviewOS

zig-utils/zig-js

Date: local time is hardcoded to UTC, and the private-ABI setTimeZone override never reaches it

#976
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Date's local-time surface always behaves as if the host were UTC, and the JSGlobalObject__setTimeZone override that the private ABI exposes (#303) does not change that — it only reaches the Intl pipeline.

Measured

Host zone Asia/Manila (UTC+8, no DST), same program under zig-js (Debug test262 --eval) and JavaScriptCore (home-tool):

zig-jsJavaScriptCore
new Date(0).getTimezoneOffset()0-480
new Date(Date.UTC(2026,6,1)).getTimezoneOffset()0-480
new Date(0).getHours()08
new Date(2026, 8, 22, 10, 20, 30).getTime()17900724300001790043630000
d = new Date(0); d.setHours(5); d.getTime()18000000-10800000
new Date(0).toString() tail GMT+0000 (Coordinated Universal Time) GMT+0800 (Philippine Standard Time)
new Date(0).toLocaleTimeString("en-US")12:00:00 AM8:00:00 AM
new Date("2026-09-22T10:20:30").getTime()17900724300001790043630000
Intl.DateTimeFormat().resolvedOptions().timeZoneUTCAsia/Manila

The last row matters for the others: an ISO date-time without an offset is local time (21.4.3.2), so parsing it as UTC is the same bug seen from the parser side.

Everything that takes an explicit zone is already correct — new Intl.DateTimeFormat("en-US", {timeZone: "Asia/Manila", ...}).format(0) gives 08:00, and {timeZone: "UTC"} gives 00:00. So the zone database and the offset lookup work; only Date is disconnected from them.

Root cause

Two places:

  • src/interpreter.zig:14153 — if (eq(name, "getTimezoneOffset")) return Value.num(0);
  • src/interpreter.zig:47499 — fn nowTimeZone() returns .{ .name = timeZoneOverride() orelse "UTC", .offset_ns = 0 }. The name is carried, the offset is not, so even an embedder that calls JSGlobalObject__setTimeZone("Asia/Manila") still gets UTC out of every local Date method.

The primitive needed already exists: iana_offsets.offsetAt(name, epoch_seconds) (used at interpreter.zig:47943) and iana_offsets.transitionAt for DST boundaries.

Why the corpus is green anyway

test262's local-time tests are self-consistent: they compare Date against LocalTZA-derived expectations, and an engine that answers "offset 0" everywhere satisfies them. 48,506/48,506 cannot see this. It shows up the moment a host or an embedder has a real zone — which is every Home/Bun use.

The decision I'd want from you before implementing

What is the default when no setTimeZone override is set?

  1. Adopt the host zone (TZ env var, else /etc/localtime), like JavaScriptCore, V8 and node. Matching behaviour, but engine output becomes host-dependent, and the corpus would then run under whatever zone CI has — the DST paths get exercised for the first time.
  2. Keep UTC as the default and only honour an explicit setTimeZone. Deterministic, keeps CI reproducible, still fixes the ABI defect and lets Home/Bun opt in.

Either way the work is the same underneath: route LocalTime/UTC (21.4.1.25-26), the local getters and setters, the Date(y, m, d, …) constructor, toString/toLocaleString and the offset-less ISO parse through the zone's offset at that instant, and make resolvedOptions().timeZone report it. I'm happy to take it once the default is settled.

9 days ago

Implemented and pushed to main in two commits:

  • fdfb7411 — fix(date): honor process default time zone (#976)
  • 777d49c7 — test(abi): cover Date time zone overrides (#976)

The runtime now discovers the process zone once from TZ or the Unix /etc/localtime IANA link, with UTC as the fail-closed fallback. The private WTF::setTimeZoneOverride boundary takes precedence and now reaches local Date construction, offset-less date-time parsing, getters, setters, string methods, and locale formatting, as well as the existing Intl and Temporal defaults. UTC Date methods remain epoch-based.

Local-to-UTC conversion now uses the generated IANA transition table for every tabulated zone. The old city/year-specific overlap and gap list was removed. Compatible disambiguation matches ECMAScript for gaps, overlaps, and date-line changes.

Validation:

  • Full unit suite: 2,491 passed, 1 skipped, 0 failed, 0 leaked; 459.2s across 10 shards.
  • Date-focused unit suite: 26/26 passed.
  • Focused TSan Date override witness: 2/2 passed.
  • test262 Date under TZ=Asia/Manila: 590/594 before and 590/594 after; the same four pre-existing toISOString failures, so 0 regressions.
  • test262 Temporal.ZonedDateTime: 901/901 passed.
  • Six IANA zones × five dates: all 30 local-construction results matched Node exactly, including New York/Los Angeles overlaps and gaps, Lord Howe, Apia's date-line skip, and Singapore's historical offset change.
  • Home private ABI audit: 538 symbols, 0 pending and 0 unclassified.
  • Home private runtime fixture: 392/392 symbols linked; runtime matrix passed.
  • Docs: 50 pages built and the deterministic manifest was refreshed and rechecked.
9 days ago

Independent verification at bcc32b71 with a freshly built Debug runner, real CI-pinned zig-gc 0285ef8, and zig-regex 1d3e5c9 (not the local non-collecting shim). Zig 0.17.0-dev.1441. Shared 3072 MB guard; build peaked at 2161 MB.

Re-ran the preserved 26-case Date parsing probe and 13-case local-time probe in fresh processes under TZ=Asia/Manila, TZ=America/New_York, and TZ=UTC, in both tree and VM modes. Compared keyed outputs against Node and the Home JavaScriptCore oracle.

  • Parsing: 22/26 rows match both oracles in each zone; the four remaining differences are legacy parsing (9/22/2026, Sep 22, 2026, 22 Sep 2026, 2026-9-22 return NaN in zig-js). These are compatibility observations, not demonstrated regressions or failures of the time-zone fix.
  • Local-time surface: 12/13 exact text matches in Manila and New York; 13/13 in UTC. The remaining difference is the parenthesized display name (Asia/Manila vs Philippine Standard Time, America/New_York vs Eastern Standard Time); offsets and local fields agree.
  • Tree and VM outputs agree for every preserved row.
  • Added 12 controlled New York DST probes: immediately before/after spring and autumn transitions; local constructor and offset-less parsing in the gap/overlap; setHours in each; setDate across spring; setMonth into autumn. All 12 match Node exactly in both modes, including epoch milliseconds, offset, and local fields.

Representative compatible-disambiguation results:

Local input, America/New_YorkEpoch millisecondsResulting local time
constructor 2026-03-08 02:30177295500000003:30, offset 240
constructor 2026-11-01 01:30179351100000001:30, offset 240 (earlier occurrence)

This verifies the JS-level local-time fix and DST behavior for these probes, not all zones/history, private ABI coverage, or a new full test262 score. No engine change was necessary. Legacy parsing and display wording remain separate compatibility work.

Sign in to comment on this issue.