Repository navigation
Carry nanoseconds from the wall clock into Time.now (#689) - #702
Merged
Merged
Conversation
Time.now returned a whole second, so the universal Ruby elapsed-time idiom
`t = Time.now; work; Time.now - t` measured 0.0 no matter how long the work
took. A measuring instrument that answers a plausible zero is worse than one
that crashes, because zero reads as good news.
The truncation was in the *type* of the clock seam, not in Time.now's body:
var nowUnix = func() int64 { return stdtime.Now().Unix() }
vm.clock = timecop.NewWith(func() stdtime.Time {
return stdtime.Unix(nowUnix(), 0).UTC()
})
A seconds-typed seam is whole-second for every reader, whatever the readers do,
and the clock has four of them: Time.now, Time.new (no args), Date.today and
DateTime.now. The seam now hands back a whole instant (nowWall), which is what
MRI's time_s_now -> rb_time_timespec_now -> clock_gettime(CLOCK_REALTIME) keeps
in tv_nsec. The Time representation already carried nanoseconds exactly, so
nothing downstream needed changing.
Two further sub-second losses on the same path, found by auditing it:
* Timecop.freeze/travel resolved a Time argument through
time.Unix(v.Unix(), 0), so freezing at an instant carrying a sub-second
silently moved the clock back to the start of that second;
* Time#- (Time minus Time) went through time.Time.Sub, whose time.Duration is
nanosecond-granular, dropping the sub-nanosecond remainder a Time keeps in
frac: (t + 0.000001) - t read 9.99e-07 where MRI reads 1.0e-06. It is now
the exact rational difference, converted to Float once.
Time is the one subject where a test must not assert a literal, so the new tests
assert properties (range, internal agreement, ordering, a positive elapsed
interval) plus a pinned-seam leg whose expectations were each read off MRI
4.0.5. The live leg samples until it sees a non-zero sub-second rather than
trusting one sample, which can legitimately be zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #689.
The code path, traced rather than inferred
Time.nowis registered ininternal/vm/time.go(registerTime,sm("now", …))and goes:
The truncation was in the type of the seam, not in
Time.now's body.nowUnixwas declared
func() int64, and the wiring then had to invent a nanosecond —time.Unix(nowUnix(), 0). So the whole-second answer was a property of the clock,not of its callers, and the clock has four callers:
Time.now,Time.newwith noarguments,
Date.todayandDateTime.now(the last two throughwithDeterministicClock, which already passedt.Nanosecond()on to the Datelibrary — it was receiving a zero).
That matters for how the issue reads the defect. The issue's localisation
("only
Time.now's own construction drops the fraction") is right about where tolook and understates the blast radius by one step:
DateTime.now.sec_fractionwas0 too, and
Timecop.freeze(t)had a truncation of its own (below). Nothing waswrong in
Time's representation, exactly as the issue says —Time.atproving theplumbing is what made this small.
timecop'sClockdoes not truncate:Current()returns the seam's instant, andits freeze/travel/scale frames do
time.Timearithmetic. So one seam change fixesall four constructors.
What changed
internal/vm/time.go— the seam is nownowWall func() time.Time, returning awhole instant, which is what MRI's
time_s_now→rb_time_timespec_now→clock_gettime(CLOCK_REALTIME)keeps intv_nsec.internal/vm/vm.go— the clock is wired tonowWall().UTC(); no nanosecond isinvented any more.
internal/vm/timecop.go—timecopInstantresolved aTimeargument throughtime.Unix(v.Unix(), 0), soTimecop.freeze(Time.at(x.25))silently moved theclock back to the start of that second. It now takes the whole instant.
internal/vm/time.go—Time#-between two Times went throughtime.Time.Sub, whosetime.Durationis nanosecond-granular, dropping thesub-nanosecond remainder a
Timekeeps infrac:(t + 0.000001) - tread9.99e-07where MRI 4.0.5 reads1.0e-06. It is now the exact rationaldifference converted to
Floatonce, sharing oneexactSeconds()helper withshiftByRatso the two cannot drift apart.No change was needed anywhere in
Time's accessors, rendering or arithmetic, andbuiltins.gois untouched.Measured against MRI 4.0.5
A 70-property probe (
Time.nowfields,Time.new, thein:keyword,Process.clock_gettimeper clock id,Time.atper argument type,Time#+/#-,round/floor/ceil,File::Stattimes,sleepgranularity,Date/DateTime)run under MRI 4.0.5 and under rbgo before and after. Every row that moved, moved to
MRI's answer:
Time.now.usecnon-zero, 20 samplesTime.now.nsecnon-zero, 20 samplesTime.nowdeltas > 0, 200 pairsTime.now.subsec.classRationalIntegerRationalTime.new.usecnon-zero, 20 samplesTime.now(in: "+02:00").usecnon-zeroTime.now.strftime("%6N") != "000000"sleep 0.05thenTime.now - t0 > 0.04DateTime.now.sec_fractionnon-zero(t + 0.000001) - t1.0e-069.99e-071.0e-06Time.now.subsec.classis worth a note:Integerwas not a second defect —MRI returns
Integer0for a whole-second instant too. It was the zero showingthrough.
The tests, and why they cannot flake
internal/vm/time_now_clock_internal_test.go. Time is the one subject where a testmust not assert a literal, so no instant is compared to a constant. Two legs:
nowWallis replaced by a fixed nanosecond-bearing instant and thevalue Ruby reads back is asserted exactly (
nsec,usec,subsec,%N,%6N,Time.new, bothin:paths,DateTime.now.sec_fraction). Deterministic. Itproves the wrapper carries nanoseconds — and only that: a production seam that
truncated would still pass, because pinning replaces it. That is why there is a
second leg.
consecutive reads,
0 <= nsec < 1e9,0 <= usec < 1e6,nsec / 1000 == usec,0 <= subsec < 1,to_i <= to_f < to_i + 1, and no read earlier than the onebefore it. Then liveness: a single
Time.nowmay legitimately land on a wholesecond, so no single sample is asserted — it samples until it sees a non-zero
sub-second or a 3-second budget expires. On any clock with better than
one-second resolution that happens on the first or second sample; the budget is
spent only when the property is false, which is the defect. A fixed-N "at least
one of N" would have been a probabilistic assertion; sampling-to-first-witness is
not.
Elapsed time is asserted the same way:
sleep 0.05between twoTime.nowreads,then
0.0 < d < 30.0— a lower bound the runtime's own timer guarantees and anupper bound no loaded machine reaches, never a duration literal.
A/B against the old code, three mutations that each compile:
nowWall = func() time.Time { return time.Unix(time.Now().Unix(), 0) }(the original behaviour)TestTimeNowLiveSubSecondProperties,TestTimeNowMeasuresElapsedTimetimecopInstantback totime.Unix(v.Unix(), 0).UTC()TestTimecopFreezeKeepsSubSecondTime#-back toa.t.Sub(bt.t).Seconds()TestTimeMinusTimeIsExactAs designed, the pinned leg catches none of mutation 1 — recorded here rather than
presented as coverage.
Every expected value in the pinned and arithmetic tests was read off MRI 4.0.5, not
computed by hand. One expectation I first wrote from my own arithmetic
(
Time.at(1600000000.25).to_f == 1600000000.25) is false in MRI too, whichrenders that instant's
to_fas1600000000.2499998; the test now asserts#to_r(
(6400000001/4)), which is exact and has no rendering subtleties.Wider clock audit
Each row match/divergence against MRI 4.0.5 on this host.
Match after this change:
Time.now/Time.new/Time.now(in:)sub-second;Time.atwith Integer, Float, Rational,(sec, usec)and(sec, ns, :nanosecond);Time.at(aTime);Time#+/#-including exactRationalshifts;Time#round/#floor/#ceilat digits 0 and 3;subsec,to_r,%N,%6N;Process.clock_gettime(CLOCK_REALTIME | CLOCK_MONOTONIC)in:float_secondand:nanosecond(sub-second, ordered, and REALTIME agrees withTime.nowto < 1 s);Kernel#sleepgranularity (0.05 s measured through both the monotonic clock and,now, the wall clock) and its
Integerreturn;File.mtime == File.stat(…).mtime;Date.today;DateTime.now.Divergences left open — outside this PR's cluster, not touched here:
File::Stat#mtime/#atime/#ctime/#birthtimetruncate to whole seconds.internal/vm/filestat.go:statTimestampsreduces every timestamp tofi.ModTime().Unix()(int64seconds) andstatTime(unix int64)rebuilds aTimefrom seconds alone, soFile.stat(f).mtime.nsecis always 0 where MRIreports the filesystem's nanoseconds. Same shape of defect as this one — a
seconds-typed carrier — in a different subsystem; the
Atimespec/Ctimespecreflection would have to carry
Nsectoo.Process::CLOCK_*constants are missing:CLOCK_PROCESS_CPUTIME_ID,CLOCK_THREAD_CPUTIME_ID,CLOCK_MONOTONIC_RAW,CLOCK_UPTIME_RAWall raiseNameError(internal/vm/process.godefines onlyCLOCK_REALTIMEandCLOCK_MONOTONIC).Process.clock_gettime(999)returns a REALTIME reading; MRI raisesErrno::EINVAL, "Invalid argument - clock_gettime(999)". Thedefault:branchin
clock_gettimetreats every unrecognised id as REALTIME — a wrong answerwhere MRI refuses, which is the same failure mode as this issue: a plausible
number instead of an error.
Process.clock_gettime(CLOCK_MONOTONIC)returns seconds since process start whereMRI's is machine uptime. MRI documents the monotonic origin as unspecified, so this
is not counted as a divergence.
bench/*.rbChecked, and the answer is the good one: nothing in
bench/was reading zero.bench/run.shtimes from the shell (/usr/bin/time -p, best of N runs), neverfrom Ruby, so the differential table was never affected.
bench/modules/benchmark.rbis the only bench that touches timing at all, and itdeliberately checksums the work rather than the clock ("no wall-clock value
used" in its own comment); its assertion is
tms.is_a?(Benchmark::Tms).Benchmark.measure { … }.realandBenchmark.realtime { … }were alreadypositive before this change:
internal/vm/benchmark.goreads the monotonicsource behind
Process.clock_gettime(CLOCK_MONOTONIC), notTime.now..rbfile in the repo outside the vendored spec corpus usesTime.nowtomeasure an interval.
So the instrument that was lying was the one users write by hand, and the issue's
own reproduction is the evidence for it; the repo's harness happened to be immune
because it measures from outside.
Gates
CGO_ENABLED=0 GOWORK=off go test -count=1 ./...— full suite green.CGO_ENABLED=0 go build ./...,GOOS=wasip1 GOARCH=wasm,GOOS=js GOARCH=wasm—all three build. On wasm the change is a resolution change, not a behaviour
change: no build tag decides it, both targets call the same
time.Now().wasip1 gets
clock_time_getnanoseconds; js/wasm getsDate.now()milliseconds, so there the sub-second is a multiple of 1e6 — non-zero, ordered,
and within every invariant the tests assert, which is why the live test asserts
ranges and a witness rather than nanosecond distinctness.
gofmt -l internal/ cmd/— empty.ruby/spec ratchet, corpus pinned
87b1631at/Users/Shared/rbgo-measure/rubyspec-corpus,TIMEOUT=25,/tmp/rbgo-rubyspecremoved before each run, both trees measured back to back on an idle machine:
origin/main(clean worktree)8f2a019b9c73ffc3+2, and both above the frozen floor 23113 (whichorigin/mainalready clearsby 66 — the floor is the reporter's to move, untouched here). The two examples
are attributable, not run-to-run noise: comparing per-file pass counts with the
two binaries,
core/time/new_spec.rbgoes 112 → 113 andcore/time/now_spec.rb16 → 17, and no other file in
core/timeorcore/processmoves.Sweep integrity, since rows-equals-inputs is not sufficient: the population is
language+core, 2206*_spec.rbfiles, 2206 distinct paths; eachrun reports 2198 loaded + 8 failed to load = 2206, the same 8 in both trees; and
the measurement is floored (a run that read files but scored nothing would fail
the ratchet, not pass it).
Coverage — measured with the toolchain the gate uses. The first profiles here
were taken with the host default Go 1.26.4, which counts fewer basic blocks than
CI's Go 1.27.1 and so can only give an upper bound (The coverage gate cannot be reproduced locally (Go 1.26 counts 404 blocks where CI's 1.27 counts 414) and has no rounding headroom left #704). Re-measured with
~/.pkgx/go.dev/v1.27.1/bin/go,-coverpkg=$(go list ./internal/...), oneprofile per tree, each
go tool cover -funcresolved in the tree that producedits profile.
The criterion applied is "the below-100 list is unchanged", not a 100.0%
reading, since the total only rounds to one decimal.
(run 36271161184, Go 1.27.1, 19 entries) is byte-identical to the
ubuntu list for
main's content — taken from the ubuntu lane of Raise the ruby/spec floor 22475 -> 23113 after waves 33-36 #696(run 36261499627), whose head
8ebbb1d6differs from8f2a019bonly in theFLOORfile and so has the same Go code. Both lanes printcoverage: 100.0%.16, identical, for
main's content, both atcoverage: 100.0%.again identical between
origin/main(8f2a019b, clean separate worktree)and this branch (
9c73ffc3). The local macOS list agrees with CI's macOS listentry for entry, which is the check that the local tool is counting like the
gate. The 16/19 difference between the lanes is the lane, not the change —
dir.go cwdStillNamed,filestat.go Inspectandfilestat.go registerFileStatappear only on the linux lane.time.go,timecop.goorvm.go; every one is present on unmodifiedmain, and two sit at0.0%(
bigdecimal_bind.go:Inspect,date_bind.go:Inspect).internal/vm/time.go exactSecondsat 100.0%, and zero removals — nothingorphaned, 7325 → 7326 functions.
timeNow,timeNew,timeArith,shiftByRat,subsecRat,exactSeconds,nowInstant,timeFromInstant,timecopInstant,NewWithStderr. Go 1.27.1exposed no new uncovered block in this change, so there was no
reachable-or-dead question to answer here.
Pre-existence of the below-100 entries was proved on a clean
origin/maincheckout in a separate worktree (
~/rbgo-wt43/main-clean, detached at8f2a019b), not by stashing.🤖 Generated with Claude Code