Read the clock in the reader's zone when /etc/localtime is a file
Every timestamp came out in UTC under the nix package: eight in the morning next to a clock reading one. The zone is found by resolving /etc/localtime and taking the name out of the path, and in a container that file is often the bytes themselves rather than a symlink — Arch under distrobox has it as an overlay mount — so readlink answers with the path it was given, no name comes out, and the chain fell through to UTC. libc knew the zone throughout; only the name recovery failed. So fall back to libc's colon form, TZ=:/etc/localtime, which names the tzfile by path instead of by zone and reads it with its transitions intact: -25200 in September and -28800 in February, not one frozen offset. Nothing downstream minds that it is a path, the zone only ever reaching tz-offset-seconds. TZDIR on the jolt wrapper for the other half of the same question: a zone *name* needs a zoneinfo tree to resolve against, and a NixOS host has no /usr/share/zoneinfo. Set as a default, so one already set wins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bffe879 parent: 7434fee added
CLAUDE.md +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +# Working in this repo | |
| 2 | + | |
| 3 | +## Nix | |
| 4 | + | |
| 5 | +`nix` is not installed on the host. It lives in the Arch distrobox, so run every | |
| 6 | +nix command through that: | |
| 7 | + | |
| 8 | +```bash | |
| 9 | +distrobox enter arch -- bash -lc 'cd <this directory> && nix build .#frq' | |
| 10 | +``` | |
| 11 | + | |
| 12 | +The worktree path is the same inside the container as outside, so `cd "$PWD"` | |
| 13 | +works. A remote builder (`eu.nixbuild.net`) is already configured there; the | |
| 14 | +Android outputs want `--store ssh-ng://eu.nixbuild.net --eval-store auto`, for | |
| 15 | +the reason nix/android.nix gives at the top. | |
| 16 | + | |
| 17 | +One thing that container is *not* representative of: `/etc/localtime` is a | |
| 18 | +regular file there rather than a symlink, so anything that reads the zone out | |
| 19 | +of its path sees nothing. That is a real deployment shape, not an artefact — | |
| 20 | +frq.clock handles it. | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +# Working in this repo | ||
| 2 | + | ||
| 3 | +## Nix | ||
| 4 | + | ||
| 5 | +`nix` is not installed on the host. It lives in the Arch distrobox, so run every | ||
| 6 | +nix command through that: | ||
| 7 | + | ||
| 8 | +```bash | ||
| 9 | +distrobox enter arch -- bash -lc 'cd <this directory> && nix build .#frq' | ||
| 10 | +``` | ||
| 11 | + | ||
| 12 | +The worktree path is the same inside the container as outside, so `cd "$PWD"` | ||
| 13 | +works. A remote builder (`eu.nixbuild.net`) is already configured there; the | ||
| 14 | +Android outputs want `--store ssh-ng://eu.nixbuild.net --eval-store auto`, for | ||
| 15 | +the reason nix/android.nix gives at the top. | ||
| 16 | + | ||
| 17 | +One thing that container is *not* representative of: `/etc/localtime` is a | ||
| 18 | +regular file there rather than a symlink, so anything that reads the zone out | ||
| 19 | +of its path sees nothing. That is a real deployment shape, not an artefact — | ||
| 20 | +frq.clock handles it. | ||
modified
flake.nix +7 -0 | @@ -165,10 +165,17 @@ | ||
| 165 | 165 | |
| 166 | 166 | # jolt.deps shells out to git and unzip, and jolt.mvn-http dlopens |
| 167 | 167 | # OpenSSL through the JOLT_OPENSSL_LIBDIR seam. |
| 168 | + # | |
| 169 | + # TZDIR so a zone *name* resolves wherever this runs: frq.clock | |
| 170 | + # hands one to tzset, and glibc then looks for the tzfile under | |
| 171 | + # /usr/share/zoneinfo unless told otherwise — which a NixOS host | |
| 172 | + # does not have. The store's own tzdata is there on both kinds of | |
| 173 | + # machine. --set-default, so a TZDIR the user set still wins. | |
| 168 | 174 | postFixup = '' |
| 169 | 175 | wrapProgram "$out/bin/jolt" \ |
| 170 | 176 | --prefix PATH : "${pkgs.lib.makeBinPath [ pkgs.git pkgs.unzip ]}" \ |
| 171 | 177 | --set-default JOLT_OPENSSL_LIBDIR "${pkgs.lib.makeLibraryPath [ pkgs.openssl ]}" \ |
| 178 | + --set-default TZDIR "${pkgs.tzdata}/share/zoneinfo" \ | |
| 172 | 179 | --set-default SSL_CERT_FILE "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" |
| 173 | 180 | ''; |
| 174 | 181 | }; |
| @@ -165,10 +165,17 @@ | |||
| 165 | 165 | ||
| 166 | # jolt.deps shells out to git and unzip, and jolt.mvn-http dlopens | 166 | # jolt.deps shells out to git and unzip, and jolt.mvn-http dlopens |
| 167 | # OpenSSL through the JOLT_OPENSSL_LIBDIR seam. | 167 | # OpenSSL through the JOLT_OPENSSL_LIBDIR seam. |
| 168 | + # | ||
| 169 | + # TZDIR so a zone *name* resolves wherever this runs: frq.clock | ||
| 170 | + # hands one to tzset, and glibc then looks for the tzfile under | ||
| 171 | + # /usr/share/zoneinfo unless told otherwise — which a NixOS host | ||
| 172 | + # does not have. The store's own tzdata is there on both kinds of | ||
| 173 | + # machine. --set-default, so a TZDIR the user set still wins. | ||
| 168 | postFixup = '' | 174 | postFixup = '' |
| 169 | wrapProgram "$out/bin/jolt" \ | 175 | wrapProgram "$out/bin/jolt" \ |
| 170 | --prefix PATH : "${pkgs.lib.makeBinPath [ pkgs.git pkgs.unzip ]}" \ | 176 | --prefix PATH : "${pkgs.lib.makeBinPath [ pkgs.git pkgs.unzip ]}" \ |
| 171 | --set-default JOLT_OPENSSL_LIBDIR "${pkgs.lib.makeLibraryPath [ pkgs.openssl ]}" \ | 177 | --set-default JOLT_OPENSSL_LIBDIR "${pkgs.lib.makeLibraryPath [ pkgs.openssl ]}" \ |
| 178 | + --set-default TZDIR "${pkgs.tzdata}/share/zoneinfo" \ | ||
| 172 | --set-default SSL_CERT_FILE "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" | 179 | --set-default SSL_CERT_FILE "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" |
| 173 | ''; | 180 | ''; |
| 174 | }; | 181 | }; |
modified
src/frq/clock.jolt +13 -0 | @@ -12,12 +12,25 @@ | ||
| 12 | 12 | (def ^:private zone |
| 13 | 13 | ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise |
| 14 | 14 | ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing. |
| 15 | + ;; | |
| 16 | + ;; /etc/localtime is not always a symlink. In a container it is often the | |
| 17 | + ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular | |
| 18 | + ;; file — and then `readlink -f` answers with the path it was given and there | |
| 19 | + ;; is no zone name to read out of it at all. That fell through to UTC, so a | |
| 20 | + ;; channel read seven hours ahead of the clock on the same screen. | |
| 21 | + ;; | |
| 22 | + ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime` | |
| 23 | + ;; names the tzfile by path rather than by zone, and tzset reads it with the | |
| 24 | + ;; transitions intact — the offset it gives is DST-correct per instant, not a | |
| 25 | + ;; fixed one. Nothing downstream cares that this is a path: the zone is only | |
| 26 | + ;; ever handed back to tz-offset-seconds, never shown. | |
| 15 | 27 | (delay |
| 16 | 28 | (or (let [tz (host/getenv "TZ")] (when (seq tz) tz)) |
| 17 | 29 | (try |
| 18 | 30 | (second (re-find #"/zoneinfo/(.+)$" |
| 19 | 31 | (str/trim (str (host/sh-out "readlink -f /etc/localtime"))))) |
| 20 | 32 | (catch Exception _ nil)) |
| 33 | + (when (host/file-exists? "/etc/localtime") ":/etc/localtime") | |
| 21 | 34 | "UTC"))) |
| 22 | 35 | |
| 23 | 36 | (defn now-ms [] (quot (host/wall-nanos) 1000000)) |
| @@ -12,12 +12,25 @@ | |||
| 12 | (def ^:private zone | 12 | (def ^:private zone |
| 13 | ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise | 13 | ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise |
| 14 | ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing. | 14 | ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing. |
| 15 | + ;; | ||
| 16 | + ;; /etc/localtime is not always a symlink. In a container it is often the | ||
| 17 | + ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular | ||
| 18 | + ;; file — and then `readlink -f` answers with the path it was given and there | ||
| 19 | + ;; is no zone name to read out of it at all. That fell through to UTC, so a | ||
| 20 | + ;; channel read seven hours ahead of the clock on the same screen. | ||
| 21 | + ;; | ||
| 22 | + ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime` | ||
| 23 | + ;; names the tzfile by path rather than by zone, and tzset reads it with the | ||
| 24 | + ;; transitions intact — the offset it gives is DST-correct per instant, not a | ||
| 25 | + ;; fixed one. Nothing downstream cares that this is a path: the zone is only | ||
| 26 | + ;; ever handed back to tz-offset-seconds, never shown. | ||
| 15 | (delay | 27 | (delay |
| 16 | (or (let [tz (host/getenv "TZ")] (when (seq tz) tz)) | 28 | (or (let [tz (host/getenv "TZ")] (when (seq tz) tz)) |
| 17 | (try | 29 | (try |
| 18 | (second (re-find #"/zoneinfo/(.+)$" | 30 | (second (re-find #"/zoneinfo/(.+)$" |
| 19 | (str/trim (str (host/sh-out "readlink -f /etc/localtime"))))) | 31 | (str/trim (str (host/sh-out "readlink -f /etc/localtime"))))) |
| 20 | (catch Exception _ nil)) | 32 | (catch Exception _ nil)) |
| 33 | + (when (host/file-exists? "/etc/localtime") ":/etc/localtime") | ||
| 21 | "UTC"))) | 34 | "UTC"))) |
| 22 | 35 | ||
| 23 | (defn now-ms [] (quot (host/wall-nanos) 1000000)) | 36 | (defn now-ms [] (quot (host/wall-nanos) 1000000)) |