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
>
>

Reply via email to