Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
pull_request:
branches: [master]

permissions:
contents: read
pull-requests: read

jobs:
build:
name: PHP ${{ matrix.php }} - ${{ matrix.os }}
Expand All @@ -18,6 +22,8 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
115 changes: 70 additions & 45 deletions eventloop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -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;
}
/* }}} */

Expand Down Expand Up @@ -544,13 +556,25 @@ 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)
Z_PARAM_OBJECT_OF_CLASS(closure, zend_ce_closure)
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")) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion php_eventloop.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
32 changes: 32 additions & 0 deletions tests/032_microtask_queue_reentrant.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
--TEST--
EventLoop::queue() preserves order when microtasks queue more microtasks
--EXTENSIONS--
eventloop
--FILE--
<?php

use EventLoop\EventLoop;

EventLoop::queue(function () {
echo "A\n";
EventLoop::queue(function () {
echo "C\n";
});
});

EventLoop::queue(function () {
echo "B\n";
});

EventLoop::defer(function () {
echo "D\n";
});

EventLoop::run();

?>
--EXPECT--
A
B
C
D
29 changes: 29 additions & 0 deletions tests/033_delay_self_cancel.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
--TEST--
EventLoop::cancel() inside a running delay callback is safe
--EXTENSIONS--
eventloop
--FILE--
<?php

use EventLoop\EventLoop;
use EventLoop\InvalidCallbackError;

$id = EventLoop::delay(0, function (string $callbackId) {
echo "delay running\n";
EventLoop::cancel($callbackId);
echo "delay cancelled self\n";
});

EventLoop::run();

try {
EventLoop::isEnabled($id);
} catch (InvalidCallbackError) {
echo "invalid after run\n";
}

?>
--EXPECT--
delay running
delay cancelled self
invalid after run
32 changes: 32 additions & 0 deletions tests/034_timer_cancel_peer.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
--TEST--
Timer callbacks can cancel another due timer during dispatch
--EXTENSIONS--
eventloop
--FILE--
<?php

use EventLoop\EventLoop;
use EventLoop\InvalidCallbackError;

$first = null;
$second = EventLoop::delay(0.001, function () {
echo "second\n";
});

$first = EventLoop::delay(0, function () use (&$second) {
echo "first\n";
EventLoop::cancel($second);
});

EventLoop::run();

try {
EventLoop::isEnabled($second);
} catch (InvalidCallbackError) {
echo "second cancelled\n";
}

?>
--EXPECT--
first
second cancelled
35 changes: 35 additions & 0 deletions tests/035_error_handler_cancel_active.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
--TEST--
Error handler can cancel an active repeating callback safely
--EXTENSIONS--
eventloop
--FILE--
<?php

use EventLoop\EventLoop;
use EventLoop\InvalidCallbackError;

$id = null;

EventLoop::setErrorHandler(function (\Throwable $e) use (&$id) {
echo "handled: " . $e->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
Loading
Loading