Commit 65a69b4a09f for php.net

commit 65a69b4a09ff454e0ed2c597d23f4c9dc52d1507
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Fri Jul 10 15:33:45 2026 -0400

    Fix GH-22671: assert.bail must not unwind with a pending exception (#22679)

    zend_throw_unwind_exit() requires !EG(exception), but assert.bail called it
    right after zend_exception_error(), which can itself re-throw while printing
    the callback's exception (a throwing __toString, a throwing __destruct when
    the exception is released, or the call-stack guard re-tripping at the stack
    limit). Only throw the unwind exit when no exception is pending; otherwise let
    the re-thrown exception propagate, matching exit() and phar.

    Fixes GH-22671

diff --git a/NEWS b/NEWS
index ac709587f69..cd259f06660 100644
--- a/NEWS
+++ b/NEWS
@@ -80,6 +80,8 @@ PHP                                                                        NEWS
 - Standard:
   . Fixed sleep() and usleep() to reject values that overflow the underlying
     unsigned int timeout. (Weilin Du)
+  . Fixed bug GH-22671 (assert.bail aborts the process when the assert callback
+    throws an exception whose reporting re-throws). (iliaal)

 - Streams:
   . Fixed bug GH-21468 (Segfault in file_get_contents w/ a https URL
diff --git a/ext/standard/assert.c b/ext/standard/assert.c
index a976aeda149..618f69a2cf7 100644
--- a/ext/standard/assert.c
+++ b/ext/standard/assert.c
@@ -245,7 +245,9 @@ PHP_FUNCTION(assert)
 			 * exception so we can avoid bailout and use unwind_exit. */
 			zend_exception_error(EG(exception), E_WARNING);
 		}
-		zend_throw_unwind_exit();
+		if (!EG(exception)) {
+			zend_throw_unwind_exit();
+		}
 		RETURN_THROWS();
 	} else {
 		RETURN_FALSE;
diff --git a/ext/standard/tests/assert/gh22671.phpt b/ext/standard/tests/assert/gh22671.phpt
new file mode 100644
index 00000000000..3487b0687cc
--- /dev/null
+++ b/ext/standard/tests/assert/gh22671.phpt
@@ -0,0 +1,40 @@
+--TEST--
+GH-22671 (assert.bail must not call zend_throw_unwind_exit with a pending exception)
+--INI--
+zend.assertions=1
+assert.bail=1
+assert.exception=0
+assert.callback=cb
+error_reporting=0
+--FILE--
+<?php
+register_shutdown_function(function () {
+    echo "shutdown reached\n";
+});
+
+class Inner extends Exception {
+    public function __destruct() {
+        throw new Exception("thrown from __destruct");
+    }
+}
+
+class Boom extends Exception {
+    public function __toString(): string {
+        throw new Inner("inner");
+    }
+}
+
+function cb() {
+    throw new Boom("boom");
+}
+
+// The callback throws Boom; the bail path prints it, which re-throws from
+// Boom::__toString(), and releasing that exception re-throws again from
+// Inner::__destruct(). The bail path must not reach zend_throw_unwind_exit()
+// while an exception is pending, otherwise the process aborts.
+assert(false);
+
+echo "not reached\n";
+?>
+--EXPECT--
+shutdown reached