[PATCH 3/5] libstdc++: Resolve named-rule UNTIL save adjustment [PR116110]

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


The previous patches for PR 116110 left one case unresolved: a Zone
line with a wall-time UNTIL whose RULES field is a named rule set.
The save value used to convert the wall UNTIL to UTC depends on which
rule of the set was active at the UNTIL instant, but at parse time
the rule records have not all been loaded, so the active-rule lookup
can't yet be performed.

The remaining FIXME in operator>>(istream&, ZoneInfo&) caused zones
like Africa/Algiers (around 1977-10-21) to place their zone-line
boundary one save-period off from the canonical zic interpretation,
producing brief incorrect sys_info windows during DST transitions.

This commit defers the save adjustment to a fixup pass run after
ranges::stable_sort(node->rules) at the end of reload_tzdb.

A new ZoneInfo::m_until_save_pending bit (stolen from m_pos:15, which
becomes m_pos:14 -- still 16384 max, far above any realistic offset
into m_buf) marks pending entries.  The parser sets this bit when
it sees a wall-time UNTIL on a named-rule line and skips the save
subtraction.  The fixup pass walks every zone's ZoneInfos, looks up
the active rule, and applies the deferred adjustment.

The active-rule lookup uses a new helper find_pre_until_rule() that
walks all (rule, year) pairs in chronological order and maintains a
running save value, so wall-time rules' TIME fields are interpreted
relative to the cascaded save state.  The boundary it compares
against shrinks as the running save grows, which gives zic.c's
interpretation: a rule firing at exactly the wall UNTIL belongs to
the next zone line, not the current one.

The lazy-expansion seeding code that finds the active rule at
info.begin is also updated to use a half-open `rule_start < t`
window with `t = info.begin + 1s`, so a rule firing at exactly
info.begin (the new line's first instant) is correctly identified
as in force.  Without this, the new line would seed with the wrong
save and the first sys_info would have the wrong total offset and
abbreviation.

The test_apia case in 116110.cc had a hardcoded `+11h` workaround
for the unfixed bug; with this fix in place the workaround is removed
and the value becomes the canonical `+10h`.

libstdc++-v3/ChangeLog:

	PR libstdc++/116110
	* src/c++20/tzdb.cc (ZoneInfo): Add m_until_save_pending bit
	(stolen from m_pos:15) and accessors until_save_pending(),
	set_until_save_pending(), clear_until_save_pending(), and
	adjust_until().
	(find_pre_until_rule): New function.  Chronological cascade
	walker with iterative-boundary semantics, used by the post-
	parse fixup pass.
	(operator>>(istream&, ZoneInfo&)): Set m_until_save_pending
	when the wall UNTIL on a named-rule line cannot have its save
	subtracted at parse time.  Replaces the FIXME.
	(time_zone::_Impl::_M_get_sys_info): Change the seeding active-
	rule lookup to use t = info.begin + 1s, so a rule firing at
	exactly info.begin is included.
	(reload_tzdb): After sorting node->rules, run a fixup pass over
	every ZoneInfo with until_save_pending set, calling
	find_pre_until_rule and adjust_until.
	* testsuite/std/time/time_zone/116110.cc (test_apia): Remove
	the +11h workaround for the unfixed named-rule UNTIL bug; the
	canonical +10h boundary is now produced.
	* testsuite/std/time/time_zone/pr116110_named.cc: New test.
---
 libstdc++-v3/src/c++20/tzdb.cc                | 165 +++++++++++++++++-
 .../testsuite/std/time/time_zone/116110.cc    |   7 +-
 .../std/time/time_zone/pr116110_named.cc      | 106 +++++++++++
 3 files changed, 273 insertions(+), 5 deletions(-)
 create mode 100644 libstdc++-v3/testsuite/std/time/time_zone/pr116110_named.cc

diff --git a/libstdc++-v3/src/c++20/tzdb.cc b/libstdc++-v3/src/c++20/tzdb.cc
index 8cbf3213d..2e910c99d 100644
--- a/libstdc++-v3/src/c++20/tzdb.cc
+++ b/libstdc++-v3/src/c++20/tzdb.cc
@@ -520,6 +520,26 @@ namespace std::chrono
       sys_seconds
       until() const noexcept { return m_until; }
 
+      // PR 116110: When the parser sees a wall-time UNTIL on a named-Rule
+      // line, the SAVE component of the conversion can't be applied yet
+      // because the active Rule depends on the cumulative state of the
+      // (not-yet-loaded) rule set.  The parser leaves m_until in the
+      // "STDOFF subtracted but SAVE not yet subtracted" intermediate
+      // state and sets this bit; reload_tzdb runs a fixup pass after all
+      // Rule records are loaded to subtract the SAVE.
+      bool
+      until_save_pending() const noexcept { return m_until_save_pending; }
+
+      void
+      set_until_save_pending() noexcept { m_until_save_pending = 1; }
+
+      void
+      clear_until_save_pending() noexcept { m_until_save_pending = 0; }
+
+      // Subtract `s` from m_until.  Used by the PR 116110 fixup pass.
+      void
+      adjust_until(seconds s) noexcept { m_until -= s; }
+
       friend istream& operator>>(istream&, ZoneInfo&);
 
       bool
@@ -575,8 +595,9 @@ namespace std::chrono
       }
 
       string m_buf;     // rules() + ' ' + format() OR letters + ' ' + format()
