From ff70eb10b7573fd81cd37c4bfb75d98fc877fe39 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:12:00 +0200 Subject: [PATCH 1/3] Time: follow daylight saving time in the Windows machine zone With TZ unset, RubyTimeZone.Machine() reads /etc/localtime, and on Windows, which has none, fell back to a fixed offset taken from TimeZoneInfo.Local.BaseUtcOffset. That is the zone's standard offset, so every local Time was an hour off all summer: Time.now on 2026-10-12 in Europe/Zurich ("W. Europe Standard Time") said 20:20:46 +0100 while DateTime.Now said 21:20:46, and Time.at, Time.local/mktime/new, #localtime and dst? were wrong the same way. The machine zone is now backed by TimeZoneInfo.Local itself and asked for each instant (GetUtcOffset, IsDaylightSavingTime), so it carries the zone's DST and historical rules. Wall-clock readings go through the same GetOffsetForWallClock as the zoneinfo-backed zones, so a reading in the spring gap or the autumn overlap resolves as in MRI. Time#zone reports the zone's DaylightName during daylight saving time and its StandardName otherwise. Nothing changes where zoneinfo exists: /etc/localtime and the TZ ids and POSIX rules still go through TzFile. Util/time-local-matrix.rb prints utc_offset, dst? and the wall clock for the machine's zone across a year, through both EU and US changes and for the gap and overlap readings. On Windows 11 in W. Europe Standard Time, against CRuby 4.0.1 on the same machine: 102 of 299 lines differed before, none after; with TZ=CET-1CEST,M3.5.0,M10.5.0/3 (the TzFile path) the output is identical too. --- Src/Libraries/Builtins/RubyTimeSupport.cs | 73 +++++++++++++++------- Util/time-local-matrix.rb | 75 +++++++++++++++++++++++ 2 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 Util/time-local-matrix.rb diff --git a/Src/Libraries/Builtins/RubyTimeSupport.cs b/Src/Libraries/Builtins/RubyTimeSupport.cs index 4e0aef0a8..6943924d4 100644 --- a/Src/Libraries/Builtins/RubyTimeSupport.cs +++ b/Src/Libraries/Builtins/RubyTimeSupport.cs @@ -476,49 +476,73 @@ namespace IronRuby.Builtins { /// /// A time zone as Ruby sees it. Backed by the zoneinfo file when one exists, because that - /// carries the DST transitions and the POSIX abbreviations that Ruby reports; otherwise a - /// plain fixed offset parsed out of a POSIX TZ string. + /// carries the DST transitions and the POSIX abbreviations that Ruby reports; on Windows, + /// which has no zoneinfo, by the .NET TimeZoneInfo; otherwise a plain fixed offset parsed + /// out of a POSIX TZ string. /// public sealed class RubyTimeZone { - private readonly TzFile _file; // null for a fixed-offset zone + private readonly TzFile _file; // null unless backed by zoneinfo or a POSIX rule + private readonly TimeZoneInfo _clrZone; // null unless backed by a .NET zone private readonly long _fixedOffset; // seconds east of UTC private readonly string/*!*/ _name; - public static readonly RubyTimeZone/*!*/ Utc = new RubyTimeZone(null, 0, "UTC"); + public static readonly RubyTimeZone/*!*/ Utc = new RubyTimeZone(null, null, 0, "UTC"); - private RubyTimeZone(TzFile file, long fixedOffset, string/*!*/ name) { + private RubyTimeZone(TzFile file, TimeZoneInfo clrZone, long fixedOffset, string/*!*/ name) { _file = file; + _clrZone = clrZone; _fixedOffset = fixedOffset; _name = name; } internal static RubyTimeZone/*!*/ MakeFixed(long offsetSeconds, string/*!*/ name) { - return new RubyTimeZone(null, offsetSeconds, name); + return new RubyTimeZone(null, null, offsetSeconds, name); } internal static RubyTimeZone/*!*/ FromPosixRule(string/*!*/ stdName, long stdOffset, string/*!*/ dstName, long dstOffset, string startRule, string endRule) { TzFile file = TzFile.FromPosixRule(stdName, (int)stdOffset, dstName, (int)dstOffset, startRule, endRule); - return new RubyTimeZone(file, 0, stdName); + return new RubyTimeZone(file, null, 0, stdName); + } + + /// + /// A .NET zone. It has to be asked for the offset of each instant: its BaseUtcOffset is + /// the standard offset only, an hour off all summer in a zone with daylight saving time. + /// + internal static RubyTimeZone/*!*/ FromTimeZoneInfo(TimeZoneInfo/*!*/ zone) { + return new RubyTimeZone(null, zone, 0, zone.StandardName); } internal static RubyTimeZone FromId(string/*!*/ id) { TzFile file = TzFile.TryLoad(id); - return (file != null) ? new RubyTimeZone(file, 0, id) : null; + return (file != null) ? new RubyTimeZone(file, null, 0, id) : null; } /// The machine's zone, used when TZ is unset. internal static RubyTimeZone/*!*/ Machine() { TzFile file = TzFile.TryLoadFile("/etc/localtime"); if (file != null) { - return new RubyTimeZone(file, 0, System.TimeZoneInfo.Local.Id); + return new RubyTimeZone(file, null, 0, System.TimeZoneInfo.Local.Id); } - var local = System.TimeZoneInfo.Local; - return MakeFixed((long)local.BaseUtcOffset.TotalSeconds, local.StandardName); + return FromTimeZoneInfo(System.TimeZoneInfo.Local); + } + + /// The instant as a UTC DateTime, clamped to the range DateTime can hold. + private static DateTime ToUtcDateTime(long unixSeconds) { + long min = (DateTime.MinValue.Ticks - RubyTime.Epoch.Ticks) / TimeSpan.TicksPerSecond; + long max = (DateTime.MaxValue.Ticks - RubyTime.Epoch.Ticks) / TimeSpan.TicksPerSecond; + unixSeconds = Math.Max(min, Math.Min(max, unixSeconds)); + return RubyTime.Epoch.AddTicks(unixSeconds * TimeSpan.TicksPerSecond); } internal long GetOffset(long unixSeconds) { - return (_file != null) ? _file.GetOffset(unixSeconds) : _fixedOffset; + if (_file != null) { + return _file.GetOffset(unixSeconds); + } + if (_clrZone != null) { + return (long)_clrZone.GetUtcOffset(ToUtcDateTime(unixSeconds)).TotalSeconds; + } + return _fixedOffset; } /// @@ -529,13 +553,13 @@ internal long GetOffset(long unixSeconds) { /// never occurs (the clocks went forward) is taken with the offset from before the change. /// internal long GetOffsetForWallClock(long wallSeconds, bool preferDst = false) { - if (_file == null) { + if (_file == null && _clrZone == null) { return _fixedOffset; } - long before = _file.GetOffset(wallSeconds - 86400); - long after = _file.GetOffset(wallSeconds + 86400); - bool beforeFits = _file.GetOffset(wallSeconds - before) == before; - bool afterFits = _file.GetOffset(wallSeconds - after) == after; + long before = GetOffset(wallSeconds - 86400); + long after = GetOffset(wallSeconds + 86400); + bool beforeFits = GetOffset(wallSeconds - before) == before; + bool afterFits = GetOffset(wallSeconds - after) == after; if (beforeFits && afterFits) { return preferDst ? Math.Max(before, after) : Math.Min(before, after); } @@ -546,9 +570,9 @@ internal long GetOffsetForWallClock(long wallSeconds, bool preferDst = false) { return before; } // neither fits: a gap, or more than one change within the day - long guess = _file.GetOffset(wallSeconds); - long second = _file.GetOffset(wallSeconds - guess); - return (_file.GetOffset(wallSeconds - second) == second) ? second : before; + long guess = GetOffset(wallSeconds); + long second = GetOffset(wallSeconds - guess); + return (GetOffset(wallSeconds - second) == second) ? second : before; } internal string/*!*/ GetAbbreviation(long unixSeconds) { @@ -558,11 +582,18 @@ internal long GetOffsetForWallClock(long wallSeconds, bool preferDst = false) { return result; } } + if (_clrZone != null && IsDaylightSavingTime(unixSeconds)) { + // .NET has no abbreviations, only names such as "W. Europe Daylight Time" + return _clrZone.DaylightName; + } return _name; } internal bool IsDaylightSavingTime(long unixSeconds) { - return _file != null && _file.IsDaylightSavingTime(unixSeconds); + if (_file != null) { + return _file.IsDaylightSavingTime(unixSeconds); + } + return _clrZone != null && _clrZone.IsDaylightSavingTime(ToUtcDateTime(unixSeconds)); } internal string/*!*/ Name { diff --git a/Util/time-local-matrix.rb b/Util/time-local-matrix.rb new file mode 100644 index 000000000..2a6e47087 --- /dev/null +++ b/Util/time-local-matrix.rb @@ -0,0 +1,75 @@ +# Cross-checks Time's local zone - utc_offset, dst? and the wall clock - against CRuby, +# for the machine's own zone (TZ unset) across a year, through both daylight saving +# changes of the European and the US rules, and for wall-clock readings in the gaps and +# overlaps. +# +# ruby Util/time-local-matrix.rb > /tmp/cruby.txt +# ./ir.sh Util/time-local-matrix.rb > /tmp/ir.txt +# diff -u /tmp/cruby.txt /tmp/ir.txt +# +# Run both with TZ unset and on the same machine: the expected values are whatever that +# machine's zone says, so the script is only interesting in a zone that has daylight saving +# time (Europe/Zurich, America/New_York, ...). A zone that IronRuby resolves to a fixed +# offset passes in winter and fails every summer line. Zone names are left out because +# CRuby on Windows reports the C runtime's names, not the POSIX abbreviations. + +def fmt(time) + format("%04d-%02d-%02d %02d:%02d:%02d off=%d dst=%p", time.year, time.month, time.day, + time.hour, time.min, time.sec, time.utc_offset, time.dst?) +end + +def t(label) + puts "#{label}: #{yield}" +rescue Exception => e + puts "#{label}: RAISED #{e.class}: #{e.message}" +end + +# Time.now: the offset must be the one in force now, not the zone's standard offset. +now = Time.now +t("now consistent") { (now.utc_offset == Time.at(now.to_i).utc_offset).inspect } +t("now wall clock") { ((now.to_i + now.utc_offset) % 86400 == (now.hour * 3600 + now.min * 60 + now.sec)).inspect } + +# Time.at and #localtime for the middle and the 1st of every month. +year = 2026 +(1..12).each do |month| + [1, 15].each do |day| + instant = Time.utc(year, month, day, 12, 0, 0).to_i + t("at #{year}-#{month}-#{day} 12:00Z") { fmt(Time.at(instant)) } + t("localtime #{year}-#{month}-#{day} 12:00Z") { fmt(Time.utc(year, month, day, 12, 0, 0).localtime) } + end +end + +# Every hour across the days of the changes: last Sunday of March and of October (EU), +# second Sunday of March and first Sunday of November (US), in UTC so the labels do not +# depend on the zone. +[[3, 28], [3, 29], [3, 7], [3, 8], [10, 24], [10, 25], [10, 31], [11, 1]].each do |month, day| + base = Time.utc(year, month, day).to_i + 24.times do |hour| + t("at #{year}-#{month}-#{day} #{hour}:30Z") { fmt(Time.at(base + hour * 3600 + 1800)) } + end +end + +# Wall-clock constructors, including the readings that do not exist (the clocks went +# forward) and the ones that occur twice (the clocks went back) under either rule. +WALL_CLOCKS = [ + [2026, 1, 15, 12, 0, 0], [2026, 7, 15, 12, 0, 0], [2026, 10, 12, 21, 20, 46], + [2026, 3, 29, 1, 30, 0], [2026, 3, 29, 2, 30, 0], [2026, 3, 29, 3, 30, 0], + [2026, 10, 25, 1, 30, 0], [2026, 10, 25, 2, 30, 0], [2026, 10, 25, 3, 30, 0], + [2026, 3, 8, 1, 30, 0], [2026, 3, 8, 2, 30, 0], [2026, 3, 8, 3, 30, 0], + [2026, 11, 1, 0, 30, 0], [2026, 11, 1, 1, 30, 0], [2026, 11, 1, 2, 30, 0], + [1999, 7, 1, 0, 0, 0], [2040, 7, 1, 0, 0, 0], +] +WALL_CLOCKS.each do |args| + label = args.join(",") + t("local #{label}") { fmt(Time.local(*args)) } + t("mktime #{label}") { fmt(Time.mktime(*args)) } + t("new #{label}") { fmt(Time.new(*args)) } +end + +# A local Time keeps its offset through arithmetic that crosses a change. +t("summer - 120 days") { fmt(Time.local(2026, 7, 15, 12) - 120 * 86400) } +t("winter + 180 days") { fmt(Time.local(2026, 1, 15, 12) + 180 * 86400) } +t("to_a summer") { Time.local(2026, 7, 15, 12).to_a.values_at(0..8).inspect } +t("strftime %z summer") { Time.local(2026, 7, 15, 12).strftime("%z") } +t("strftime %z winter") { Time.local(2026, 1, 15, 12).strftime("%z") } +t("inspect summer") { Time.local(2026, 7, 15, 12).inspect.sub(/ [A-Za-z].*\z/, "") } From 30e649e89d1ac9a0eb9f586143bb91da69b5bf41 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:12:39 +0200 Subject: [PATCH 2/3] Time: resolve TZ through TimeZoneInfo on Windows TZ=America/Los_Angeles (or Europe/Zurich, or a Windows id such as "W. Europe Standard Time") is looked up in the zoneinfo directories, which Windows does not have, then parsed as a POSIX rule, which it is not, so the Time came out in UTC. On Windows, an id with no zoneinfo file is now handed to TimeZoneInfo.FindSystemTimeZoneById, which knows the Windows ids and, through ICU, the Olson ones; POSIX strings such as "PST8:00:00" or "CET-1CEST,M3.5.0,M10.5.0/3" are not ids and still take the POSIX path. Linux and macOS are unchanged: there the lookup stays zoneinfo, then POSIX. spec/core/time on Windows: 639 examples, 1 failure (Time#dst? in America/Los_Angeles) before this and the previous commit; 0 failures after. Together they also fix File::Stat#atime/#ctime/#mtime/#birthtime there, whose specs compare with local Times. --- Src/Libraries/Builtins/RubyTimeSupport.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Src/Libraries/Builtins/RubyTimeSupport.cs b/Src/Libraries/Builtins/RubyTimeSupport.cs index 6943924d4..883c8d905 100644 --- a/Src/Libraries/Builtins/RubyTimeSupport.cs +++ b/Src/Libraries/Builtins/RubyTimeSupport.cs @@ -515,7 +515,22 @@ private RubyTimeZone(TzFile file, TimeZoneInfo clrZone, long fixedOffset, string internal static RubyTimeZone FromId(string/*!*/ id) { TzFile file = TzFile.TryLoad(id); - return (file != null) ? new RubyTimeZone(file, null, 0, id) : null; + if (file != null) { + return new RubyTimeZone(file, null, 0, id); + } + + // Windows has no zoneinfo files, but its TimeZoneInfo knows both the Windows ids + // ("W. Europe Standard Time") and, through ICU, the Olson ones ("Europe/Zurich"). + if (RubyFileOps.IsWindows) { + try { + return FromTimeZoneInfo(TimeZoneInfo.FindSystemTimeZoneById(id)); + } catch (TimeZoneNotFoundException) { + } catch (InvalidTimeZoneException) { + } catch (System.Security.SecurityException) { + } catch (ArgumentException) { + } + } + return null; } /// The machine's zone, used when TZ is unset. From f9817feabff92f5f36883b9b7cbc436eebfd0974 Mon Sep 17 00:00:00 2001 From: Largo Date: Tue, 13 Oct 2026 10:12:57 +0200 Subject: [PATCH 3/3] Time#_dump: keep a non-ASCII zone name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _dump wrote the zone name with MutableString.CreateAscii, so on a Windows whose zone names are localized ("Mitteleuropäische Zeit") Marshal.dump of any local Time raised EncoderFallbackException. It now encodes the name as Time#zone already does: ASCII when it is, the path encoding otherwise. spec/core/marshal on a German Windows: 6 errors before (all "for a Time"), none after. --- Src/Libraries/Builtins/RubyTimeOps.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Src/Libraries/Builtins/RubyTimeOps.cs b/Src/Libraries/Builtins/RubyTimeOps.cs index 092b0f9db..061a40e61 100644 --- a/Src/Libraries/Builtins/RubyTimeOps.cs +++ b/Src/Libraries/Builtins/RubyTimeOps.cs @@ -927,9 +927,11 @@ private static int GetYear(RubyContext/*!*/ context, object[]/*!*/ components, i Invoke(context, ref _nameSite, "name", self.ZoneObject)); } else { // A time made with a numeric offset has no zone to name, and MRI writes nil. + // On Windows the name is the localized one ("Mitteleuropäische Zeit"), as in #zone. string zoneName = self.HasFixedOffset ? null : self.GetZoneName(); - context.SetInstanceVariable(result, "zone", - zoneName != null ? MutableString.CreateAscii(zoneName).Freeze() : null); + context.SetInstanceVariable(result, "zone", zoneName == null ? null : (zoneName.IsAscii() + ? MutableString.CreateAscii(zoneName) + : MutableString.Create(zoneName, context.GetPathEncoding())).Freeze()); } }