From fe7b1cd199d9a21f6b0765eeb7942cbabf449d76 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 13:33:41 -0500 Subject: [PATCH] Convert a Date to local time once for its local getters and follow TZ changes Each local-time getter (getFullYear, getMonth, getDate, getHours, ..., getTimezoneOffset) called localtime_r and walked the calendar for its own field. The thread now keeps its last local conversion, keyed by the instant and a timezone generation, so reading several local fields of one Date converts it once. The UTC walk and the second computation use integer division instead of floor. Writing or deleting process.env.TZ calls tzset and starts a new generation, so later local-time reads use the new zone, as in Node. Before, the zone the process started with stayed in effect. --- .../corpus/5683-date-local-fields-tz.ts.json | 6 ++ packages/runtime/src/scr_lib.c | 66 ++++++++++++- tests/corpus/5683-date-local-fields-tz.ts | 95 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5683-date-local-fields-tz.ts.json create mode 100644 tests/corpus/5683-date-local-fields-tz.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5683-date-local-fields-tz.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5683-date-local-fields-tz.ts.json new file mode 100644 index 000000000..7ac133650 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5683-date-local-fields-tz.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5683-date-local-fields-tz.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 9307d9b01..fd51e9c78 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -824,6 +824,26 @@ static void scr_node_debug_capture(const ScrStr *name) { if (name->len == 10 && memcmp(name->data, "NODE_DEBUG", 10) == 0) (void)scr_node_debug_initial(); } +/* Counts writes and deletes of process.env.TZ. The local-time getters keep + * their last conversion for the generation it was made in. */ +#ifdef SCR_WORKERS +static atomic_uint scr_date_tz_generation; +#else +static unsigned scr_date_tz_generation; +#endif + +/* After a write or delete of the process's TZ, local time follows the new + * value, as in Node: tzset(3) rereads it for later localtime calls. */ +static void scr_env_tz_changed(const ScrStr *name) { + /* Compared by character: a string literal would join the merged + * read-only strings of every program that links this file. */ + if (name->len != 2 || name->data[0] != 'T' || name->data[1] != 'Z') return; +#if !defined(_WIN32) && !defined(__wasi__) + tzset(); +#endif + scr_date_tz_generation++; +} + /* process.env.NAME = v — setenv(3): later scr_env_get reads and spawned * children (posix_spawn inherits environ) observe the write, like Node. * Both args borrowed (NUL-terminated ScrStr data); setenv copies. */ @@ -842,6 +862,7 @@ void scr_env_set(const ScrStr *name, const ScrStr *value) { #else setenv(k, v, 1); #endif + scr_env_tz_changed(name); #ifdef SCR_WORKERS done: #endif @@ -861,6 +882,7 @@ void scr_env_unset(const ScrStr *name) { #else unsetenv(name->data); #endif + scr_env_tz_changed(name); } static ScrStr *scr_fs_read_file_core(ScrStr *path, bool env_file, size_t *ascii); @@ -10475,9 +10497,14 @@ typedef struct ScrDateParts { * endpoint. The checked wrapper is the public UTC-getter gate; the inner * walk accepts those bounded local-time values too. */ static void scr_date_utc_parts_unchecked(double t, ScrDateParts *out) { - double dayd = floor(t / 86400000.0); - long long msday = (long long)(t - dayd * 86400000.0); - long long z = (long long)dayd + 719468; + /* t is an integral number of milliseconds within about 2^53. */ + long long tms = (long long)t; + long long day = tms / 86400000, msday = tms % 86400000; + if (msday < 0) { + msday += 86400000; + day--; + } + long long z = day + 719468; long long era = (z >= 0 ? z : z - 146096) / 146097; unsigned long long doe = (unsigned long long)(z - era * 146097); unsigned long long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; @@ -10487,7 +10514,7 @@ static void scr_date_utc_parts_unchecked(double t, ScrDateParts *out) { unsigned long long d = doy - (153 * mp + 2) / 5 + 1; unsigned long long m = mp < 10 ? mp + 3 : mp - 9; if (m <= 2) y++; - long long wday = ((long long)dayd + 4) % 7; + long long wday = (day + 4) % 7; if (wday < 0) wday += 7; out->year = y; out->month = (int)m - 1; @@ -10516,10 +10543,39 @@ static bool scr_date_localtime(double secd, struct tm *out) { #endif } +/* The thread's last local conversion: reading several local fields of one + * Date (getFullYear, getMonth, getDate, getHours, ...) converts it once. + * A change of process.env.TZ starts a new generation. */ +typedef struct { + bool valid; + unsigned generation; + double ms; + ScrDateParts parts; +} ScrDateLocalCache; +static SCR_TL ScrDateLocalCache scr_date_local_cache; + +static bool scr_date_local_parts_uncached(double clipped, ScrDateParts *out); + static bool scr_date_local_parts(double ms, ScrDateParts *out) { if (!isfinite(ms) || fabs(ms) > 8640000000000000.0) return false; double clipped = trunc(ms); - double secd = floor(clipped / 1000.0); + ScrDateLocalCache *c = &scr_date_local_cache; + unsigned generation = scr_date_tz_generation; + if (c->valid && c->ms == clipped && c->generation == generation) { + *out = c->parts; + return true; + } + if (!scr_date_local_parts_uncached(clipped, out)) return false; + c->valid = true; + c->generation = generation; + c->ms = clipped; + c->parts = *out; + return true; +} + +static bool scr_date_local_parts_uncached(double clipped, ScrDateParts *out) { + long long cms = (long long)clipped; + double secd = (double)(cms / 1000 - (cms % 1000 < 0)); struct tm tmv; double basis_secd = secd; if (!scr_date_localtime(basis_secd, &tmv)) { diff --git a/tests/corpus/5683-date-local-fields-tz.ts b/tests/corpus/5683-date-local-fields-tz.ts new file mode 100644 index 000000000..dc75011ed --- /dev/null +++ b/tests/corpus/5683-date-local-fields-tz.ts @@ -0,0 +1,95 @@ +// Local-time getters read repeatedly on one Date and alternately on two, +// on Dates parsed from strings, and across process.env.TZ writes and +// deletes: every read answers the fields of the zone in effect at that +// moment, as in Node. + +function fields(d: Date): string { + return [ + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getDay(), + d.getHours(), + d.getMinutes(), + d.getSeconds(), + d.getMilliseconds(), + d.getTimezoneOffset(), + ].join(","); +} + +const a = new Date(Date.UTC(2024, 2, 10, 6, 59, 59, 999)); +const b = new Date(Date.UTC(2024, 2, 10, 7, 0, 0, 0)); +const c = new Date(Date.UTC(2024, 10, 3, 5, 30, 0, 250)); + +function report(label: string): void { + console.log(label, fields(a), fields(a)); + console.log(label, fields(b), fields(c), fields(b)); + let mixed = 0; + for (let i = 0; i < 6; i++) mixed += a.getHours() * 100 + b.getHours() + c.getDate(); + console.log(label, "alternating", mixed); +} + +report("start"); +process.env.TZ = "America/New_York"; +report("new york"); +process.env.TZ = "Asia/Kolkata"; +report("kolkata"); +process.env.TZ = "Australia/Lord_Howe"; +report("lord howe"); + +// Dates parsed from strings with an explicit offset, read in local time. +const d = new Date("2024-06-01T23:45:00+05:30"); +console.log("offset string", fields(d), d.getTime()); +const e = new Date("2024-07-11T00:15:30.001Z"); +console.log("offset string", fields(e), e.getTime()); +console.log("first again", fields(d)); + +delete process.env.TZ; +report("deleted"); +process.env.TZ = "UTC"; +report("utc"); + +// Many instants read field by field. +process.env.TZ = "Europe/Berlin"; +let sum = 0; +for (let i = 0; i < 2000; i++) { + const t = new Date(1700000000000 + i * 3_599_999); + sum += t.getHours() + t.getDate() * 3 + t.getMonth() * 7 + t.getTimezoneOffset(); +} +console.log("berlin sum", sum); + +const invalid = new Date(Number.NaN); +console.log("invalid", fields(invalid), fields(invalid)); + +// Instants around day and second boundaries, before the epoch and at the +// ends of the Date range, in UTC and local fields; then 2000-2030 in a zone +// with DST. +function utcFields(d: Date): string { + return [ + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCDay(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds(), + d.getUTCMilliseconds(), + ].join(","); +} +const edges = [ + 0, -0, -0.5, 0.5, -1, 1, -999, -1000, -1001, 999.9, -86_400_000, -86_400_001, 86_399_999, + -62_198_755_200_000, -62_198_755_200_001, 8_640_000_000_000_000, -8_640_000_000_000_000, + 8_639_999_999_999_999, -8_639_999_999_999_999, 951_782_400_000, 4_107_542_399_999, +]; +process.env.TZ = "UTC"; +for (const t of edges) { + const x = new Date(t); + console.log(t, utcFields(x), fields(x)); +} +process.env.TZ = "America/Sao_Paulo"; +let walk = 0; +for (let t = 946_684_800_000; t < 1_893_456_000_000; t += 77_777_777) { + const x = new Date(t); + walk = (walk * 31 + x.getUTCDate() + x.getUTCHours() * 7 + x.getDate() + x.getMinutes() + x.getDay()) % 1_000_003; +} +console.log("walk", walk); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 7d8030e95..907f27383 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1997,6 +1997,7 @@ "5680-array-growth-after-copy.ts": 3791, "5681-array-pop-and-length.ts": 3754, "5682-publish-wide-objects.ts": 3326, + "5683-date-local-fields-tz.ts": 3848, "5700-cycle-release-retained.ts": 2156, "5701-cycle-release-old-edges.ts": 2601, "5702-publish-candidate-buffers.ts": 3469, @@ -5019,6 +5020,7 @@ "5680-array-growth-after-copy.ts": 4581, "5681-array-pop-and-length.ts": 3216, "5682-publish-wide-objects.ts": 3376, + "5683-date-local-fields-tz.ts": 3911, "5700-cycle-release-retained.ts": 3414, "5701-cycle-release-old-edges.ts": 4210, "5702-publish-candidate-buffers.ts": 3848,