Compute Datetime.diff and Duration in Long so spans past 68 years hold - #29
Merged
Conversation
diff multiplied the ordinal difference by DAY in 32-bit Int, so any span over 2^31/86400 = 24855 days wrapped: 2100-01-01 minus 2000-01-01 (36525 days, 3155760000 seconds) came out as -1139207296, and the reversed pair came out positive. Duration's seconds- field was an Int, so between inherited that and the constructors overflowed on their own -- weeks 3551 gave -2147322496 instead of 2147644800, days 24856 gave -2147408896. Every derived operation followed: to-days of weeks 3551 was -24853, str of days 36524 rendered "-13186d 6h 28m 16s", pos? was false and neg? true for days 30000, and add of days 36524 to 2000-01-01 landed on 1963-11-24. diff now returns a Long and Duration holds a Long, with the widening at the multiplication as in 30f21fd; DAY/HOUR/MINUTE/SECOND stay Int, since only the day count can overflow. The constructors keep taking an Int count, so (Duration.days 2) still compiles, and widen at the multiply. scale likewise keeps its Int factor. add and sub no longer hand a whole Duration to Datetime.add-seconds, which takes an Int: they split the Long total into whole days and a remainder under a day, shift the date by the days through the ordinal, and pass only the remainder to add-seconds. Widening add-seconds instead would have broken its Int callers -- http/http.carp:177 is one -- and would still have had to narrow the day count for the Int ordinal. The ordinal sum is clamped rather than truncated, so a day count past the range an ordinal can name cannot wrap into a plausible date. Seventeen new assertions cover a century-long diff in both signs, weeks and days past their wrap points, between and to-days across a century, each comparison operator over the boundary, two deltas that collide when truncated to 32 bits, a sum of exactly 2^32 seconds, str for a positive and a negative multi-decade delta, and add/sub of a century-long delta including the time of day. All seventeen fail with the Int arithmetic restored under the Long signatures.
hellerve
approved these changes
Aug 31, 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.
The follow-up #27 flagged in its own body.
Datetime.diffends in(+ (* (- ord-a ord-b) DAY) (- time-a time-b))withDAYat 86400, so any spanover
2^31/86400= 24855 days wraps, andDuration'sseconds-field was anInt, sobetweeninherited the wrap and the constructors overflowed on theirown.
Measured on
masterbefore the change:Note 2000-01-01 to 2100-01-01 is 36525 days, not 36524 — 2000 is a leap year —
so the century is 3155760000 seconds.
What changed
Datetime.diffreturns aLongandDurationholds aLong. The widening isat the multiplication, as in #27:
DAY/HOUR/MINUTE/SECONDstayInt,since the time-of-day part is bounded by 86399 and only the day count can
overflow.
plus/minus/negate/scale/mul/to-*/=/</>/zero?/pos?/neg?/strmoved toLongarithmetic. Instronly the day componentcan exceed an
Int; hours, minutes and seconds are bounded by their units andstay
Int, so only the%dddirective changed.The constructors still take an
Intcount.(Duration.days 2)is what themodule's own docstring writes, and
2is anIntliteral, soseconds,minutes,hours,daysandweekskeep theirIntparameter and widen atthe multiply —
(Duration.days 30000)stops wrapping without anyone editingtheir code.
scale/mulkeep theirIntfactor for the same reason. NoLong-taking variants added; nothing suggests a caller needs a count that anIntcannot hold.add/subsplit the total rather than wideningadd-seconds. They nowdivide the
Longtotal into whole days and a remainder under a day, shift thedate by the days through the ordinal, and pass only the remainder — always
within ±86399 — to
Datetime.add-seconds. Wideningadd-secondstoLonginstead would have been both more invasive and no more correct:
Ints —http/http.carp:177is(Datetime.add-seconds &(Datetime.now) i), withianIntfrom aMax-Ageparse, so it would have needed aLong.from-intat the call site;Intdiv-/mod-helpers and the signed-overflowfix from Fix signed overflow in Datetime.add-seconds for large offsets #19, all of which would have had to be reworked;
Datetimenames its date by an
Intordinal. The narrowing does not go away, it justmoves somewhere hairier.
Nothing is silently truncated at that boundary: the ordinal sum is computed in
Longand clamped to theIntordinal range, so a day count past what anordinal can name saturates instead of wrapping into a plausible date. Dates
before
0001-01-01remain outsidefrom-ordinal's domain —0001-01-01minusa day is
0001-00-00onmastertoday — which this change does not address.I did not add a public
Datetime.add-days, which would have madeDuration.adda one-liner; the private helper keeps the change to the bug.
Blast radius
Grepping all 47 clones under
carpentry/forDatetime.diffandDuration.:there are no hits outside this repo's own source, tests and
gendocs.carp.Both are used exclusively within
time.Datetime.add-seconds, whichthis change deliberately leaves alone, has one external caller
(
http/http.carp:177) and it is unaffected.Tests
17 new assertions, all of which fail on the
Intarithmetic:diffin both signsweeks 3551anddays 24856, one step past each constructor's wrap pointbetweenandto-daysacross a century>,<,pos?,neg?for a delta past the boundary;=on two deltas thatcollide when truncated to 32 bits (
days 49710vsseconds -23296);zero?on a sum of exactly
2^32secondsstrfor a positive and a negative multi-decade deltaadd/subof a century-long delta, including one that keeps the time of dayExpected values over
2^31are assembled from parts ((* 36525l 86400l))rather than written as wide literals, following #27 — an oversized
Longliteral is folded on a 32-bit host, which is where I ran this.
The suite is 335 passing, 0 failing. Putting the
Intarithmetic back under theLongsignatures fails exactly those 17 and no others, so they pin the wideningrather than the types.
carp-fmt --checkandanglerare clean, anddocs/Datetime.htmlanddocs/Duration.htmlare regenerated — the only diff isthe five changed signatures.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.