-      uint_least16_t m_pos : 15 = 0; // offset of format() in m_buf
+      uint_least16_t m_pos : 14 = 0; // offset of format() in m_buf
       uint_least16_t m_expanded : 1 = 0;
+      uint_least16_t m_until_save_pending : 1 = 0; // PR 116110, see above
       duration<int_least16_t, ratio<60>> m_save{};
       sec32_t m_offset{};
       sys_seconds m_until{};
@@ -683,6 +704,97 @@ namespace std::chrono
       }
 #endif
     };
+
+    // Find the Rule whose save value is in force at the wall-time UNTIL
+    // of a Zone line, given that `wall_minus_stdoff` is the line's UNTIL
+    // expressed in the "save=0" frame (i.e. the parsed wall UNTIL with
+    // the line's STDOFF subtracted) and `stdoff` is the line's standard
+    // offset.
+    //
+    // The function walks all (rule, year) pairs in chronological order,
+    // maintaining a running save value.  Wall-time rules have their TIME
+    // field interpreted relative to the running save (since "wall" means
+    // local civil time = stdoff + save), so a rule's effective UT firing
+    // time depends on which prior rule was last in force.  This matches
+    // zic.c's outzone() logic and is required for zone lines whose
+    // rule set has rules whose at_time depends on cascading saves.
+    //
+    // The comparison `fire < boundary` shrinks `boundary` as the running
+    // save cascades up: a rule that fires AT the boundary (with the
+    // cascaded save applied) is treated as belonging to the next zone
+    // line, and its save is excluded from the running total.
+    //
+    // Canonical case: Africa/Algiers 1977-10-21.  The "Algeria 1977
+    // Oct 21" rule (save=0) fires at the same instant as the wall UNTIL
+    // of line 6 ("0 d WE%sT 1977 O 21").  The pre-rule save (1h, from
+    // the May 6 rule) is what determines the boundary's UT placement,
+    // not the Oct 21 rule's save=0.
+    //
+    // The calendar window is extended by one year on each side, to
+    // catch rules whose wall-time at_time falls in early January or
+    // late December but whose UT firing crosses a year boundary due
+    // to a large stdoff or save.
+    template<typename _RuleRange>
+      const Rule*
+      find_pre_until_rule(const _RuleRange& rules,
+			  sys_seconds wall_minus_stdoff, seconds stdoff)
+      {
+	if (rules.empty())
+	  return nullptr;
+
+	const year last_year
+	  = year_month_day{chrono::floor<days>(wall_minus_stdoff)}.year()
+	  + years(1);
+	year first_year = year::max();
+	for (const auto& r : rules)
+	  if (r.from < first_year)
+	    first_year = r.from;
+	if (first_year > last_year)
+	  return nullptr;
+
+	struct Pending
+	{
+	  const Rule* rule;
+	  year y;
+	  sys_seconds approx_when;
+	};
+	vector<Pending> pending;
+	pending.reserve(64);
+	for (year y = first_year; y <= last_year; ++y)
+	  for (const auto& r : rules)
+	    {
+	      if (y < r.from || y > r.to)
+		continue;
+	      seconds approx_off{};
+	      if (r.when.indicator == at_time::Wall
+		    || r.when.indicator == at_time::Standard)
+		approx_off = stdoff;
+	      pending.push_back({&r, y, r.start_time(y, approx_off)});
+	    }
+	std::sort(pending.begin(), pending.end(),
+		  [](const Pending& a, const Pending& b) {
+		    return a.approx_when < b.approx_when;
+		  });
+
+	seconds running_save{};
+	sys_seconds boundary = wall_minus_stdoff;
+	const Rule* last_fired = nullptr;
+	for (const auto& p : pending)
+	  {
+	    seconds offset{};
+	    if (p.rule->when.indicator == at_time::Wall)
+	      offset = stdoff + running_save;
+	    else if (p.rule->when.indicator == at_time::Standard)
+	      offset = stdoff;
+	    sys_seconds fire = p.rule->start_time(p.y, offset);
+	    if (fire >= boundary)
+	      continue;
+	    last_fired = p.rule;
+	    running_save = p.rule->save;
+	    boundary = wall_minus_stdoff - running_save;
+	  }
+	return last_fired;
+      }
   } // namespace
 #endif // TZDB_DISABLED
 
