[PATCH 5/5] libstdc++: Implement zic writezone merge optimization [PR 124854]

Alvaro Begue alvaro.begue@gmail.com
Sat Apr 11 13:33:09 GMT 2026


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.

   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::_Impl::_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.
---
 libstdc++-v3/src/c++20/tzdb.cc                | 154 ++++++++++++------
 .../std/time/time_zone/zone_merge.cc          | 101 ++++++++++++
 2 files changed, 208 insertions(+), 47 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 76ec956b5..0da232834 100644
--- a/libstdc++-v3/src/c++20/tzdb.cc
+++ b/libstdc++-v3/src/c++20/tzdb.cc
@@ -1052,54 +1052,117 @@ namespace std::chrono
     if (i != infos.begin() && i[-1].expanded())
       letters = i[-1].next_letters();
 
-    if (letters.empty())
-      {
-	// We want the rule whose effect is in force at info.begin --
-	// including a rule that fires at exactly info.begin (its effect
-	// has just begun and is active for the first sys_info we are
-	// about to generate).  The search below uses a strict
-	// `rule_start < t` comparison, so pass info.begin + 1s to make
-	// the half-open lookup (..., info.begin] inclusive of the
-	// boundary instant.  This is what makes named-rule zone lines
-	// like Africa/Algiers (PR 116110) seed with the correct save:
-	// the Oct-21 rule fires at Oct 20 23:00 UTC in the new line's
-	// frame, which is exactly the new line's 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 whose effect is in
+    // force at info.begin.  This must run even when `letters` was
+    // already populated from i[-1].next_letters() (which happens
+    // during a re-entry of partial lazy expansion: the previous batch
+    // left an expanded ZoneInfo whose next_letters() field is the
+    // letters for the first sys_info of this batch), because
+    // info.offset/save are still at their stdoff/0 init values and
+    // would otherwise carry through into the first emitted sys_info
+    // with the wrong total offset.
+    //
+    // The search uses a strict `rule_start < t` comparison, so pass
+    // info.begin + 1s to make the half-open lookup (..., info.begin]
+    // inclusive of the boundary instant.  This is what makes named-
+    // rule zone lines like Africa/Algiers (PR 116110) seed with the
+    // correct save: the Oct-21 rule fires at Oct 20 23:00 UTC in the
+    // new line's frame, which is exactly the new line's 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 previous zone
+    // line's end total offset differs from this line's seeded total
+    // and the local time would jump backward at the boundary, look
+    // for a rule in this line's set that fires within the resulting
+    // gap window and would compensate the jump.  zic folds such a
+    // rule into the boundary transition; we mirror that here by
+    // pulling the rule's save back to info.begin.
+    //
+    // Only runs at the first sys_info of a zone line (not on re-entry
+    // mid-line during partial lazy expansion), which is detected by
+    // an empty next_letters() on the prior expanded ZoneInfo: a mid-
+    // line re-entry's prior ZoneInfo always has a non-empty
+    // next_letters() (the letters of the rule that fires at the new
+    // batch's start), whereas a zone-line transition's prior ZoneInfo
+    // ends with empty next_letters() because the line's last
+    // iteration emits with letters cleared.
+    //
+    // Canonical examples:
+    //   * America/Argentina/Buenos_Aires 1999-10-03: lines
+    //       -3 A -03/-02 1999 O 3
+    //       -4 A -04/-03 2000 Mar 3
+    //     have new_total = -4, prev_total = -3 (jump = -1h).  The
+    //     1999 Oct Argentina rule fires at Oct 3 04:00 UTC in the
+    //     new -4 frame, which is exactly info.begin + 1h, inside
+    //     the 1h window.
+    //   * Europe/Berlin 1945-05-24: lines
+    //       1 c CE%sT 1945 May 24 2
+    //       1 So CE%sT 1946
+    //     have new_total = 1, prev_total = 2 (jump = -1h).  The So
+    //     1945-May-24 rule (save = 2h, "CEMT") fires at 01:00 UTC
+    //     in the new frame, inside the 1h window.
+    if (i != infos.begin() && i[-1].expanded()
+	  && i[-1].next_letters().empty())
+      {
+	sys_info prev;
+	i[-1].to(prev);
+	const seconds prev_total = prev.offset;
+	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;
 	  }
       }
 
@@ -1148,9 +1211,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..87f55b9d6
--- /dev/null
+++ b/libstdc++-v3/testsuite/std/time/time_zone/zone_merge.cc
@@ -0,0 +1,101 @@
+// { 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* } }
+
+// Regression test: zic.c's writezone merges a zone-line transition with
+// a rule firing that would otherwise create a brief observably-wrong
+// stretch of local time.  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.
+//
+// Two canonical real-world cases:
+//   * America/Argentina/Buenos_Aires 1999-10-03 (lines change stdoff
+//     -3 → -4 with an Argentina DST rule firing on the same day).
+//   * Europe/Berlin 1945-05-24 (lines split a rule set, with the So
+//     1945-May-24 rule firing inside the boundary's window).
+//
+// Mirror the Buenos Aires shape with a synthetic zone.
+
+#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;
+
+  // Argentina-style: stdoff jumps from -3 to -4 at the same instant
+  // a save=1 ("S") rule fires.  In the new (-4) frame, the rule's
+  // wall at_time of 00:00 is at UT 04:00, which is 1 hour after the
+  // boundary at UT 03:00.  Without the merge optimization the new
+  // line would seed with save=0 (offset -4, abbrev -04) for that 1
+  // hour and then transition to save=1 (offset -3, abbrev -03);
+  // with the merge, the boundary itself is at offset=-3, save=1.
+  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" );
+
+  // At the boundary the merge optimization kicks in: the second zone
+  // line's first sys_info should already have save=1 from the Oct 3
+  // rule, total offset -3h, abbrev "-03".  Without the fix, chrono
+  // would emit a 1-hour stretch of save=0 ("-04") here.
+  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



More information about the Libstdc++ mailing list