bashrc.d/zz-10-base.sh exports TZ as a path when the variable is unset, at zz-10-base.sh line 67. Programs that resolve timezones through ICU rather than libc cannot use a path. They do not fail outright, which would be easier to notice, but read it partially: they take the standard offset of the zone the file describes and discard both the daylight saving rule and the zone identity.
Node on a machine in America/Chicago, with that TZ value, reports GMT-0600 for a January date and GMT-0600 for a July date, and Intl.DateTimeFormat().resolvedOptions().timeZone comes back undefined rather than naming a zone. Reading the identical file, libc reports tzname CST and CDT with daylight set, so python is right all year while node is an hour off for the whole daylight saving half of it.
The colon is not the problem, and neither is the symlink. Testing node against several values of TZ on one machine: America/Denver gives -0600 in July and -0700 in January with the zone resolved, and :America/Denver behaves identically, the leading colon simply being stripped. Both /usr/share/zoneinfo/America/Denver and :/usr/share/zoneinfo/America/Denver give -0700 in both months with the zone undefined, and /etc/localtime and :/etc/localtime both give -0600 in both months, also undefined. A name works with or without the colon; a path fails with or without it, falling back to whatever standard offset that file describes.
Unsetting TZ entirely is correct everywhere. Node then reports America/Chicago with -0500 in July and -0600 in January, and python and date agree. Node is not broken in general; it is broken only when handed a path.
That points at a cost in the obvious fix. Exporting a zone name freezes the zone for the life of the process, so a laptop carried to another timezone keeps reporting the old one in any shell that is already running. Following /etc/localtime is precisely what the repeated stat buys, and that stat is what the optimization set out to remove, so the optimization and the automatic follow cannot both be had.
The split that resolves it is by platform. On macOS the optimization does not apply at all, laptops are the machines that travel, and leaving TZ unset is correct in every tool including node, so it should simply not be set there. On Linux, where the stat cost was measured and the machines are servers that stay put, exporting the zone name keeps the benefit and fixes the ICU behavior; a Linux laptop that did move would pick up the new zone in any newly started shell.
Deriving the name on Linux works on the systems that matter, since /etc/localtime is a symlink into the zoneinfo tree on CentOS and the Red Hat family, which is the platform the optimization was written for. Where it is a regular copied file instead, as on some older distributions and in many container images, there is no name to recover; Debian and its derivatives keep it in /etc/timezone, which covers part of that gap. Where the name cannot be derived, the export has to fail safe by leaving TZ unset, and specifically not by falling back to the path form or to an empty value, since an empty TZ means UTC to glibc and would turn a one hour error into a several hour one.
bashrc.d/zz-10-base.sh exports TZ as a path when the variable is unset, at zz-10-base.sh line 67. Programs that resolve timezones through ICU rather than libc cannot use a path. They do not fail outright, which would be easier to notice, but read it partially: they take the standard offset of the zone the file describes and discard both the daylight saving rule and the zone identity.
Node on a machine in America/Chicago, with that TZ value, reports GMT-0600 for a January date and GMT-0600 for a July date, and Intl.DateTimeFormat().resolvedOptions().timeZone comes back undefined rather than naming a zone. Reading the identical file, libc reports tzname CST and CDT with daylight set, so python is right all year while node is an hour off for the whole daylight saving half of it.
The colon is not the problem, and neither is the symlink. Testing node against several values of TZ on one machine: America/Denver gives -0600 in July and -0700 in January with the zone resolved, and :America/Denver behaves identically, the leading colon simply being stripped. Both /usr/share/zoneinfo/America/Denver and :/usr/share/zoneinfo/America/Denver give -0700 in both months with the zone undefined, and /etc/localtime and :/etc/localtime both give -0600 in both months, also undefined. A name works with or without the colon; a path fails with or without it, falling back to whatever standard offset that file describes.
Unsetting TZ entirely is correct everywhere. Node then reports America/Chicago with -0500 in July and -0600 in January, and python and date agree. Node is not broken in general; it is broken only when handed a path.
That points at a cost in the obvious fix. Exporting a zone name freezes the zone for the life of the process, so a laptop carried to another timezone keeps reporting the old one in any shell that is already running. Following /etc/localtime is precisely what the repeated stat buys, and that stat is what the optimization set out to remove, so the optimization and the automatic follow cannot both be had.
The split that resolves it is by platform. On macOS the optimization does not apply at all, laptops are the machines that travel, and leaving TZ unset is correct in every tool including node, so it should simply not be set there. On Linux, where the stat cost was measured and the machines are servers that stay put, exporting the zone name keeps the benefit and fixes the ICU behavior; a Linux laptop that did move would pick up the new zone in any newly started shell.
Deriving the name on Linux works on the systems that matter, since /etc/localtime is a symlink into the zoneinfo tree on CentOS and the Red Hat family, which is the platform the optimization was written for. Where it is a regular copied file instead, as on some older distributions and in many container images, there is no name to recover; Debian and its derivatives keep it in /etc/timezone, which covers part of that gap. Where the name cannot be derived, the export has to fail safe by leaving TZ unset, and specifically not by falling back to the path form or to an empty value, since an empty TZ means UTC to glibc and would turn a one hour error into a several hour one.