Skip to content

[JSC] The host time zone is not UTC when ICU has no name for it - #890

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/13b3b63c/process-time-zone-offset
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/13b3b63c/process-time-zone-offset

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • TZ=MSK-3 jsc -e 'print(new Date().getTimezoneOffset())' prints 0. Node prints -180.
  • The cause: retrieveTimeZoneInformation() (runtime/JSDateMath.cpp) resolves the ID from ucal_getHostTimeZone() by name. An ID with no name (a POSIX TZ value, a tzfile path, the override GMT+05:30) is UTC, with no error.

Fix

  • Under USE(BUN_JSC_ADDITIONS). An override with no IANA name is the zone at ICU's offset for it.
  • For the host, TZ is read first: a zone name in it, or in its path after /zoneinfo/, is the zone. A name that ICU guessed, or that a link such as :/etc/localtime points to, stays only if it tells the C library's time in January and July. The rest is the C library's standard offset.
  • Verified: 8 new JSTests fail without the change. The 18 other TZ tests of complex.yaml pass.

Background

  • TimeZone is a name or a fixed offset, as in ECMA-262.
  • ICU 80 names a /zoneinfo/ path in TZ itself (ICU-13694). So the path is read from getenv("TZ"), not from the host ID.
  • Do not move Bun's pin to this before Route every TZ writer through one setter that reads MSK-3 and :name bun#46698 merges. Bun's start gives the raw TZ to WTF::setTimeZoneOverride() today, and TZ=GMT+3 would become three hours east.

Downsides

  • A zone with a daylight saving rule and no name has its standard offset only: a POSIX rule value, or a TZ=:/etc/localtime that is a regular file. Before: UTC.
  • First local-time use: 1 host detection, 1 ucal_open, 20,843 allocations, before and after. JSDateMath.cpp.o: +7,240 bytes.
  • Not run on musl, macOS or Windows: the test lanes here are glibc.
Notes

Results in the jsc shell

Linux x64, glibc 2.41, ICU 78.3, /etc/localtime -> /usr/share/zoneinfo/Etc/UTC. The values are getTimezoneOffset() for 09:00 local time on 2026-01-15 and on 2026-07-15, and Intl.DateTimeFormat().resolvedOptions().timeZone.

TZ before after date(1)
MSK-3 0/0 UTC -180/-180 Etc/GMT-3 +0300/+0300
EST5 0/0 UTC 300/300 Etc/GMT+5 -0500/-0500
GMT+3 0/0 UTC 180/180 Etc/GMT+3 -0300/-0300
XXX+13 0/0 UTC 780/780 -13:00 -1300/-1300
<+03>-3 0/0 UTC -180/-180 Etc/GMT-3 +0300/+0300
IST-5:30 0/0 UTC -330/-330 +05:30 +0530/+0530
CET-1CEST,M3.5.0,M10.5.0/3 0/0 UTC -60/-60 Etc/GMT-1 +0100/+0200
:/usr/share/zoneinfo/America/New_York 0/0 UTC 300/240 America/New_York -0500/-0400
/nix/store/tzdata-2025a/share/zoneinfo/Asia/Seoul (no such file) 0/0 UTC -540/-540 Asia/Seoul +0000/+0000
:/tmp/link to zoneinfo/Europe/Berlin 0/0 UTC -60/-120 Europe/Berlin +0100/+0200
:/tmp/copy of America/New_York 0/0 UTC 300/300 Etc/GMT+5 -0500/-0400
Europe/Moscow, EST, CET, Cuba, PRC, ACT, EST5EDT, Etc/GMT+5 by name no change

Node v26.3.0 on the same host has the offset of MSK-3, EST5, GMT+3 and XXX+13, reports undefined for the zone, has no daylight saving time for the three path forms, and is UTC for <+03>-3, IST-5:30 and the rule form.

A POSIX value with a daylight saving rule

