From 6cd7ab30db5c2aad49c817a8449a9cefc1ba85ad Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:03:53 +0000 Subject: [PATCH] fix(thread-channel): a refused value never reaches the buffer --- CHANGELOG.md | 6 +++ .../045-send_refused_value_not_buffered.phpt | 39 +++++++++++++++++++ thread.h | 4 ++ thread_channel.c | 8 ++++ 4 files changed, 57 insertions(+) create mode 100644 tests/thread_channel/045-send_refused_value_not_buffered.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f542e61..ee7ab60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the Async extension for PHP will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Sending a value that cannot cross a thread boundary aborted the process instead of only throwing.** `ThreadChannel::send()` transfers its argument into persistent memory before it takes the lock, and the transfer refuses what it cannot copy — a resource, an object with dynamic properties — by releasing the partial graph, leaving the destination `IS_UNDEF` and throwing. The send did not check for that: it pushed the undefined slot into the buffer and reported success, so the caller got the right exception while the buffer held a value no receiver can interpret. `ThreadPool` reads the task as an array, so a debug build died on the assertion at `thread_pool.c:347` and a release build, where that assertion is compiled out, reads array fields from a value that is not one. The send now leaves the buffer untouched and returns false, which every caller already handles: `ThreadChannel::send()` rethrows, and `ThreadPool::submit()` and `map()` release the snapshot and the future first. Reproduced with `$pool->submit(fn () => 1, fopen('php://memory', 'r'))`: SIGABRT before, a caught `Error` and exit 0 after. + ## [0.9.3] - 2026-08-13 ### Fixed diff --git a/tests/thread_channel/045-send_refused_value_not_buffered.phpt b/tests/thread_channel/045-send_refused_value_not_buffered.phpt new file mode 100644 index 00000000..18c9bedb --- /dev/null +++ b/tests/thread_channel/045-send_refused_value_not_buffered.phpt @@ -0,0 +1,39 @@ +--TEST-- +ThreadChannel: a refused value never reaches the buffer +--SKIPIF-- + +--FILE-- +send(fopen('php://memory', 'r')); +} catch (Error $e) { + echo "Caught: " . $e->getMessage() . "\n"; +} + +echo "count: " . $ch->count() . "\n"; +echo "empty: " . var_export($ch->isEmpty(), true) . "\n"; + +// The channel stays usable for a value that can be transferred. +$ch->send(['ok' => 1]); +var_dump($ch->recv()); + +echo "Done\n"; +?> +--EXPECT-- +Caught: Cannot transfer a resource between threads +count: 0 +empty: true +array(1) { + ["ok"]=> + int(1) +} +Done diff --git a/thread.h b/thread.h index c9e995d8..de3abe22 100644 --- a/thread.h +++ b/thread.h @@ -163,6 +163,10 @@ typedef zend_async_thread_transfer_ctx_t thread_transfer_ctx_t; * Copy a zval into persistent memory for cross-thread transfer. * Deep copies strings, arrays, and objects. Preserves identity * (shared references → shared copies) and handles cycles. + * + * A value it cannot copy (resource, object with dynamic properties) is refused: + * the partial copy is released, *dst is left IS_UNDEF and an exception thrown. + * Test *dst, not EG(exception): one may have been pending before the call. */ void async_thread_transfer_zval(zval *dst, const zval *src); diff --git a/thread_channel.c b/thread_channel.c index 3ba72be2..0eb5a97f 100644 --- a/thread_channel.c +++ b/thread_channel.c @@ -104,6 +104,14 @@ static bool thread_channel_send(zend_async_channel_t *channel, zval *value) zval persistent_copy; async_thread_transfer_zval(&persistent_copy, value); + if (UNEXPECTED(Z_TYPE(persistent_copy) == IS_UNDEF)) { + /* The value is not transferable between threads. async_thread_transfer_zval + * has already released the partial graph and thrown; leave the buffer + * untouched, because a receiver has no way to tell an undefined slot from + * a real message and every reader asserts on the type it expects. */ + return false; + } + retry: ASYNC_MUTEX_LOCK(ch->mutex);