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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Changelog

## 1.0.2 - 2026-05-23

### Fixed

- Kept callbacks alive while they are being dispatched, preventing use-after-free when callbacks cancel themselves.
- Made I/O watcher dispatch resilient when watchers cancel themselves or each other during event processing.
- Restored previous signal handlers when signal watchers are cancelled or the request shuts down.
- Cancelled replaced signal watchers when registering a new watcher for the same signal.
- Rejected non-finite and excessively large timer values before scheduling timers.
- Guarded internal queue and timer heap capacity growth against integer overflow.

## 1.0.1 - 2026-05-23

### Changed

- Skipped unnecessary driver polling when no I/O watchers are active.

## 1.0.0 - 2026-05-23

### Added

- Initial native PHP event loop extension.
- Revolt-compatible `EventLoop\EventLoop` API for deferred callbacks, timers, repeaters, I/O watchers, signal watchers, error handlers, and loop control.
- Fiber suspension support via `EventLoop\Suspension`.
- Auto-selected I/O drivers for epoll, kqueue, poll, and select.
- PHP stubs and generated arginfo for the public API.
- `.phpt` test suite and benchmark scripts.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,16 @@ If you use `Revolt\EventLoop\Suspension`:
make test
```

The extension ships with 26 `.phpt` tests covering defer, delay, repeat, I/O watchers, signals, suspensions, error handling, and edge cases.
The extension ships with 31 `.phpt` tests covering defer, delay, repeat, I/O watchers, signals, suspensions, error handling, and edge cases.

## Changelog

Release notes are available in [CHANGELOG.md](CHANGELOG.md).

## Acknowledgements

This project is built on the ideas and API design of [Revolt](https://github.com/revoltphp/event-loop) by Aaron Piotrowski, Niklas Keller, and contributors. Revolt's clean, well-thought-out API made it the natural foundation for a native implementation. Full credit to the Revolt team for defining the contract that this extension follows.

## License

Licensed under the [MIT License](LICENSE).
Licensed under the [MIT License](LICENSE).
16 changes: 12 additions & 4 deletions drivers/kqueue.c
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ static int eventloop_kqueue_add(eventloop_callback *cb)
}

/* Defer the syscall — will be flushed in poll() */
kq_changelist_push(cb->io.fd, filter, EV_ADD | EV_CLEAR, cb);
kq_changelist_push(cb->io.fd, filter, EV_ADD | EV_CLEAR, NULL);

return SUCCESS;
}
Expand All @@ -121,6 +121,7 @@ static int eventloop_kqueue_poll(double timeout)
int ret;
int i;
eventloop_callback *cb;
HashTable *callbacks;

if (timeout < 0) {
ts.tv_sec = 1;
Expand All @@ -142,9 +143,16 @@ static int eventloop_kqueue_poll(double timeout)
}

for (i = 0; i < ret; i++) {
cb = (eventloop_callback *)kq_events[i].udata;
if (cb && (cb->flags & EVENTLOOP_CB_FLAG_ENABLED) &&
!(cb->flags & EVENTLOOP_CB_FLAG_CANCELLED)) {
if (kq_events[i].filter == EVFILT_READ) {
callbacks = &kq_read_cbs;
} else if (kq_events[i].filter == EVFILT_WRITE) {
callbacks = &kq_write_cbs;
} else {
continue;
}

cb = zend_hash_index_find_ptr(callbacks, (zend_ulong)kq_events[i].ident);
if (cb) {
eventloop_dispatch_callback(cb);
}
}
Expand Down
34 changes: 30 additions & 4 deletions drivers/poll.c
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,26 @@ static int eventloop_poll_add(eventloop_callback *cb)
{
uint32_t idx;
zend_ulong key;
zval *idx_zv;
zval zv;

/* Store index for quick removal. Use a composite key: fd + type to allow
* both readable and writable on the same fd. */
key = ((zend_ulong)cb->io.fd << 1) | (cb->type == EVENTLOOP_CB_WRITABLE ? 1 : 0);
idx_zv = zend_hash_index_find(&fd_to_index, key);
if (idx_zv) {
idx = (uint32_t)Z_LVAL_P(idx_zv);
pollfds[idx].fd = cb->io.fd;
pollfds[idx].events = (cb->type == EVENTLOOP_CB_READABLE) ? POLLIN : POLLOUT;
pollfds[idx].revents = 0;

if (pollfd_cbs[idx] != cb) {
pollfd_cbs[idx] = cb;
}

return SUCCESS;
}

if (pollfds_size >= pollfds_capacity) {
pollfds_capacity *= 2;
pollfds = erealloc(pollfds, sizeof(struct pollfd) * pollfds_capacity);
Expand All @@ -69,9 +87,6 @@ static int eventloop_poll_add(eventloop_callback *cb)
pollfds[idx].revents = 0;
pollfd_cbs[idx] = cb;

/* Store index for quick removal. Use a composite key: fd + type to allow
* both readable and writable on the same fd. */
key = ((zend_ulong)cb->io.fd << 1) | (cb->type == EVENTLOOP_CB_WRITABLE ? 1 : 0);
ZVAL_LONG(&zv, idx);
zend_hash_index_update(&fd_to_index, key, &zv);

Expand Down Expand Up @@ -111,6 +126,7 @@ static void eventloop_poll_remove(eventloop_callback *cb)
ZVAL_LONG(&zv, idx);
zend_hash_index_update(&fd_to_index, moved_key, &zv);
}
pollfd_cbs[pollfds_size] = NULL;
}

static int eventloop_poll_poll(double timeout)
Expand All @@ -119,6 +135,8 @@ static int eventloop_poll_poll(double timeout)
int ret;
uint32_t n;
uint32_t i;
uint32_t ready_count = 0;
eventloop_callback **ready;

if (pollfds_size == 0) {
/* No fds to poll -- just sleep for the timeout duration */
Expand Down Expand Up @@ -150,13 +168,21 @@ static int eventloop_poll_poll(double timeout)
/* Dispatch events. Iterate a snapshot of the current size since
* callbacks may modify the array. */
n = pollfds_size;
ready = safe_emalloc(n, sizeof(eventloop_callback *), 0);
for (i = 0; i < n && i < pollfds_size; i++) {
if (pollfds[i].revents != 0) {
eventloop_dispatch_callback(pollfd_cbs[i]);
eventloop_cb_addref(pollfd_cbs[i]);
ready[ready_count++] = pollfd_cbs[i];
pollfds[i].revents = 0;
}
}

for (i = 0; i < ready_count; i++) {
eventloop_dispatch_callback(ready[i]);
eventloop_cb_release(ready[i]);
}
efree(ready);

return ret;
}

Expand Down
28 changes: 26 additions & 2 deletions drivers/select.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ static php_socket_t max_fd;
static HashTable read_cbs;
static HashTable write_cbs;

static void select_ready_push(eventloop_callback ***ready, uint32_t *count,
uint32_t *capacity, eventloop_callback *cb)
{
if (*count >= *capacity) {
*capacity = *capacity ? *capacity * 2 : 8;
*ready = erealloc(*ready, sizeof(eventloop_callback *) * *capacity);
}

eventloop_cb_addref(cb);
(*ready)[(*count)++] = cb;
}

static int eventloop_select_init(void)
{
FD_ZERO(&read_fds);
Expand Down Expand Up @@ -85,7 +97,11 @@ static int eventloop_select_poll(double timeout)
fd_set tmp_read, tmp_write;
struct timeval tv;
eventloop_callback *cb;
eventloop_callback **ready = NULL;
zend_ulong fd;
uint32_t ready_count = 0;
uint32_t ready_capacity = 0;
uint32_t i;
int ret;

memcpy(&tmp_read, &read_fds, sizeof(fd_set));
Expand Down Expand Up @@ -118,17 +134,25 @@ static int eventloop_select_poll(double timeout)
/* Dispatch readable events */
ZEND_HASH_FOREACH_NUM_KEY_PTR(&read_cbs, fd, cb) {
if (FD_ISSET((php_socket_t)fd, &tmp_read)) {
eventloop_dispatch_callback(cb);
select_ready_push(&ready, &ready_count, &ready_capacity, cb);
}
} ZEND_HASH_FOREACH_END();

/* Dispatch writable events */
ZEND_HASH_FOREACH_NUM_KEY_PTR(&write_cbs, fd, cb) {
if (FD_ISSET((php_socket_t)fd, &tmp_write)) {
eventloop_dispatch_callback(cb);
select_ready_push(&ready, &ready_count, &ready_capacity, cb);
}
} ZEND_HASH_FOREACH_END();

for (i = 0; i < ready_count; i++) {
eventloop_dispatch_callback(ready[i]);
eventloop_cb_release(ready[i]);
}
if (ready) {
efree(ready);
}

return ret;
}

Expand Down
Loading
Loading