@@ -871,7 +983,17 @@ namespace std::chrono
 
     if (letters.empty())
       {
-	sys_seconds t = info.begin - seconds(1);
+	// 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);
 	const year_month_day date(chrono::floor<days>(t));
 
 	// Try to find a Rule active before this time, to get initial
@@ -1629,6 +1751,37 @@ namespace std::chrono
     ranges::sort(node->db.links, {}, &time_zone_link::name);
     ranges::stable_sort(node->rules, {}, &Rule::name);
 
+    // PR 116110 fixup pass.  For every Zone line whose UNTIL was a wall-
+    // time expression on a named-rule line, the parser deferred the SAVE
+    // adjustment because the active rule wasn't yet identifiable.  Now
+    // that all Rule records are loaded and indexed, walk every pending
+    // ZoneInfo, find the rule whose effect was in force just before the
+    // wall UNTIL, and subtract that rule's save from m_until.
+    //
+    // "Just before the wall UNTIL" matches zic.c's interpretation: the
+    // wall time of UNTIL is read in the frame in effect immediately
+    // prior to the boundary, so the SAVE used is the value that the
+    // most recent rule strictly before the UNTIL set.  A rule firing at
+    // exactly the UNTIL is not yet in force at the moment the wall
+    // time is being interpreted -- its effect belongs to the next zone
+    // line, not this one.  find_pre_until_rule's iterative-boundary
+    // walker implements this semantics.
+    for (const auto& tz : node->db.zones)
+      {
+	auto& infos = tz._M_impl->infos;
+	for (auto& info : infos)
+	  {
+	    if (!info.until_save_pending())
+	      continue;
+	    auto rules = ranges::equal_range(node->rules, info.rules(),
+					     ranges::less{}, &Rule::name);
+	    if (const Rule* r
+		  = find_pre_until_rule(rules, info.until(), info.offset()))
+	      info.adjust_until(seconds(r->save));
+	    info.clear_until_save_pending();
+	  }
+      }
+
     return Node::_S_replace_head(std::move(head), std::move(node));
 #else
     __throw_disabled();
@@ -2400,7 +2553,13 @@ namespace std::chrono
 		{
 		  if (inf.m_expanded) // Not a named Rule, SAVE is known now.
 		    inf.m_until -= inf.m_save;
-		  // else Named Rule, SAVE is unknown. FIXME: PR 116110
+		  else
+		    // Named Rule: SAVE depends on which rule of the set
+		    // was active at this instant, which can only be
+		    // determined once all Rule records are loaded.  Mark
+		    // the ZoneInfo so that the fixup pass in reload_tzdb
+		    // applies the deferred adjustment.  PR 116110.
+		    inf.set_until_save_pending();
 		}
 	    }
 	}
diff --git a/libstdc++-v3/testsuite/std/time/time_zone/116110.cc b/libstdc++-v3/testsuite/std/time/time_zone/116110.cc
index 26b9ba33c..4dd6647e1 100644
--- a/libstdc++-v3/testsuite/std/time/time_zone/116110.cc
+++ b/libstdc++-v3/testsuite/std/time/time_zone/116110.cc
@@ -65,8 +65,11 @@ test_apia()
   auto* tz = locate_zone("Pacific/Apia");
   local_seconds t = local_days(2011y/December/29) + 24h;
 
-  // FIXME: this should be + 10h but we do not account for DST yet, so + 11h.
-  sys_seconds ut(t.time_since_epoch() + 11h );
+  // The wall UNTIL "2011 Dec 29 24" is interpreted in the prior offset
+  // (-11h + save 1h = -10h), so the boundary is at local_days +24h +10h
+  // (the FIXME for the +11h compensation has been resolved by the
+  // fix for the named-rule UNTIL case in PR 116110).
+  sys_seconds ut(t.time_since_epoch() + 10h );
   sys_info info;
   info = tz->get_info(ut - 1s);
   VERIFY( info.offset == (-11h + info.save) );
