Skip to content

Fix CarbonInterval::spec() when the fraction holds a second or more - #3366

Merged
kylekatarnls merged 1 commit into
briannesbitt:masterfrom
t1gor:fix/interval-spec-microseconds-overflow
Sep 23, 2026
Merged

kylekatarnls merged 1 commit into
briannesbitt:masterfrom
t1gor:fix/interval-spec-microseconds-overflow

Conversation

@t1gor

@t1gor t1gor commented Sep 23, 2026

Copy link
Copy Markdown

Fixes #3365.

The bug

getDateIntervalSpec() built the seconds component by concatenation:

\sprintf('%d.%06d', $seconds, abs($interval->f) * 1000000)

%06d pads but never carries, so an interval whose DateInterval::$f holds one second or more is rendered as a fraction of its own magnitude. CarbonInterval::milliseconds() and ::microseconds() store the whole value in $f, so:

$interval = CarbonInterval::milliseconds(10500);

$interval->totalSeconds;                                    // 10.5
$interval->spec(true);                                      // PT0.10500000S
CarbonInterval::make($interval->spec(true))->totalSeconds;   // 0.105

The output is well-formed ISO 8601, so every conforming parser accepts it and silently reads back the wrong number — I cross-checked against two independent xs:duration implementations (.NET's XmlConvert, and luxon), and both return 0.105. totalSeconds and spec(true) therefore describe different durations on the same object.

The fraction width is unstable for the same reason — 6, 7 or 8 digits depending on the magnitude of $f.

The fix

The $withNegatives branch of the same ternary already computes this correctly by addition rather than concatenation, which is why spec(true, true) returns PT10.500000S today where spec(true) does not. This applies that form on both paths.

Verification

  • Output is byte-identical for every in-range pair — compared old and new across all 60,000 combinations of s 0–59 × f 0.000–0.999, zero differences.
  • Full suite passes: 6217 tests, 194,219 assertions. The 2 errors are testSetLocaleToAuto needing fr_FR.UTF-8, which are identical on unmodified master in the same container.
  • tests/CarbonInterval/ alone: 443 tests, 1219 assertions, green.
  • The new test is a genuine regression test — it fails on master (5 of 8 assertions) and passes with the fix.
  • php-cs-fixer reports no issues in either changed file (the two files it does flag, Traits/Date.php and Traits/Units.php, are flagged identically on unmodified master).
  • Existing microsecond expectations are untouched: PT0.012300S and PT-15.321654S both still pass.

One behaviour change worth flagging

Beyond 6 decimal places, sprintf's integer cast truncates where number_format rounds — f = 0.1234565 gives 0.123456 before and 0.123457 after. That is below DateInterval's own resolution, but it is a real difference and I would rather name it than leave it to be discovered.

Alternative worth considering

This fixes the formatter defensively. The arguably deeper issue is that milliseconds() / microseconds() leave $f outside the 0 <= f < 1 range the property represents — ->cascade() normalises it and spec(true) is then correct either way. Happy to fix it in the factories instead if you prefer that; this change is the smaller one and does not alter any already-normalised interval.

getDateIntervalSpec() built the seconds component by concatenation:

    \sprintf('%d.%06d', $seconds, abs($interval->f) * 1000000)

%06d pads but never carries, so an interval whose DateInterval::$f holds
one second or more was rendered as a fraction of its own magnitude.
CarbonInterval::milliseconds() and ::microseconds() store the whole value
in $f, so:

    CarbonInterval::milliseconds(10500)->spec(true);   // PT0.10500000S

The result is well-formed ISO 8601, so every conforming parser accepts it
and silently reads back 0.105 rather than 10.5, and totalSeconds disagrees
with spec() on the same object.

Use the addition already applied by the $withNegatives branch of the same
expression on both paths. Output is byte-identical for intervals whose
fraction is already below one second.

Fixes briannesbitt#3365
@kylekatarnls kylekatarnls added this to the 3.14.1 milestone Sep 23, 2026
@kylekatarnls
kylekatarnls merged commit 7231cf8 into briannesbitt:master Sep 23, 2026
24 checks passed
@kylekatarnls

Copy link
Copy Markdown
Collaborator

Thanks 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CarbonInterval::spec(true) returns a wrong value for intervals built with milliseconds()/microseconds()

3 participants