From ecc352df5dda56f9270568c39e6736f9a4a51909 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:40:01 +0000 Subject: [PATCH 1/2] test: reach the EH_THROW window without a database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 016 held the window open with a PDO connect to an unroutable address and gave the victim 50 ms to run inside it. If the network answered quickly the window was already closed by then and the test passed without exercising anything — green either way, which is the one thing a regression test must not be. A userland stream wrapper under DirectoryIterator reaches the same window and parks inside it on a timer we control, so both halves are covered with no pdo_mysql, no routing assumptions and no three-second timeout. 017 covers the resume half and never landed with #214. Co-Authored-By: Claude Opus 5 (1M context) --- .../016-error_handling_window_not_shared.phpt | 71 ++++++++++++------ ...ndling_window_not_inherited_on_resume.phpt | 75 +++++++++++++++++++ 2 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 tests/edge_cases/017-error_handling_window_not_inherited_on_resume.phpt diff --git a/tests/edge_cases/016-error_handling_window_not_shared.phpt b/tests/edge_cases/016-error_handling_window_not_shared.phpt index 5af9482..38c6a30 100644 --- a/tests/edge_cases/016-error_handling_window_not_shared.phpt +++ b/tests/edge_cases/016-error_handling_window_not_shared.phpt @@ -1,7 +1,7 @@ --TEST-- -An EH_THROW window opened by another coroutine does not repaint our warnings +An EH_THROW window does not follow a coroutine spawned inside it --EXTENSIONS-- -pdo_mysql +zlib --SKIPIF-- 3]); - } catch (Throwable $e) { - return 'connect ended'; +/* DirectoryIterator::__construct turns warnings into UnexpectedValueException for + * the duration of the directory open (zend_replace_error_handling), and the open + * goes through a userland wrapper — so a coroutine can be spawned, and the holder + * can park, while that mode is in force. The spawned coroutine is a separate flow + * of control: its own warning must stay a warning, whatever the holder is doing. + * PDO::__construct holds the same kind of window across its connect, which is how + * this surfaced downstream, but a wrapper reproduces it without a database. */ + +class WindowedDirectory +{ + public static ?object $spawned = null; + + public $context; + + public function dir_opendir(string $path, int $options): bool + { + self::$spawned = spawn(static function (): string { + try { + return 'suppressed, got ' . var_export(@gzdecode(''), true); + } catch (Throwable $e) { + return 'leaked ' . $e::class . ': ' . $e->getMessage(); + } + }); + + delay(50); + + return false; } - return 'connect ended'; -}); + public function dir_readdir(): string|false + { + return false; + } -$victim = spawn(function () { - delay(50); + public function dir_closedir(): bool + { + return true; + } +} + +stream_wrapper_register('windowed', WindowedDirectory::class); +$holder = spawn(static function (): string { try { - $decoded = @gzdecode(''); - return 'suppressed, got ' . var_export($decoded, true); + new DirectoryIterator('windowed://dir'); } catch (Throwable $e) { - return 'leaked ' . $e::class . ': ' . $e->getMessage(); + return 'holder: ' . $e::class; } + + return 'holder: no exception'; }); -echo await($victim), "\n"; -echo await($connector), "\n"; +echo await($holder), "\n"; +echo await(WindowedDirectory::$spawned), "\n"; echo "Done\n"; ?> --EXPECT-- +holder: UnexpectedValueException suppressed, got false -connect ended Done diff --git a/tests/edge_cases/017-error_handling_window_not_inherited_on_resume.phpt b/tests/edge_cases/017-error_handling_window_not_inherited_on_resume.phpt new file mode 100644 index 0000000..f6f818b --- /dev/null +++ b/tests/edge_cases/017-error_handling_window_not_inherited_on_resume.phpt @@ -0,0 +1,75 @@ +--TEST-- +An EH_THROW window opened while we were parked does not follow us on resume +--EXTENSIONS-- +zlib +--SKIPIF-- + +--FILE-- +getMessage(); + } +}); + +$holder = spawn(static function (): string { + delay(30); + + try { + new DirectoryIterator('windowed://dir'); + } catch (Throwable $e) { + return 'holder: ' . $e::class; + } + + return 'holder: no exception'; +}); + +echo await($victim), "\n"; +echo await($holder), "\n"; +echo "Done\n"; +?> +--EXPECT-- +suppressed, got false +holder: UnexpectedValueException +Done From 8e89b1c5be6cf5b039845ea7b7092ffa0ad9421f Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:46:56 +0000 Subject: [PATCH 2/2] fix(scheduler): drop the fiber-entry reset the engine now does zend_fiber_switch_context clears EG(error_handling)/EG(exception_class) before the jump, so a coroutine's fiber starts outside any EH_THROW window whichever way it is entered. A second reset here left two mechanisms for one invariant, and neither of them visibly load-bearing. Requires php-src true-async-stable bfb4525597; edge_cases 016 fails without it. Co-Authored-By: Claude Opus 5 (1M context) --- scheduler.c | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scheduler.c b/scheduler.c index e20d171..2fa44a2 100644 --- a/scheduler.c +++ b/scheduler.c @@ -1811,11 +1811,6 @@ ZEND_STACK_ALIGNED void fiber_entry(zend_fiber_transfer *transfer) EG(current_execute_data) = execute_data; EG(jit_trace_num) = 0; EG(error_reporting) = (int) error_reporting; - /* A fiber starts outside anyone's EH_THROW window (zend_replace_error_handling): - * inheriting one would paint this coroutine's warnings with a foreign - * exception class. */ - EG(error_handling) = EH_NORMAL; - EG(exception_class) = NULL; #ifdef ZEND_CHECK_STACK_LIMIT EG(stack_base) = zend_fiber_stack_base(internal_fiber_context->stack);