Skip to content

Round ISO8601 fractional seconds to the nearest millisecond#1997

Open
adityasingh2400 wants to merge 2 commits into
swiftlang:mainfrom
adityasingh2400:iso8601-fractional-seconds-rounding
Open

Round ISO8601 fractional seconds to the nearest millisecond#1997
adityasingh2400 wants to merge 2 commits into
swiftlang:mainfrom
adityasingh2400:iso8601-fractional-seconds-rounding

Conversation

@adityasingh2400
Copy link
Copy Markdown
Contributor

Date.ISO8601FormatStyle with includingFractionalSeconds reports the milliseconds field one millisecond too low for many values. For example:

let date = Date(timeIntervalSince1970: 1_674_036_251.123)
let style = Date.ISO8601FormatStyle.iso8601.year().month().day().time(includingFractionalSeconds: true)
style.format(date) // "2023-01-18T10:04:11.122", expected "...11.123"

The same off-by-one drops the last millisecond for a large fraction of inputs at present-day magnitudes, e.g. .001 formats as .000 and .011 formats as .010.

Root cause: the milliseconds field is derived from the nanosecond component by truncating toward zero.

let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero))

A Date built from a fractional TimeInterval at a current-date magnitude cannot represent the value exactly, and the nanosecond the calendar extracts lands just below the intended value. For the example above the nanosecond component is 122999906 rather than 123000000, so truncating gives 122. This only shows up at large magnitudes: the existing 665076946.011 test case happens to land on a nanosecond at or above 11000000, so truncation still produced .011 there, which is why it was not caught.

The fix rounds to the nearest millisecond instead of truncating, and clamps the result to 999 so a value that would round up to 1000 does not overflow the three-digit field. That clamp keeps the behavior already asserted by the existing rounding() test, where 15:35:45.9999 formats as 15:35:45.999.

Parsing is unaffected: it already maps .123 back to 123000000 ns exactly, so the formatted output now matches what the parser expects for the displayable resolution.

Verification: added a regression test that fails before this change and passes after. It checks the reported .123 case, asserts that every millisecond 0..<1000 from a present-day base round-trips through the formatted string, and checks the 1000 overflow clamp. The full ISO8601FormatStyle, Date.FormatStyle, and HTTP format style suites pass (the only failure in the wider FoundationEssentialsTests run is fileExtendedAttributes, which also fails on a clean checkout and is unrelated).

Fixes #963.

Happy to retarget this at an active release branch if that fits the merge-forward strategy better.

When formatting with includingFractionalSeconds, the milliseconds field
was computed from the nanosecond component by truncating toward zero:

    let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero))

A Date created from a fractional TimeInterval at a present-day magnitude
cannot represent the value exactly. For Date(timeIntervalSince1970:
1674036251.123) the nanosecond component the calendar extracts is
122999906 rather than 123000000, so truncation yields ".122" instead of
".123". The same off-by-one drops the last millisecond for many values,
for example ".001" becomes ".000" and ".011" becomes ".010".

Round to the nearest millisecond instead. The result is clamped to 999
so a value that rounds up to 1000 does not overflow the three-digit
field, matching the existing behavior already covered by the rounding()
test for ".9999".

Fixes swiftlang#963.
@adityasingh2400 adityasingh2400 requested a review from a team as a code owner May 25, 2026 03:03
@itingliu
Copy link
Copy Markdown
Contributor

Does this work for the scenario that @parkera mentioned in his comment in the issue?

The behavior was introduced in an attempt to avoid something like this:

Time: 12:59:59.9999
Format: 3 digits of ms, rounding nearest
Result: 12:59:59.000

Can we add a unit test to see what this change would do to this input?

Cover the scenario from issue swiftlang#963: a time at HH:MM:59 with a sub-millisecond
remainder that rounds up to 1000 ms must clamp to .999 and keep the second at
59, never reading .000 or carrying into the seconds field.
@adityasingh2400
Copy link
Copy Markdown
Contributor Author

Yes, it handles that case. The min(..., 999) clamp is exactly the guard for it: when the sub-millisecond remainder rounds up to 1000 ms, it is capped at .999 rather than wrapping the rounded value to .000 or carrying into the seconds field. So a time like 12:59:59.9999 formats as 12:59:59.999, never 12:59:59.000. I added a test at the :59 boundary in 7208ae6 (1674036299.0 + 0.9996 is 2023-01-18T10:04:59) asserting the output stays at .999. The existing per-millisecond loop and the round-up-to-1000 test cover the rest.

@itingliu itingliu requested a review from parkera May 28, 2026 00:14
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.

ISO8601FormatStyle produces unexpected results when including fractional seconds

2 participants