ICU does not read such a TZ. It names /etc/localtime, or it guesses from the abbreviations when there is no such file. On a host with no /etc/localtime, TZ=CET-1CEST,M3.5.0,M10.5.0/3 is Europe/Brussels before and after this change (-60/-120), because ICU knows CET and that zone tells the time that the C library tells. On a host where /etc/localtime is another zone, the result was that other zone. It is now the standard offset of the value.

TZ unset, /etc/localtime a regular file, no zoneinfo directory

Each of the 312 zones of zone1970.tab was copied to /etc/localtime in a private mount namespace, with /usr/share/zoneinfo hidden. The first column counts the zones for which both offsets above are the ones of date(1). The second counts the zones for which getTimezoneOffset() is the offset of localtime() at every hour of 2026.

15 January and 15 July every hour of 2026
before 103 98
after 284 277
node v26.3.0 273 267

No zone that was exact before is wrong after, by either count. Of the 35 zones that are not exact at every hour, 24 have daylight saving time and get the standard offset. The other 11 have a long name from ICU's abbreviation table with other transition dates: Africa/Cairo is Europe/Athens, America/Mexico_City is America/Chicago. They are the same before this change. A long name is not checked, because ICU also returns a long name for the common case, a link into a zoneinfo directory, and that case must not cost more. The two instants are a test that a guess is plausible. They are not a proof that it is the host's zone.

With the zoneinfo directory present, all 312 zones are exact at the two instants before and after.

Why not the raw offset

ICU compares the raw offset of a guessed zone with libc's timezone. timezone is the standard offset after the last rule of the tzfile. For America/Vancouver in tzdata 2025c it is -7 hours, the permanent offset from November 2026, while January 2026 is at -8. With that test, a host on America/Vancouver that ICU names America/Los_Angeles (correct for 2026) would get a fixed -7. The test here is the offset that localtime_r() gives in the middle of January and of July of this year, against the offset of the guessed zone at the same times.

A name that TZ gives is never checked. TZ=EST on a Debian host without tzdata-legacy is -5 hours to ICU and UTC to the C library, and it stays -5 hours.

/etc/localtime of the host mounted into a container

docker run -v /etc/localtime:/etc/localtime:ro on an image where /etc/localtime is a link (Debian: to /usr/share/zoneinfo/Etc/UTC) mounts the file of the host over the target of the link. The path then names Etc/UTC, and the file is the zone of the host. The C library reads the file.

TZ=:/etc/localtime, the file is Australia/Sydney the file is Asia/Kolkata
before 0/0 UTC 0/0 UTC
after -600/-600 Etc/GMT-10 -330/-330 +05:30
node v26.3.0 -600/-600 -330/-330
date(1) +1100/+1000 +0530/+0530

With TZ unset in that container, ICU names Etc/UTC from the link, and the zone is UTC before and after, as in node. A long name with TZ unset is not checked (see above).

Not in this change

  • A tzfile with daylight saving time that no path names: the zone needs the rule of the file, which TimeZone cannot hold. It gets the standard offset. With TZ=:/etc/localtime and /etc/localtime a regular file, that is 105 of the 312 zones.
  • TZ unset in a container with the host's /etc/localtime mounted over the target of the link: UTC, as before.
  • WTF::setTimeZoneOverride() does not change. An earlier revision made it admit +05:30. Bun can map that to GMT+05:30 itself.

Open points for the review

  • The host policy (TZ, the two probes, the link names) is about 160 lines in JavaScriptCore, under the guard. The other place for it is a weak Bun__ hook with the policy in Bun, as Bun__reportUnhandledError is. It is here because the jsc shell and JSTests then cover it, and because the defect is the engine's: upstream has the same lookup.
  • A search for a named zone that tells the C library's time, before the fixed offset, would keep daylight saving time for most of those 105 zones. It is not done here.
  • No test runs with TZ unset: the test runner sets TZ for every test. The decision has no seam for a table test yet.
  • The change ran on Linux with glibc only. On Android, ICU reads the zone from a system property and not from TZ. Here a zone name in TZ is read first, so it does not depend on that.
  • No upstream WebKit bug is linked.

