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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/thread_channel/045-send_refused_value_not_buffered.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
--TEST--
ThreadChannel: a refused value never reaches the buffer
--SKIPIF--
<?php
if (!PHP_ZTS) die('skip ZTS required');
?>
--FILE--
<?php

use Async\ThreadChannel;

// A resource cannot cross a thread boundary: send() throws and leaves the channel
// as it was, because a receiver cannot tell an undefined slot from a message.
$ch = new ThreadChannel(4);

try {
$ch->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
4 changes: 4 additions & 0 deletions thread.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 8 additions & 0 deletions thread_channel.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading