Skip to content

Time: follow daylight saving time on Windows - #19

Merged
Largo merged 4 commits into
masterfrom
fix/windows-local-time-dst
Oct 13, 2026
Merged

Largo merged 4 commits into
masterfrom
fix/windows-local-time-dst

Conversation

@Largo

@Largo Largo commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

On Windows, Time.now ignored daylight saving time. Reproduced on 2026-10-12 in Europe/Zurich ("W. Europe Standard Time", TZ unset):

puts Time.now              # 2026-10-12 20:20:46 +0100  <- wrong
puts System::DateTime.Now  # 12.10.2026 21:20:46        <- correct

It also showed up inside a .NET 10 host (a JobRouter service running as LocalSystem, using irubyc --library).

Cause

With TZ unset, RubyTimeZone.Machine() reads /etc/localtime. Windows has no such file, so it 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.

Changes

  • Follow DST in the Windows machine zone. The machine zone now uses TimeZoneInfo.Local and looks up the offset for each instant (GetUtcOffset, IsDaylightSavingTime). This fixes Time.now, Time.at, Time.local/mktime/new, #localtime and dst?. Times in the spring gap or the autumn overlap go through the existing GetOffsetForWallClock, so they resolve as in MRI. Time#zone returns the zone's DaylightName during DST.
  • Resolve TZ through TimeZoneInfo on Windows. TZ=America/Los_Angeles (or Europe/Zurich, or a Windows id) used to end up as UTC. On Windows only, an id with no zoneinfo file now goes to FindSystemTimeZoneById. POSIX strings like PST8:00:00 still take the POSIX path.
  • Time#_dump keeps a non-ASCII zone name. On a localized Windows ("Mitteleuropäische Zeit"), Marshal.dump of a local Time raised EncoderFallbackException. The name is now encoded the way Time#zone already does it.

Linux and macOS take the same path as before: /etc/localtime, zoneinfo ids and POSIX rules all still go through TzFile.

Testing

Run on Windows 11, W. Europe Standard Time, Release build, net10.0.