@robobun
robobun force-pushed the robobun/13b3b63c/process-time-zone-offset branch 2 times, most recently from f4d90a0 to e028c17 Compare October 23, 2026 19:01
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Preview build of 66eecb2: autobuild-preview-pr-890-66eecb2d

retrieveTimeZoneInformation() resolves the host time zone by name only.
ucal_getHostTimeZone() returns the ID of the zone ICU detects. When that
ID is not a zone name, the function keeps a default-constructed TimeZone,
which is UTC, with no error. That is the case for a POSIX TZ value
("MSK-3", "EST5"), for a TZ that is the path of a tzfile
(":/etc/localtime"), and for an /etc/localtime that no zoneinfo path
names. V8 does not have this problem: it keeps the zone object ICU makes,
which has the offset of the C library.

Three more results of the same lookup are wrong:
- An override that WTF::setTimeZoneOverride() admits and that has no IANA
  name ("GMT+05:30", "SystemV/EST5") is UTC.
- With TZ unset and /etc/localtime a regular file, ICU guesses a zone
  from the abbreviation. "CST" on a host in China is America/Chicago.
- With a TZ that ICU cannot read as a name (a POSIX value with a DST
  part), ICU names /etc/localtime, and that zone is used.

The change is under USE(BUN_JSC_ADDITIONS). Upstream's lines stay as the
other branch.
- An override with no IANA name is the zone at the offset that ICU has
  for the ID.
- For the host, TZ is read first, as the C library reads it. A zone name
  in it is the zone. A path into a zoneinfo directory is the zone the
  path names after "/zoneinfo/". ICU hands a name in TZ back as the host
  ID on Linux, but not on every platform, not for a path with a digit
  before its end, and from ICU 80 not as a path (ICU-13694).
- With no TZ, a long name from ICU is the zone, as before: it is the name
  in the path /etc/localtime links to, or of the zoneinfo file with the
  same content.
- Every other name from ICU is a guess: an abbreviation with TZ unset,
  any name when TZ names no zone. A guess stays only if it tells the same
  time as the C library in the middle of January and of July of this
  year. The same test applies to the zone that a link such as
  ":/etc/localtime" points to: in a container with the host's
  /etc/localtime mounted over the target of that link, the path names the
  zone of the image and the file is the zone of the host. ICU has this
  rule for abbreviations, with the raw offset only.
- What is left is a zone the C library knows and nothing names. It is the
  zone at the standard offset of the C library.
- A fixed offset is UTC for zero, the IANA zone for whole hours
  (Etc/GMT-14 to Etc/GMT+12), and an offset time zone ("+05:30") for the
  rest. Date, Intl.DateTimeFormat and Temporal.Now read the same zone.

A zone with a daylight saving rule and no name (a POSIX value such as
"CET-1CEST,M3.5.0,M10.5.0/3" on a host where ICU finds no zone with that
time, a tzfile outside a zoneinfo directory) has its standard offset
only. TimeZone is a name or a fixed offset, as the time zone of
ECMA-262 is.

Tests: JSTests/complex/host-timezone-*.js,
JSTests/stress/host-time-zone-fixed-offset.js

See oven-sh/bun#3649.
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 66eecb2, and rebased on main:

  • The new code is under USE(BUN_JSC_ADDITIONS). Upstream's lines are the other branch.
  • The host arm reads TZ itself. A zone name in it is the zone, and a tzfile path is read from getenv("TZ") and not from ICU's host ID (ICU-13694 changes that ID in ICU 80).
  • An override with no IANA name takes ICU's offset for it, so SystemV/EST5 is no longer UTC.
  • WTF::setTimeZoneOverride() no longer changes.

The three test lanes pass on this commit. The PR stays a draft: it needs a review from someone who owns the fork, and the body lists the open points. Bun's pin must not move here before oven-sh/bun#46698 merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant