On Mon, Apr 27, 2026 at 1:58 AM Álvaro Begué <[email protected]> wrote:
> Two distinct correctness fixes that together let lazy expansion match
> zic.c's writezone output for zones with rule firings near zone-line
> boundaries.
>
> 1. Always seed info.offset and info.save from the active rule.
>
> Previously, _M_get_sys_info skipped the find_active_rule lookup
> when `letters` had already been populated from i[-1].next_letters().
> That happens during a re-entry of partial lazy expansion: a prior
> call cut off after num_after sys_infos and left an expanded
> ZoneInfo whose next_letters() field is the letters of the rule
> that should fire at the next batch's start.
>
> The skip is wrong because info.offset and info.save are still at
> their (ri.offset(), 0) initialization values when entering the
> loop. Without re-running the seeding, the first sys_info of the
> new batch is emitted with stdoff alone for offset and save=0,
> even though the line had non-zero save in force at info.begin.
>
> Canonical breakage: Europe/Berlin around 1947-06-29. With
> num_after=4, batch 1 stops mid-line; batch 2 re-enters with
> non-empty letters and emits a 2-hour CEST sys_info that has
> offset=3600 (CET's offset) and save=0, instead of offset=7200
> save=60 — observably wrong total offset for two hours.
>
The num_after code performs extra iterations to guarantee that we
stop at the time for which save = 0:
// Finish on a DST sys_info if possible, so that if we resume
// generating sys_info objects after this time point, save=0
// should be correct for the next sys_info.
if (num_after > 1 || info.save != 0min)
--num_after;
}
And for the expanded zone (for which next_letters i set) we should have
save == 0. There is comment to that effect in the file. Have you validated
that the result is indeed incorrect without it?
>
> Fix: pull the seeding (find_active_rule + info.offset/save
> assignment) out of the `if (letters.empty())` branch and run it
> unconditionally. Only the letters fallback (first_std lookup)
> stays gated on letters being empty.
>
> 2. Add zic.c writezone merge optimization for backward jumps at
> zone-line boundaries.
>
> When two adjacent Zone lines have different total offsets and the
> new line's rule set has a rule firing within |jump| of the
> boundary (where jump = new_total - old_total < 0, i.e. local time
> goes backward at the boundary), zic folds that rule into the
> boundary itself: the single transition emitted has the rule's
> save value already applied, so the new line begins with the
> post-rule save rather than briefly using the pre-rule save and
> then transitioning again moments later.
>
> Canonical examples handled by the new merge block:
> * America/Argentina/Buenos_Aires 1999-10-03: lines change
> stdoff -3 → -4 with an Argentina DST rule firing on the same
> day. Without the merge, chrono emits a 1-hour stretch of
> offset=-4 save=0 and then transitions to offset=-3 save=1;
> with the merge, the boundary itself is at offset=-3 save=1.
> * Europe/Berlin 1945-05-24: lines split a rule set, with the
> So 1945-May-24 rule (save=2, "CEMT") firing at 01:00 UTC in
> the new frame, inside the 1h backward window.
>
> The merge block runs only at the first sys_info of a zone line,
> not on partial-expansion re-entry. We detect this by checking
> that i[-1].next_letters() is empty: a mid-line re-entry's prior
> ZoneInfo always has non-empty next_letters() (the rule firing
> at the new batch's start), whereas a zone-line transition's
> prior ZoneInfo ends with empty next_letters() because its
> line's last forward-walk iteration emits with letters cleared.
>
> libstdc++-v3/ChangeLog:
>
> PR libstdc++/124854
> * src/c++20/tzdb.cc (time_zone::_M_get_sys_info):
> Always run find_active_rule to seed info.offset and info.save,
> regardless of whether letters was already populated from
> i[-1].next_letters(). Add a writezone merge optimization
> block at zone-line boundaries with backward jumps, gated on
> an empty next_letters() to avoid running on partial-expansion
> re-entry.
> * testsuite/std/time/time_zone/zone_merge.cc: New test.
>
> Signed-off-by: Álvaro Begué <[email protected]>
> ---
> libstdc++-v3/src/c++20/tzdb.cc | 116 ++++++++++++------
> .../std/time/time_zone/zone_merge.cc | 84 +++++++++++++
> 2 files changed, 161 insertions(+), 39 deletions(-)
> create mode 100644 libstdc++-v3/testsuite/std/time/time_zone/zone_merge.cc
>
> diff --git a/libstdc++-v3/src/c++20/tzdb.cc
> b/libstdc++-v3/src/c++20/tzdb.cc
> index 4de77e6b1..193579a98 100644
> --- a/libstdc++-v3/src/c++20/tzdb.cc
> +++ b/libstdc++-v3/src/c++20/tzdb.cc
> @@ -1017,46 +1017,87 @@ namespace std::chrono
> if (i != infos.begin() && i[-1].expanded())
> letters = i[-1].next_letters();
>
> - if (letters.empty())
> - {
> - // info.begin + 1s makes the strict `rule_start < t` search
> - // inclusive of a rule that fires at exactly info.begin.
> - sys_seconds t = info.begin + seconds(1);
> -
> - // Try to find a Rule active before this time, to get initial
> - // SAVE and LETTERS values. See find_active_rule for the search
> - // semantics.
> - const Rule* active_rule = find_active_rule(rules, t, ri.offset());
> -
> - if (active_rule)
> - {
> - info.offset = ri.offset() + active_rule->save;
> - info.save = chrono::duration_cast<minutes>(active_rule->save);
> + // Seed info.offset and info.save from the rule active at
> + // info.begin. Always run this (even when `letters` was populated
> + // from i[-1].next_letters() during partial-expansion re-entry),
> + // because info.offset/save are still at their stdoff/0 init values
> + // and would otherwise produce the wrong total offset.
> + //
> + // info.begin + 1s makes the strict `rule_start < t` search
> + // inclusive of a rule that fires at exactly info.begin.
> + {
> + sys_seconds t = info.begin + seconds(1);
> + const Rule* active_rule = find_active_rule(rules, t, ri.offset());
> + if (active_rule)
> + {
> + info.offset = ri.offset() + active_rule->save;
> + info.save = chrono::duration_cast<minutes>(active_rule->save);
> + if (letters.empty())
> letters = active_rule->letters;
> - }
> - else
> + }
> + else if (letters.empty())
> + {
> + // No rule applies before info.begin; fall back to the LETTERS
> + // of the earliest STD rule, since the period before the first
> + // DST transition is conventionally standard time.
> + const Rule* first_std = nullptr;
> + for (const auto& rule : rules)
> + {
> + if (rule.save != minutes(0))
> + continue;
> + if (!first_std)
> + first_std = &rule;
> + else if (rule.from < first_std->from)
> + first_std = &rule;
> + else if (rule.from == first_std->from)
> + {
> + if (rule.start_time(rule.from, {})
> + < first_std->start_time(first_std->from, {}))
> + first_std = &rule;
> + }
> + }
> + if (first_std)
> + letters = first_std->letters;
> + }
> + }
> +
> + // zic.c writezone merge optimization. When the local time jumps
> + // backward at a zone-line boundary and a rule in the new line's set
> + // fires within that gap window, fold the rule's save into the
> + // boundary so the new line begins with the post-rule save.
> + //
> + // Only runs at the first sys_info of a zone line (not on partial-
> + // expansion re-entry mid-line): a mid-line re-entry's prior
> + // ZoneInfo has a non-empty next_letters(), while a zone-line
> + // transition's prior ZoneInfo ends with empty next_letters().
> + if (i != infos.begin() && i[-1].expanded()
> + && i[-1].next_letters().empty())
>
Next letters are non-empty only for expanded zones, so the condition
could be simplified a bit. Should this also apply if the previous zone is
not expanded?
I.e., should we compute the total `prev_offset` above. This will not be
visible in the test,
that is currently included, as we are querying time before the boundary.
> + {
> + sys_info prev;
> + i[-1].to(prev);
> + const seconds prev_total = prev.offset;
>
I would use prev.offset + prev.save.
> + const seconds new_total = info.offset;
> + const seconds jump = new_total - prev_total;
> + if (jump < 0s)
> {
> - // No rule applies before info.begin; fall back to the LETTERS
> - // of the earliest STD rule, since the period before the first
> - // DST transition is conventionally standard time.
> - const Rule* first_std = nullptr;
> - for (const auto& rule : rules)
> + const seconds window = -jump;
> + // Look for a rule firing in (info.begin, info.begin+window].
> + const Rule* merge_rule
> + = find_active_rule(rules,
> + info.begin + window + seconds(1),
> + ri.offset());
> + if (merge_rule
> + && merge_rule->start_time(year_month_day{
> + chrono::floor<days>(
> + info.begin + window)
> + }.year(),
> + ri.offset()) > info.begin)
> {
> - if (rule.save != minutes(0))
> - continue;
> - if (!first_std)
> - first_std = &rule;
> - else if (rule.from < first_std->from)
> - first_std = &rule;
> - else if (rule.from == first_std->from)
> - {
> - if (rule.start_time(rule.from, {})
> - < first_std->start_time(first_std->from, {}))
> - first_std = &rule;
> - }
> + info.offset = ri.offset() + merge_rule->save;
> + info.save
> + = chrono::duration_cast<minutes>(merge_rule->save);
> + letters = merge_rule->letters;
> }
> - if (first_std)
> - letters = first_std->letters;
> }
> }
>
> @@ -1105,9 +1146,6 @@ namespace std::chrono
>
> if (t < rule_start && rule_start < info.end)
> {
> - if (rule_start - t < days(1)) // XXX shouldn't be needed!
> - continue;
> -
> // Found a closer transition than the previous info.end.
> info.end = rule_start;
> next_rule = &rule;
> diff --git a/libstdc++-v3/testsuite/std/time/time_zone/zone_merge.cc
> b/libstdc++-v3/testsuite/std/time/time_zone/zone_merge.cc
> new file mode 100644
> index 000000000..75942f976
> --- /dev/null
> +++ b/libstdc++-v3/testsuite/std/time/time_zone/zone_merge.cc
> @@ -0,0 +1,84 @@
> +// { dg-do run { target c++20 } }
> +// { dg-require-effective-target tzdb }
> +// { dg-require-effective-target cxx11_abi }
> +// { dg-xfail-run-if "no weak override on AIX" { powerpc-ibm-aix* } }
> +
> +// When two adjacent Zone lines differ in total offset and the new line's
> +// rule set has a rule firing within |jump| of the boundary (where jump
> +// is a backward local-time jump), zic.c's writezone folds that rule
> +// into the boundary, so the new line begins with the post-rule save.
> +//
> +// Mirrors America/Argentina/Buenos_Aires around 1999-10-03.
> +
> +#include <chrono>
> +#include <fstream>
> +#include <testsuite_hooks.h>
> +
> +static bool override_used = false;
> +
> +namespace __gnu_cxx
> +{
> + const char* zoneinfo_dir_override() {
> + override_used = true;
> + return "./";
> + }
> +}
> +
> +int
> +main()
> +{
> + using namespace std::chrono;
> +
> + // stdoff jumps from -3 to -4 at the same instant a save=1 rule fires.
> + // In the new (-4) frame the rule fires 1 hour after the boundary at
> + // UT 03:00, so the merge folds the rule into the boundary and the
> + // new line begins at offset=-3, save=1 (abbrev "-03").
> + std::ofstream("tzdata.zi") << R"(# version test_zone_merge
> +R T 1999 o - O 3 0 1 -
> +R T 2000 o - Mar 3 0 0 -
> +Z Test/BA -3 - %z 1999 O 3
> + -4 T %z 2000 Mar 3
> + -3 - %z
> +)";
> +
> + const auto& db = reload_tzdb();
> + VERIFY( override_used );
> + VERIFY( db.version == "test_zone_merge" );
> +
> + auto* tz = locate_zone("Test/BA");
> +
> + // The boundary is the wall UNTIL "1999 O 3" (default time 00:00)
> + // interpreted in the prior (-3) frame, i.e. UT 03:00 1999-10-03.
> + sys_seconds boundary{sys_days(1999y/October/3) + 3h};
> +
> + auto before = tz->get_info(boundary - 1s);
> + VERIFY( before.offset == -3h );
> + VERIFY( before.save == 0min );
> + VERIFY( before.abbrev == "-03" );
> +
> + // The new line's first sys_info already has save=1 from the merge,
> + // total offset -3h, abbrev "-03".
> + auto at_boundary = tz->get_info(boundary);
> + VERIFY( at_boundary.offset == -3h );
> + VERIFY( at_boundary.save == 60min );
> + VERIFY( at_boundary.abbrev == "-03" );
> +
> + auto plus_30min = tz->get_info(boundary + 30min);
> + VERIFY( plus_30min.offset == -3h );
> + VERIFY( plus_30min.save == 60min );
> + VERIFY( plus_30min.abbrev == "-03" );
> +
> + // Sanity: well after the boundary, still in the merged sys_info
> + // until the Mar 3 2000 transition.
> + auto winter = tz->get_info(sys_days(2000y/January/15));
> + VERIFY( winter.offset == -3h );
> + VERIFY( winter.save == 60min );
> + VERIFY( winter.abbrev == "-03" );
> +
> + // After Mar 3 2000: line 2 ends, line 3 begins. No DST rule fires
> + // at this boundary, so total offset reverts to -3h with save=0.
> + auto spring = tz->get_info(sys_days(2000y/April/15));
> + VERIFY( spring.offset == -3h );
> + VERIFY( spring.save == 0min );
> + VERIFY( spring.abbrev == "-03" );
> +}
> --
> 2.34.1
>
>