diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d874ab6..8d7d296 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [master] +permissions: + contents: read + pull-requests: read + jobs: build: name: PHP ${{ matrix.php }} - ${{ matrix.os }} @@ -18,6 +22,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fc999..e04ce85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.1.0 - 2026-05-23 + +### Fixed + +- Routed microtask exceptions through the configured `EventLoop::setErrorHandler()` handler. +- Stopped microtask processing when the configured error handler throws. + +### Changed + +- Hardened the CI workflow permissions and avoided persisting checkout credentials. +- Optimized microtask queue processing to avoid repeated array shifting during dispatch. +- Added regression coverage for callback cancellation during timer dispatch and error handling. + ## 1.0.2 - 2026-05-23 ### Fixed diff --git a/README.md b/README.md index 07d1156..d9ae481 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ If you use `Revolt\EventLoop\Suspension`: make test ``` -The extension ships with 31 `.phpt` tests covering defer, delay, repeat, I/O watchers, signals, suspensions, error handling, and edge cases. +The extension ships with 37 `.phpt` tests covering defer, delay, repeat, I/O watchers, signals, suspensions, error handling, and edge cases. ## Changelog diff --git a/eventloop.c b/eventloop.c index e7f47e3..a9a14b7 100644 --- a/eventloop.c +++ b/eventloop.c @@ -90,6 +90,45 @@ eventloop_driver *eventloop_select_best_driver(void) } /* }}} */ +static void eventloop_handle_callback_exception(void) +{ + if (UNEXPECTED(!EG(exception))) { + return; + } + + if (Z_TYPE(EVENTLOOP_G(error_handler)) != IS_NULL) { + zval error_params[1]; + zval error_retval; + zval exception_zv; + zend_fcall_info error_fci; + zend_fcall_info_cache error_fcc; + + ZVAL_OBJ_COPY(&exception_zv, EG(exception)); + zend_clear_exception(); + ZVAL_COPY_VALUE(&error_params[0], &exception_zv); + + if (zend_fcall_info_init(&EVENTLOOP_G(error_handler), 0, + &error_fci, &error_fcc, NULL, NULL) == SUCCESS) { + ZVAL_UNDEF(&error_retval); + error_fci.retval = &error_retval; + error_fci.param_count = 1; + error_fci.params = error_params; + + if (zend_call_function(&error_fci, &error_fcc) == SUCCESS) { + zval_ptr_dtor(&error_retval); + } + + if (UNEXPECTED(EG(exception))) { + EVENTLOOP_G(stopped) = true; + } + } + + zval_ptr_dtor(&exception_zv); + } else { + EVENTLOOP_G(stopped) = true; + } +} + /* {{{ eventloop_dispatch_callback */ void eventloop_dispatch_callback(eventloop_callback *cb) { @@ -137,38 +176,7 @@ void eventloop_dispatch_callback(eventloop_callback *cb) zval_ptr_dtor(&retval); } - if (UNEXPECTED(EG(exception))) { - if (Z_TYPE(EVENTLOOP_G(error_handler)) != IS_NULL) { - zval error_params[1]; - zval error_retval; - zval exception_zv; - zend_fcall_info error_fci; - zend_fcall_info_cache error_fcc; - - ZVAL_OBJ_COPY(&exception_zv, EG(exception)); - zend_clear_exception(); - ZVAL_COPY_VALUE(&error_params[0], &exception_zv); - - if (zend_fcall_info_init(&EVENTLOOP_G(error_handler), 0, - &error_fci, &error_fcc, NULL, NULL) == SUCCESS) { - ZVAL_UNDEF(&error_retval); - error_fci.retval = &error_retval; - error_fci.param_count = 1; - error_fci.params = error_params; - - if (zend_call_function(&error_fci, &error_fcc) == SUCCESS) { - zval_ptr_dtor(&error_retval); - } - - if (UNEXPECTED(EG(exception))) { - EVENTLOOP_G(stopped) = true; - } - } - zval_ptr_dtor(&exception_zv); - } else { - EVENTLOOP_G(stopped) = true; - } - } + eventloop_handle_callback_exception(); zval_ptr_dtor(params); @@ -188,15 +196,8 @@ void eventloop_process_microtasks(void) zend_fcall_info fci; zend_fcall_info_cache fcc; - while (EVENTLOOP_G(microtask_count) > 0) { - mt = EVENTLOOP_G(microtask_queue)[0]; - - EVENTLOOP_G(microtask_count)--; - if (EVENTLOOP_G(microtask_count) > 0) { - memmove(&EVENTLOOP_G(microtask_queue)[0], - &EVENTLOOP_G(microtask_queue)[1], - sizeof(eventloop_microtask) * EVENTLOOP_G(microtask_count)); - } + while (EVENTLOOP_G(microtask_head) < EVENTLOOP_G(microtask_count)) { + mt = EVENTLOOP_G(microtask_queue)[EVENTLOOP_G(microtask_head)++]; if (zend_fcall_info_init(&mt.closure, 0, &fci, &fcc, NULL, NULL) == SUCCESS) { ZVAL_UNDEF(&retval); @@ -216,15 +217,26 @@ void eventloop_process_microtasks(void) if (Z_TYPE(mt.args) == IS_ARRAY) { zend_fcall_info_args_clear(&fci, 1); } + } - if (UNEXPECTED(EG(exception)) && Z_TYPE(EVENTLOOP_G(error_handler)) == IS_NULL) { - EVENTLOOP_G(stopped) = true; - } + eventloop_handle_callback_exception(); + + zval_ptr_dtor(&mt.closure); + zval_ptr_dtor(&mt.args); + + if (EVENTLOOP_G(stopped)) { + break; } + } + while (EVENTLOOP_G(microtask_head) < EVENTLOOP_G(microtask_count)) { + mt = EVENTLOOP_G(microtask_queue)[EVENTLOOP_G(microtask_head)++]; zval_ptr_dtor(&mt.closure); zval_ptr_dtor(&mt.args); } + + EVENTLOOP_G(microtask_head) = 0; + EVENTLOOP_G(microtask_count) = 0; } /* }}} */ @@ -544,6 +556,7 @@ ZEND_METHOD(EventLoop_EventLoop, queue) eventloop_microtask *mt; uint32_t i; uint32_t new_capacity; + uint32_t active_count; zval tmp; ZEND_PARSE_PARAMETERS_START(1, -1) @@ -551,6 +564,17 @@ ZEND_METHOD(EventLoop_EventLoop, queue) Z_PARAM_VARIADIC('+', args, argc) ZEND_PARSE_PARAMETERS_END(); + if (EVENTLOOP_G(microtask_count) >= EVENTLOOP_G(microtask_capacity)) { + if (EVENTLOOP_G(microtask_head) > 0) { + active_count = EVENTLOOP_G(microtask_count) - EVENTLOOP_G(microtask_head); + memmove(&EVENTLOOP_G(microtask_queue)[0], + &EVENTLOOP_G(microtask_queue)[EVENTLOOP_G(microtask_head)], + sizeof(eventloop_microtask) * active_count); + EVENTLOOP_G(microtask_head) = 0; + EVENTLOOP_G(microtask_count) = active_count; + } + } + if (EVENTLOOP_G(microtask_count) >= EVENTLOOP_G(microtask_capacity)) { if (!eventloop_next_capacity(EVENTLOOP_G(microtask_capacity), 8, sizeof(eventloop_microtask), &new_capacity, "microtask queue")) { @@ -1222,6 +1246,7 @@ PHP_RINIT_FUNCTION(eventloop) EVENTLOOP_G(deferred_capacity) = 0; EVENTLOOP_G(microtask_queue) = NULL; + EVENTLOOP_G(microtask_head) = 0; EVENTLOOP_G(microtask_count) = 0; EVENTLOOP_G(microtask_capacity) = 0; @@ -1257,7 +1282,7 @@ PHP_RSHUTDOWN_FUNCTION(eventloop) EVENTLOOP_G(driver) = NULL; } - for (i = 0; i < EVENTLOOP_G(microtask_count); i++) { + for (i = EVENTLOOP_G(microtask_head); i < EVENTLOOP_G(microtask_count); i++) { zval_ptr_dtor(&EVENTLOOP_G(microtask_queue)[i].closure); zval_ptr_dtor(&EVENTLOOP_G(microtask_queue)[i].args); } diff --git a/php_eventloop.h b/php_eventloop.h index 19a6b9c..5cf810b 100644 --- a/php_eventloop.h +++ b/php_eventloop.h @@ -17,7 +17,7 @@ # include "zend_fibers.h" # endif -# define PHP_EVENTLOOP_VERSION "1.0.2" +# define PHP_EVENTLOOP_VERSION "1.1.0" extern zend_module_entry eventloop_module_entry; # define phpext_eventloop_ptr &eventloop_module_entry @@ -109,6 +109,7 @@ ZEND_BEGIN_MODULE_GLOBALS(eventloop) uint32_t deferred_capacity; eventloop_microtask *microtask_queue; + uint32_t microtask_head; uint32_t microtask_count; uint32_t microtask_capacity; diff --git a/tests/032_microtask_queue_reentrant.phpt b/tests/032_microtask_queue_reentrant.phpt new file mode 100644 index 0000000..9f8899a --- /dev/null +++ b/tests/032_microtask_queue_reentrant.phpt @@ -0,0 +1,32 @@ +--TEST-- +EventLoop::queue() preserves order when microtasks queue more microtasks +--EXTENSIONS-- +eventloop +--FILE-- + +--EXPECT-- +A +B +C +D diff --git a/tests/033_delay_self_cancel.phpt b/tests/033_delay_self_cancel.phpt new file mode 100644 index 0000000..868cfe7 --- /dev/null +++ b/tests/033_delay_self_cancel.phpt @@ -0,0 +1,29 @@ +--TEST-- +EventLoop::cancel() inside a running delay callback is safe +--EXTENSIONS-- +eventloop +--FILE-- + +--EXPECT-- +delay running +delay cancelled self +invalid after run diff --git a/tests/034_timer_cancel_peer.phpt b/tests/034_timer_cancel_peer.phpt new file mode 100644 index 0000000..e88732a --- /dev/null +++ b/tests/034_timer_cancel_peer.phpt @@ -0,0 +1,32 @@ +--TEST-- +Timer callbacks can cancel another due timer during dispatch +--EXTENSIONS-- +eventloop +--FILE-- + +--EXPECT-- +first +second cancelled diff --git a/tests/035_error_handler_cancel_active.phpt b/tests/035_error_handler_cancel_active.phpt new file mode 100644 index 0000000..6b95424 --- /dev/null +++ b/tests/035_error_handler_cancel_active.phpt @@ -0,0 +1,35 @@ +--TEST-- +Error handler can cancel an active repeating callback safely +--EXTENSIONS-- +eventloop +--FILE-- +getMessage() . "\n"; + EventLoop::cancel($id); + EventLoop::stop(); +}); + +$id = EventLoop::repeat(0, function () { + throw new RuntimeException("boom"); +}); + +EventLoop::run(); +EventLoop::setErrorHandler(null); + +try { + EventLoop::isEnabled($id); +} catch (InvalidCallbackError) { + echo "repeat cancelled\n"; +} + +?> +--EXPECT-- +handled: boom +repeat cancelled diff --git a/tests/036_microtask_error_handler.phpt b/tests/036_microtask_error_handler.phpt new file mode 100644 index 0000000..d20b576 --- /dev/null +++ b/tests/036_microtask_error_handler.phpt @@ -0,0 +1,31 @@ +--TEST-- +EventLoop error handler catches microtask exceptions +--EXTENSIONS-- +eventloop +--FILE-- +getMessage() . "\n"; +}); + +EventLoop::queue(function () { + throw new RuntimeException("microtask failed"); +}); + +EventLoop::queue(function () { + echo "continued\n"; +}); + +EventLoop::run(); +EventLoop::setErrorHandler(null); + +echo "done\n"; + +?> +--EXPECT-- +handled: microtask failed +continued +done diff --git a/tests/037_microtask_error_handler_throws.phpt b/tests/037_microtask_error_handler_throws.phpt new file mode 100644 index 0000000..9f811ba --- /dev/null +++ b/tests/037_microtask_error_handler_throws.phpt @@ -0,0 +1,34 @@ +--TEST-- +EventLoop stops when microtask error handler throws +--EXTENSIONS-- +eventloop +--FILE-- +getMessage() . "\n"; + throw new RuntimeException("handler failed"); +}); + +EventLoop::queue(function () { + throw new RuntimeException("microtask failed"); +}); + +EventLoop::queue(function () { + echo "should not run\n"; +}); + +try { + EventLoop::run(); +} catch (RuntimeException $e) { + echo "caught: " . $e->getMessage() . "\n"; +} + +EventLoop::setErrorHandler(null); + +?> +--EXPECT-- +handled: microtask failed +caught: handler failed