diff --git a/libstdc++-v3/testsuite/std/time/time_zone/pr116110_named.cc b/libstdc++-v3/testsuite/std/time/time_zone/pr116110_named.cc
new file mode 100644
index 000000000..b088ae8e5
--- /dev/null
+++ b/libstdc++-v3/testsuite/std/time/time_zone/pr116110_named.cc
@@ -0,0 +1,106 @@
+// { 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 for PR 116110, named-rule case.
+//
+// A Zone line whose RULES references a named Rule and whose UNTIL is a
+// wall-time expression cannot have its UNTIL converted to a true UTC
+// instant at parse time, because the SAVE value at the UNTIL depends on
+// which rule of the named set was last in force just before that wall
+// time -- and that's not known until all Rule records have been loaded
+// and indexed.  The "Partial fix for interpretation of non-UTC UNTIL
+// times" commit handled the simpler cases (UNTIL with `s` indicator,
+// `u` indicator, or wall + non-named RULES) but explicitly left a
+// FIXME for the named-rule case.  This test exercises that case via
+// the canonical Africa/Algiers boundary at 1977-10-21.
+//
+// In this synthetic data:
+//   Rule d 1977 May  6 0:00 wall  save=1
+//   Rule d 1977 Oct 21 0:00 wall  save=0
+//   Z A    0 d WE%sT 1977 O 21
+//          1 d CE%sT
+//
+// The first Zone line has STDOFF=0 and uses rule set d.  The May rule
+// sets save=1 (WEST, total +1).  zic.c interprets the wall UNTIL
+// "1977 O 21" using the SAVE value in force just before the boundary
+// (i.e. May's save=1, since the Oct-21 rule in this line's frame fires
+// at exactly the boundary, after the wall time has been read).  So:
+//   wall(0:00) - stdoff(0) - save_just_before(1)  =  Oct 20 23:00 UTC
+// is the correct UTC instant of the line's end.
+//
+// The second Zone line has STDOFF=1 and uses the same rule set d.  In
+// its own frame, the Oct-21 rule fires at exactly its starting instant
+// (Oct 20 23:00 UTC = wall(0:00) - stdoff(1)), setting save=0.  So at
+// Oct 20 23:00 UTC the new line begins with stdoff=1, save=0, abbrev
+// "CET", total offset +1.  Both sides of the boundary have total +1;
+// only the (stdoff, save) split changes.  This is the merge that
+// zdump shows for real Africa/Algiers in October 1977.
+
+#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;
+
+  std::ofstream("tzdata.zi") << R"(# version test_pr116110_named
+R d 1977 o - May  6 0 1 S
+R d 1977 o - O   21 0 0 -
+Z Test/Algiers 0 d WE%sT 1977 O 21
+               1 d CE%sT
+)";
+
+  const auto& db = reload_tzdb();
+  VERIFY( override_used );
+  VERIFY( db.version == "test_pr116110_named" );
+
+  auto* tz = locate_zone("Test/Algiers");
+
+  // Just before the boundary: still in the first Zone line under
+  // the May-6 rule (save=1, WEST, total +1).
+  auto pre = tz->get_info(sys_days{1977y/October/20} + 22h);
+  VERIFY( pre.offset == 1h );
+  VERIFY( pre.save == 1h );
+  VERIFY( pre.abbrev == "WEST" );
+
+  // The "active rule just before the wall UNTIL" is May-6 (save=1),
+  // so the wall UNTIL "1977 O 21" gets adjusted by stdoff(0)+save(1).
+  // Without the fix, master leaves the line's m_until 1 hour too late
+  // and the query just before Oct 21 00:00 UTC is in the wrong frame.
+  // With the fix, queries strictly before the boundary stay in the
+  // first line (WEST) and queries at/after the boundary are in the
+  // second line (CET).
+  auto at = tz->get_info(sys_days{1977y/October/20} + 23h);
+  VERIFY( at.offset == 1h );    // stdoff 1 + save 0 (CET, second line)
+  VERIFY( at.save == 0min );
+  VERIFY( at.abbrev == "CET" );
+
+  // A second query inside the second line, well clear of the boundary.
+  auto after = tz->get_info(sys_days{1977y/October/21} + 12h);
+  VERIFY( after.offset == 1h );
+  VERIFY( after.save == 0min );
+  VERIFY( after.abbrev == "CET" );
+
+  // And a regression check that the boundary really moved: a query at
+  // 1977-10-20 23:30 UTC must be in the SECOND line.  Without the
+  // fix, master's m_until for the first line is 1977-10-21 00:00 UTC,
+  // and this query lands in the WET stretch produced by the first
+  // line's expansion of the Oct-21 rule.
+  auto window = tz->get_info(sys_days{1977y/October/20} + 23h + 30min);
+  VERIFY( window.offset == 1h );
+  VERIFY( window.abbrev == "CET" );
+}
-- 
2.34.1



More information about the Libstdc++ mailing list