From c477bdfa808b4076fcfc2f4e053f049b084632b3 Mon Sep 17 00:00:00 2001 From: Marcus Xavier Date: Tue, 7 Apr 2026 23:02:54 -0300 Subject: [PATCH 1/5] ext/date: tests for GH-21616 (modify() wrong at DST spring-forward) --- ext/date/tests/gh21616.phpt | 66 +++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 ext/date/tests/gh21616.phpt diff --git a/ext/date/tests/gh21616.phpt b/ext/date/tests/gh21616.phpt new file mode 100644 index 0000000000000..8383c5e711e41 --- /dev/null +++ b/ext/date/tests/gh21616.phpt @@ -0,0 +1,66 @@ +--TEST-- +Bug GH-21616 (DateTime::modify() does not respect DST transitions) +--FILE-- + 02:00 BST. + * Subtracting 1 second from 02:00:00 BST must land at 00:59:59 GMT, + * not at 02:59:59 BST. */ +$tz = new DateTimeZone('Europe/London'); + +echo "=== Spring forward ===\n"; + +echo "modify +1s then -1s:\n"; +$dt = new DateTime('2025-03-30 00:59:59', $tz); +$dt->modify('+1 second'); +$dt->modify('-1 second'); +echo $dt->format('Y-m-d H:i:s T U'), "\n"; + +echo "add/sub PT1S (reference):\n"; +$dt2 = new DateTime('2025-03-30 00:59:59', $tz); +$dt2->add(new DateInterval('PT1S')); +$dt2->sub(new DateInterval('PT1S')); +echo $dt2->format('Y-m-d H:i:s T U'), "\n"; + +echo "modify -1s from 02:00 BST:\n"; +$dt3 = new DateTime('2025-03-30 02:00:00', $tz); +echo $dt3->modify('-1 second')->format('Y-m-d H:i:s T U'), "\n"; + +echo "\n=== Combined relative (month + hours near DST) ===\n"; + +/* 2025-02-28 00:30:00 GMT + 1 month + 1 hour should land on + * 2025-03-28 01:30:00 GMT (not affected by DST on March 30). */ +$dt4 = new DateTime('2025-02-28 00:30:00', $tz); +echo "modify +1 month +1 hour:\n"; +echo $dt4->modify('+1 month +1 hour')->format('Y-m-d H:i:s T U'), "\n"; + +echo "\n=== first/last day of (must not regress) ===\n"; + +/* Ensure first_last_day_of still works correctly. */ +$dt5 = new DateTime('2025-03-15 10:00:00', $tz); +echo "modify first day of next month:\n"; +echo $dt5->modify('first day of next month') + ->format('Y-m-d H:i:s T'), "\n"; + +$dt6 = new DateTime('2025-03-15 10:00:00', $tz); +echo "modify last day of this month:\n"; +echo $dt6->modify('last day of this month') + ->format('Y-m-d H:i:s T'), "\n"; +?> +--EXPECT-- +=== Spring forward === +modify +1s then -1s: +2025-03-30 00:59:59 GMT 1743296399 +add/sub PT1S (reference): +2025-03-30 00:59:59 GMT 1743296399 +modify -1s from 02:00 BST: +2025-03-30 00:59:59 GMT 1743296399 + +=== Combined relative (month + hours near DST) === +modify +1 month +1 hour: +2025-03-28 01:30:00 GMT 1743125400 + +=== first/last day of (must not regress) === +modify first day of next month: +2025-04-01 10:00:00 BST +modify last day of this month: +2025-03-31 10:00:00 BST From 11318af3a3ab81a0cdc11902c4f56107c42e3b02 Mon Sep 17 00:00:00 2001 From: Marcus Xavier Date: Tue, 7 Apr 2026 23:02:57 -0300 Subject: [PATCH 2/5] ext/date: tests for GH-15880 (modify('+72 hours') wrong at fall-back) --- ext/date/tests/gh15880.phpt | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 ext/date/tests/gh15880.phpt diff --git a/ext/date/tests/gh15880.phpt b/ext/date/tests/gh15880.phpt new file mode 100644 index 0000000000000..a741f5a7d30a9 --- /dev/null +++ b/ext/date/tests/gh15880.phpt @@ -0,0 +1,55 @@ +--TEST-- +Bug GH-15880 (DateTime::modify('+72 hours') incorrect across DST boundary) +--FILE-- + 01:00 CST. + * +72 hours from 2024-11-01 00:00 CDT must be 2024-11-03 23:00 CST, + * not 2024-11-04 00:00 CST. */ +$tz = new DateTimeZone('America/Chicago'); + +echo "=== Fall back (explicit timezone) ===\n"; + +$start = new DateTimeImmutable('2024-11-01 00:00:00', $tz); + +echo "modify +72 hours:\n"; +echo $start->modify('+72 hours')->format('Y-m-d H:i:s T U'), "\n"; + +echo "add PT72H (reference):\n"; +echo $start->add(new DateInterval('PT72H')) + ->format('Y-m-d H:i:s T U'), "\n"; + +echo "\n=== Fall back with default timezone ===\n"; + +/* Exact reproduction from GH-15880: date_default_timezone_set, + * DateTimeImmutable, +72 hour via modify vs add. Also test that + * +3 days (calendar arithmetic) is unaffected — it should land on + * midnight Nov 4, not Nov 3 23:00. */ +date_default_timezone_set('America/Chicago'); +$start2 = new DateTimeImmutable('2024-11-01'); + +echo "modify +72 hour:\n"; +echo $start2->modify('+72 hour') + ->format('Y-m-d H:i:s T U'), "\n"; + +echo "add PT72H (reference):\n"; +echo $start2->add(new DateInterval('PT72H')) + ->format('Y-m-d H:i:s T U'), "\n"; + +echo "modify +3 days (calendar, not hours):\n"; +echo $start2->modify('+3 days') + ->format('Y-m-d H:i:s T U'), "\n"; +?> +--EXPECT-- +=== Fall back (explicit timezone) === +modify +72 hours: +2024-11-03 23:00:00 CST 1730696400 +add PT72H (reference): +2024-11-03 23:00:00 CST 1730696400 + +=== Fall back with default timezone === +modify +72 hour: +2024-11-03 23:00:00 CST 1730696400 +add PT72H (reference): +2024-11-03 23:00:00 CST 1730696400 +modify +3 days (calendar, not hours): +2024-11-04 00:00:00 CST 1730700000 From fbbdafbdfcc4c494a518e64c19b3d60aefad3090 Mon Sep 17 00:00:00 2001 From: Marcus Xavier Date: Tue, 7 Apr 2026 23:03:03 -0300 Subject: [PATCH 3/5] Fixed bug GH-21616: DateTime::modify() wrong across DST boundary do_adjust_relative() adds h/i/s to wall-clock fields before converting to a Unix timestamp. Near a spring-forward gap that produces a nonexistent time, which resolves to the wrong offset. Fix: zero out relative h/i/s/us before timelib_update_ts(), apply them to sse directly. Same technique as timelib_add_wall(). y/m/d, weekday relatives, and first_last_day_of still go through timelib_update_ts. Also fixes GH-15880. --- ext/date/php_date.c | 44 +++++++++++++++++++++++++++++++ ext/date/tests/date_modify-1.phpt | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/ext/date/php_date.c b/ext/date/php_date.c index acdd612d04c82..32fd4155dfc4a 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -3288,6 +3288,7 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ php_date_obj *dateobj; timelib_time *tmp_time; timelib_error_container *err = NULL; + timelib_sll rel_h, rel_i, rel_s, rel_us; dateobj = Z_PHPDATE_P(object); @@ -3356,8 +3357,51 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ timelib_time_dtor(tmp_time); + /* Strip relative h/i/s/us before timelib_update_ts() so that + * do_adjust_relative() does not add them to wall-clock fields. + * Wall-clock addition breaks at DST boundaries; SSE arithmetic + * (applied below) is always correct. */ + rel_h = dateobj->time->relative.h; + rel_i = dateobj->time->relative.i; + rel_s = dateobj->time->relative.s; + rel_us = dateobj->time->relative.us; + dateobj->time->relative.h = 0; + dateobj->time->relative.i = 0; + dateobj->time->relative.s = 0; + dateobj->time->relative.us = 0; + timelib_update_ts(dateobj->time, NULL); timelib_update_from_sse(dateobj->time); + + /* Fold microsecond overflow into seconds so that the seconds + * component goes through SSE arithmetic, not wall-clock. + * Matches timelib_add_wall()'s do_range_limit() call + * (interval.c:317). */ + if (rel_us >= 1000000 || rel_us <= -1000000) { + rel_s += rel_us / 1000000; + rel_us = rel_us % 1000000; + } + + /* Apply h/i/s via SSE (Unix timestamp) arithmetic, matching + * the approach in timelib_add_wall() (interval.c:312). */ + if (rel_h || rel_i || rel_s) { + dateobj->time->sse += + timelib_hms_to_seconds(rel_h, rel_i, rel_s); + timelib_update_from_sse(dateobj->time); + } + + /* Apply remaining sub-second microseconds. After the above + * normalization rel_us is in (-1000000, 1000000), so the + * cascade into seconds is at most +/-1s -- safe to go through + * wall-clock normalize + update_ts (have_relative is already + * cleared by the first timelib_update_ts call). */ + if (rel_us) { + dateobj->time->us += rel_us; + timelib_do_normalize(dateobj->time); + timelib_update_ts(dateobj->time, NULL); + timelib_update_from_sse(dateobj->time); + } + dateobj->time->have_relative = 0; memset(&dateobj->time->relative, 0, sizeof(dateobj->time->relative)); diff --git a/ext/date/tests/date_modify-1.phpt b/ext/date/tests/date_modify-1.phpt index 665c899b23fbe..a002dd266ef27 100644 --- a/ext/date/tests/date_modify-1.phpt +++ b/ext/date/tests/date_modify-1.phpt @@ -25,4 +25,4 @@ Sun, 22 Aug 1993 00:00:00 +12 Sun, 27 Mar 2005 01:59:59 CET Sun, 27 Mar 2005 03:00:00 CEST Sun, 30 Oct 2005 01:59:59 CEST -Sun, 30 Oct 2005 03:00:00 CET +Sun, 30 Oct 2005 02:00:00 CET From 03ff33332e6fb0e30f117035e2eec92a6f9bf454 Mon Sep 17 00:00:00 2001 From: Marcus Xavier Date: Wed, 8 Apr 2026 00:03:50 -0300 Subject: [PATCH 4/5] ext/date: drop conditional branches in modify() microsecond handling Always fold microseconds into seconds before SSE arithmetic instead of guarding with if-checks. Handle negative remainders correctly. --- ext/date/php_date.c | 49 ++++++++++++++++----------------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/ext/date/php_date.c b/ext/date/php_date.c index 32fd4155dfc4a..c0ae4301ace6c 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -3288,7 +3288,7 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ php_date_obj *dateobj; timelib_time *tmp_time; timelib_error_container *err = NULL; - timelib_sll rel_h, rel_i, rel_s, rel_us; + timelib_sll rel_h, rel_i, rel_s, rel_us; dateobj = Z_PHPDATE_P(object); @@ -3357,10 +3357,8 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ timelib_time_dtor(tmp_time); - /* Strip relative h/i/s/us before timelib_update_ts() so that - * do_adjust_relative() does not add them to wall-clock fields. - * Wall-clock addition breaks at DST boundaries; SSE arithmetic - * (applied below) is always correct. */ + /* do_adjust_relative() applies h/i/s as wall-clock, which breaks across + * DST. Strip them before timelib_update_ts and re-apply via SSE below. */ rel_h = dateobj->time->relative.h; rel_i = dateobj->time->relative.i; rel_s = dateobj->time->relative.s; @@ -3373,34 +3371,21 @@ static bool php_date_modify(zval *object, char *modify, size_t modify_len) /* {{ timelib_update_ts(dateobj->time, NULL); timelib_update_from_sse(dateobj->time); - /* Fold microsecond overflow into seconds so that the seconds - * component goes through SSE arithmetic, not wall-clock. - * Matches timelib_add_wall()'s do_range_limit() call - * (interval.c:317). */ - if (rel_us >= 1000000 || rel_us <= -1000000) { - rel_s += rel_us / 1000000; - rel_us = rel_us % 1000000; - } - - /* Apply h/i/s via SSE (Unix timestamp) arithmetic, matching - * the approach in timelib_add_wall() (interval.c:312). */ - if (rel_h || rel_i || rel_s) { - dateobj->time->sse += - timelib_hms_to_seconds(rel_h, rel_i, rel_s); - timelib_update_from_sse(dateobj->time); - } - - /* Apply remaining sub-second microseconds. After the above - * normalization rel_us is in (-1000000, 1000000), so the - * cascade into seconds is at most +/-1s -- safe to go through - * wall-clock normalize + update_ts (have_relative is already - * cleared by the first timelib_update_ts call). */ - if (rel_us) { - dateobj->time->us += rel_us; - timelib_do_normalize(dateobj->time); - timelib_update_ts(dateobj->time, NULL); - timelib_update_from_sse(dateobj->time); + /* Normalize microseconds: fold full seconds into rel_s, keep rel_us >= 0 */ + rel_s += rel_us / 1000000; + rel_us = rel_us % 1000000; + if (rel_us < 0) { + rel_s--; + rel_us += 1000000; + } + + dateobj->time->sse += timelib_hms_to_seconds(rel_h, rel_i, rel_s); + dateobj->time->us += rel_us; + if (dateobj->time->us >= 1000000) { + dateobj->time->us -= 1000000; + dateobj->time->sse++; } + timelib_update_from_sse(dateobj->time); dateobj->time->have_relative = 0; memset(&dateobj->time->relative, 0, sizeof(dateobj->time->relative)); From 50d8d866607a91e03b7947d23512f87d86264f9d Mon Sep 17 00:00:00 2001 From: Marcus Xavier Date: Wed, 8 Apr 2026 00:04:51 -0300 Subject: [PATCH 5/5] ext/date: trim and expand DST tests for GH-21616 and GH-15880 Remove section headers and redundant cases. Add -72h backward through fall-back, +61 minutes across spring-forward gap, and DateTimeImmutable. --- ext/date/tests/gh15880.phpt | 51 +++++++---------------------- ext/date/tests/gh21616.phpt | 64 ++++++++++++------------------------- 2 files changed, 32 insertions(+), 83 deletions(-) diff --git a/ext/date/tests/gh15880.phpt b/ext/date/tests/gh15880.phpt index a741f5a7d30a9..bbf508311ee82 100644 --- a/ext/date/tests/gh15880.phpt +++ b/ext/date/tests/gh15880.phpt @@ -3,53 +3,26 @@ Bug GH-15880 (DateTime::modify('+72 hours') incorrect across DST boundary) --FILE-- 01:00 CST. - * +72 hours from 2024-11-01 00:00 CDT must be 2024-11-03 23:00 CST, - * not 2024-11-04 00:00 CST. */ + * +72 hours from midnight Nov 1 must land at Nov 3 23:00 CST, not Nov 4 00:00. */ +date_default_timezone_set('America/Chicago'); $tz = new DateTimeZone('America/Chicago'); - -echo "=== Fall back (explicit timezone) ===\n"; - $start = new DateTimeImmutable('2024-11-01 00:00:00', $tz); -echo "modify +72 hours:\n"; +/* modify and add must agree */ echo $start->modify('+72 hours')->format('Y-m-d H:i:s T U'), "\n"; +echo $start->add(new DateInterval('PT72H'))->format('Y-m-d H:i:s T U'), "\n"; -echo "add PT72H (reference):\n"; -echo $start->add(new DateInterval('PT72H')) - ->format('Y-m-d H:i:s T U'), "\n"; - -echo "\n=== Fall back with default timezone ===\n"; - -/* Exact reproduction from GH-15880: date_default_timezone_set, - * DateTimeImmutable, +72 hour via modify vs add. Also test that - * +3 days (calendar arithmetic) is unaffected — it should land on - * midnight Nov 4, not Nov 3 23:00. */ -date_default_timezone_set('America/Chicago'); -$start2 = new DateTimeImmutable('2024-11-01'); +/* +3 days is calendar arithmetic -- it should land on midnight Nov 4 */ +echo $start->modify('+3 days')->format('Y-m-d H:i:s T U'), "\n"; -echo "modify +72 hour:\n"; -echo $start2->modify('+72 hour') - ->format('Y-m-d H:i:s T U'), "\n"; - -echo "add PT72H (reference):\n"; -echo $start2->add(new DateInterval('PT72H')) - ->format('Y-m-d H:i:s T U'), "\n"; - -echo "modify +3 days (calendar, not hours):\n"; -echo $start2->modify('+3 days') - ->format('Y-m-d H:i:s T U'), "\n"; +/* -72 hours backward through fall-back: 73 real hours separate Nov 1 00:00 CDT + * from Nov 4 00:00 CST (the extra hour is the repeated hour), so -72h lands 1h + * ahead of Nov 1 midnight */ +$end = new DateTimeImmutable('2024-11-04 00:00:00', $tz); +echo $end->modify('-72 hours')->format('Y-m-d H:i:s T U'), "\n"; ?> --EXPECT-- -=== Fall back (explicit timezone) === -modify +72 hours: -2024-11-03 23:00:00 CST 1730696400 -add PT72H (reference): -2024-11-03 23:00:00 CST 1730696400 - -=== Fall back with default timezone === -modify +72 hour: 2024-11-03 23:00:00 CST 1730696400 -add PT72H (reference): 2024-11-03 23:00:00 CST 1730696400 -modify +3 days (calendar, not hours): 2024-11-04 00:00:00 CST 1730700000 +2024-11-01 01:00:00 CDT 1730440800 diff --git a/ext/date/tests/gh21616.phpt b/ext/date/tests/gh21616.phpt index 8383c5e711e41..08befe27b09ab 100644 --- a/ext/date/tests/gh21616.phpt +++ b/ext/date/tests/gh21616.phpt @@ -2,65 +2,41 @@ Bug GH-21616 (DateTime::modify() does not respect DST transitions) --FILE-- 02:00 BST. - * Subtracting 1 second from 02:00:00 BST must land at 00:59:59 GMT, - * not at 02:59:59 BST. */ +/* Spring forward: Europe/London, 2025-03-30 01:00 GMT -> 02:00 BST */ $tz = new DateTimeZone('Europe/London'); -echo "=== Spring forward ===\n"; - -echo "modify +1s then -1s:\n"; +/* +1s then -1s must round-trip */ $dt = new DateTime('2025-03-30 00:59:59', $tz); $dt->modify('+1 second'); $dt->modify('-1 second'); echo $dt->format('Y-m-d H:i:s T U'), "\n"; -echo "add/sub PT1S (reference):\n"; -$dt2 = new DateTime('2025-03-30 00:59:59', $tz); -$dt2->add(new DateInterval('PT1S')); -$dt2->sub(new DateInterval('PT1S')); -echo $dt2->format('Y-m-d H:i:s T U'), "\n"; - -echo "modify -1s from 02:00 BST:\n"; -$dt3 = new DateTime('2025-03-30 02:00:00', $tz); -echo $dt3->modify('-1 second')->format('Y-m-d H:i:s T U'), "\n"; +/* -1s from 02:00 BST must land at 00:59:59 GMT, not 02:59:59 BST */ +$dt2 = new DateTime('2025-03-30 02:00:00', $tz); +echo $dt2->modify('-1 second')->format('Y-m-d H:i:s T U'), "\n"; -echo "\n=== Combined relative (month + hours near DST) ===\n"; +/* month + hours: +1 month lands before the DST boundary, so +1 hour is plain GMT */ +$dt3 = new DateTime('2025-02-28 00:30:00', $tz); +echo $dt3->modify('+1 month +1 hour')->format('Y-m-d H:i:s T U'), "\n"; -/* 2025-02-28 00:30:00 GMT + 1 month + 1 hour should land on - * 2025-03-28 01:30:00 GMT (not affected by DST on March 30). */ -$dt4 = new DateTime('2025-02-28 00:30:00', $tz); -echo "modify +1 month +1 hour:\n"; -echo $dt4->modify('+1 month +1 hour')->format('Y-m-d H:i:s T U'), "\n"; +/* first/last day of must still work */ +$base = new DateTimeImmutable('2025-03-15 10:00:00', $tz); +echo $base->modify('first day of next month')->format('Y-m-d H:i:s T'), "\n"; +echo $base->modify('last day of this month')->format('Y-m-d H:i:s T'), "\n"; -echo "\n=== first/last day of (must not regress) ===\n"; +/* +61 minutes from just before the gap -- minutes must also count as elapsed time */ +$dt4 = new DateTime('2025-03-30 00:59:00', $tz); +echo $dt4->modify('+61 minutes')->format('Y-m-d H:i:s T U'), "\n"; -/* Ensure first_last_day_of still works correctly. */ -$dt5 = new DateTime('2025-03-15 10:00:00', $tz); -echo "modify first day of next month:\n"; -echo $dt5->modify('first day of next month') - ->format('Y-m-d H:i:s T'), "\n"; - -$dt6 = new DateTime('2025-03-15 10:00:00', $tz); -echo "modify last day of this month:\n"; -echo $dt6->modify('last day of this month') - ->format('Y-m-d H:i:s T'), "\n"; +/* DateTimeImmutable must behave the same as mutable DateTime */ +$dt5 = new DateTimeImmutable('2025-03-30 02:00:00', $tz); +echo $dt5->modify('-1 second')->format('Y-m-d H:i:s T U'), "\n"; ?> --EXPECT-- -=== Spring forward === -modify +1s then -1s: -2025-03-30 00:59:59 GMT 1743296399 -add/sub PT1S (reference): 2025-03-30 00:59:59 GMT 1743296399 -modify -1s from 02:00 BST: 2025-03-30 00:59:59 GMT 1743296399 - -=== Combined relative (month + hours near DST) === -modify +1 month +1 hour: 2025-03-28 01:30:00 GMT 1743125400 - -=== first/last day of (must not regress) === -modify first day of next month: 2025-04-01 10:00:00 BST -modify last day of this month: 2025-03-31 10:00:00 BST +2025-03-30 03:00:00 BST 1743300000 +2025-03-30 00:59:59 GMT 1743296399