Check Before After
Util/time-local-matrix.rb vs CRuby 4.0.1 on the same machine 102 of 299 lines differ 0 differ
spec/core/time (639 examples) 1 failure (Time#dst? in America/Los_Angeles) 0 failures
spec/core/marshal 6 errors (all "for a Time") 0
spec/core/file/stat atime/ctime/mtime/birthtime failing passing
  • spec/library/time, spec/library/date and spec/library/datetime have 0 failures.
  • All IronRuby.Tests C# tests pass.
  • With TZ=CET-1CEST,M3.5.0,M10.5.0/3, which goes through the same TzFile engine Linux uses, the matrix output also matches CRuby.

Not run locally: Linux (no WSL or Docker on the test machine) and the net8.0 target (only the .NET 10 runtime is installed). CI covers both.

Summary by CodeRabbit

  • Bug Fixes
    • Improved local-time handling when zoneinfo data is unavailable, including fallback to the system’s .NET time zone on Windows. Daylight-saving offsets and wall-clock times now use the active time-zone source.
    • Time-zone names containing non-ASCII characters are now saved using the configured path encoding; ASCII names retain their existing encoding behavior.
  • Chores
    • Added a diagnostic script comparing local-time results across dates, daylight-saving transitions, and wall-clock edge cases.

Largo added 3 commits October 13, 2026 10:12
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.
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.
_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.
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 928b8a37-939f-43c8-ae1c-d20027fea026
📥 Commits

Reviewing files that changed from the base of the PR and between f9817fe and df9458e.

📒 Files selected for processing: 2
  • Src/Libraries/Builtins/RubyTimeOps.cs
  • Src/Libraries/Builtins/RubyTimeSupport.cs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📝 Walkthrough

RubyTimeZone now supports .NET time zones alongside TZif data and fixed offsets. Time dumps use path encoding for non-ASCII zone names. A new script reports local-time results for selected dates and inputs.

Changes: Ruby time behavior

Layer / File(s) Summary
Dynamic zone lookup and local-time resolution
Src/Libraries/Builtins/RubyTimeSupport.cs, Util/time-local-matrix.rb
RubyTimeZone can use .NET zones for lookup, offsets, wall-clock resolution, and daylight-saving information. The diagnostic script probes local-time behavior across dates, daylight-saving transitions, and wall-clock inputs.
Time dump zone-name encoding
Src/Libraries/Builtins/RubyTimeOps.cs
RubyTimeOps.Dump uses path encoding for non-ASCII zone names. ASCII names retain ASCII encoding.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to df945

Windows local-time handling gains .NET time-zone support, and time dumps preserve localized zone names. The inspected edge cases establish no concrete issue requiring resolution before merge.

Security architecture risk: 🔵 Low · up to df945

The changes remain within time-zone lookup and time metadata; no new privileged capability was identified. Risk is low, with uncertainty around host-specific zone support and downstream reliance on the changed local-time behavior.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Code with existing authority to change TZ can influence the shared runtime zone and subsequent local-time results. That process-level configuration scope predates this PR; the new backend expands recognized zone IDs and time semantics rather than granting environment-write authority.

Trust Boundaries and Controls

  • observed — The added backend invokes system-zone lookup and offset, DST, and name queries. Existing TZif path restrictions remain unchanged. Marshal load can invoke a class-defined find_timezone callback, but that callback path already accepted serialized zone strings before this PR.

Resilience and Maintainability Implications

  • observed — In-place localtime conversion constructs a replacement before copying fields into the receiver, containing backend-resolution failures before mutation. CopyFrom remains a sequential field copy, not a synchronized atomic update. The separate reads of the shared zone during wall-clock conversion and metadata capture also predate this PR.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: adding Windows daylight-saving-time support for Time handling.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@Largo
Largo merged commit 3533146 into master Oct 13, 2026
7 checks passed
Largo added a commit to khasinski/ironruby that referenced this pull request Oct 13, 2026
CI builds libprism from prism's main branch, and since ruby/prism#4255
(attach_comments, merged 2026-10-12) every build job has failed in the
smoke test with "Unknown prism node type: 0 (TypeError)" - master, Largo#17,
Largo#18 and Largo#19 alike. That change altered prism's serialization in two
places without moving the serialization version off 1.9, so the version
check in PrismLoader could not catch it:

- the options blob has a new attach_comments byte after freeze. The blob
  IronRuby wrote was one byte short, so prism read the scope count from
  the wrong offset;
- the serialized header has a comment-attachment list after the comments
  (node id, count, comment indices). PrismLoader read its count as the
  magic comment count and walked off from there.

PrismParser.BuildOptionsData writes the byte (0: IronRuby never asks for
attachments) and PrismLoader.Load skips the list. PrismLex only reads the
token stream, which ends before the header, so it needs nothing.

The rest of the revision comes along so that every piece names the same
prism commit, as Src/Prism/README.md asks:

- Src/Prism/Generated is regenerated from cd76b0d1's config.yml:
  SymbolNode#value_loc became the non-optional content_loc (the bridge
  reads neither), and a WORDS_SEP_IMPLICIT token was added after
  WORDS_SEP. Ripper looks tokens up by name and skips the ones it has no
  event for, which is what prism's own lex_compat does with this one.
- Src/StdLib/ruby/4.0/prism is prism's lib/ at cd76b0d1, generated files
  included; prism.rb keeps IronRuby's backend branch.
  parse_result/comments.rb is gone upstream - attaching comments moved
  into libprism.
- Src/StdLib/ironruby/prism/ironruby.rb follows ffi.rb: attach_comments
  is a known option and is dumped after freeze, and the *_file APIs take
  a Pathname (to_path / to_str) the way PrismSource.with_file now does.
- release.yml's PRISM_REF and the README build instructions move to
  cd76b0d1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant