From 074be66867c9a7cdd7358657941a37a344fd71ea Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:02:54 +0000 Subject: [PATCH 1/2] feat(websocket): a Room transfers into another thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRANSFER puts a hub reference, a persistent copy of the topic and the retry snapshot into the transit shell; LOAD rebuilds the handle in the destination thread. Before this a task closure that captured a room received an uninitialized object. Known and measured: the transit shell leaks. thread_release_transferred_object (php-async) frees the properties table and the allocation, never free_obj, and the transfer API has no release kind — so the hub reference (21,608 bytes) and the persistent topic (40) are never dropped. HttpServer works around the same gap with http_server_release_worker_shell, which a room has no owner to call. --- src/http_server_class.c | 43 ++++++++++- .../websocket/069-room-transfers-to-pool.phpt | 77 +++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 tests/phpt/websocket/069-room-transfers-to-pool.phpt diff --git a/src/http_server_class.c b/src/http_server_class.c index 0377eb95..968d79cf 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -6105,6 +6105,39 @@ static zend_object *room_mint(http_server_object *server, zend_string *topic) return obj; } +/* Carries a room to another thread. The transit shell owns its own hub reference + * and a persistent copy of the topic, so neither outlives the source thread's + * allocator; LOAD takes a second reference and a thread-local copy. */ +static zend_object *room_transfer_obj( + zend_object *object, + zend_async_thread_transfer_ctx_t *ctx, + zend_object_transfer_kind_t kind, + zend_object_transfer_default_fn default_fn) +{ + room_object *src = room_from_obj(object); + + if (UNEXPECTED(src->hub == NULL)) { + return NULL; /* unminted; nothing to carry */ + } + + const bool persistent = (kind == ZEND_OBJECT_TRANSFER); + + zend_object *dst = default_fn(object, ctx, persistent ? sizeof(room_object) : 0); + + if (UNEXPECTED(dst == NULL)) { + return NULL; + } + + room_object *room = room_from_obj(dst); + + room->hub = src->hub; + topic_hub_addref(room->hub); + room->topic = zend_string_init(ZSTR_VAL(src->topic), ZSTR_LEN(src->topic), persistent); + room->retry = src->retry; + + return dst; +} + /* Reflection can build a Room past the private constructor: hub and topic NULL. */ static bool room_is_minted(const room_object *room) { @@ -7014,9 +7047,13 @@ void http_server_class_register(void) room_ce->create_object = room_create; memcpy(&room_handlers, &std_object_handlers, sizeof(zend_object_handlers)); - room_handlers.offset = offsetof(room_object, std); - room_handlers.free_obj = room_free; - room_handlers.clone_obj = NULL; + room_handlers.offset = offsetof(room_object, std); + room_handlers.free_obj = room_free; + room_handlers.clone_obj = NULL; + room_handlers.transfer_obj = room_transfer_obj; + + /* LOAD has no live source object and resolves the handler by class name. */ + room_ce->default_object_handlers = &room_handlers; #endif } /* }}} */ diff --git a/tests/phpt/websocket/069-room-transfers-to-pool.phpt b/tests/phpt/websocket/069-room-transfers-to-pool.phpt new file mode 100644 index 00000000..7c321da7 --- /dev/null +++ b/tests/phpt/websocket/069-room-transfers-to-pool.phpt @@ -0,0 +1,77 @@ +--TEST-- +Rooms: a Room captured by a ThreadPool task publishes from that thread +--SKIPIF-- + +--EXTENSIONS-- +true_async_server +true_async +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(10) + ->setWriteTimeout(10) + ->setWsPingIntervalMs(0) +); +$server->enableRooms(); + +$server->addWebSocketHandler(function (WebSocket $ws, HttpRequest $req) { + $ws->subscribe('projects/demo'); + + foreach ($ws as $msg) { + /* read-only watcher */ + } +}); + +$server->addHttpHandler(function ($req, $res) { $res->setStatusCode(404)->end(); }); + +spawn(function () use ($port, $server) { + delay(1500); + + $room = $server->room('projects/demo'); + + $fp = ws_open($port); + if ($fp === null) { echo "handshake failed\n"; $server->stop(); return; } + + delay(400); + + $pool = new ThreadPool(1); + $task = $pool->submit(function () use ($room) { + return $room->name() . ':' . $room->publish('from-pool')['posted']; + }); + + echo 'task: ', await($task), "\n"; + + delay(500); + echo 'client got: ', ws_read_pending($fp) ?? '(nothing)', "\n"; + + $pool->close(); + fclose($fp); + $server->stop(); +}); + +$server->start(); +?> +--EXPECTF-- +task: projects/demo:1 +client got: from-pool%A From 173f9467a8cc427b30a2328272472bc56b624a4c Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:25:34 +0000 Subject: [PATCH 2/2] feat(websocket): release a transferred room's hub through the new transfer kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transit shell's C state is now reachable at release (php-src ZEND_OBJECT_TRANSFER_RELEASE, php-async dispatch), so a room drops its hub reference and frees the persistent topic instead of leaking them. Measured on 069: definitely lost falls from 21,648 bytes in 2 blocks to zero. HttpServerConfig releases its frozen snapshot the same way. HttpServer does not: it owns its shells and frees them in http_server_release_worker_shell, so releasing here would double-free. Room::subscriberCount() gets the caveat it always deserved — from a thread that never attached to the hub, which is exactly where a transferred room runs, it returns 0 and that reads the same as a room nobody joined. default_fn now gets 0 for the allocation size in both directions: the default derives it from the handler offset and the property count, and a literal sizeof() would stop covering the object once the stub declares a property. --- src/http_server_class.c | 22 +++++++++++++++++++++- src/http_server_config.c | 9 +++++++++ stubs/Room.php | 4 ++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/http_server_class.c b/src/http_server_class.c index 968d79cf..a0f53017 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -6116,13 +6116,27 @@ static zend_object *room_transfer_obj( { room_object *src = room_from_obj(object); + if (kind == ZEND_OBJECT_TRANSFER_RELEASE) { + topic_hub_release(src->hub); + src->hub = NULL; + + if (src->topic != NULL) { + zend_string_release(src->topic); + src->topic = NULL; + } + + return NULL; + } + if (UNEXPECTED(src->hub == NULL)) { return NULL; /* unminted; nothing to carry */ } const bool persistent = (kind == ZEND_OBJECT_TRANSFER); - zend_object *dst = default_fn(object, ctx, persistent ? sizeof(room_object) : 0); + /* 0 lets the default size the allocation from the handler offset and the + * property count; a literal sizeof() would stop covering declared properties. */ + zend_object *dst = default_fn(object, ctx, 0); if (UNEXPECTED(dst == NULL)) { return NULL; @@ -6779,6 +6793,12 @@ static zend_object *http_server_transfer_obj( zend_object_transfer_kind_t kind, zend_object_transfer_default_fn default_fn) { + if (kind == ZEND_OBJECT_TRANSFER_RELEASE) { + /* The server owns its shells and frees them in + * http_server_release_worker_shell; releasing here would double-free. */ + return NULL; + } + if (kind == ZEND_OBJECT_TRANSFER) { http_server_object *src = http_server_from_obj(object); diff --git a/src/http_server_config.c b/src/http_server_config.c index fae4fa0f..2be5b53d 100644 --- a/src/http_server_config.c +++ b/src/http_server_config.c @@ -3755,6 +3755,15 @@ static zend_object *http_server_config_transfer_obj( zend_object_transfer_kind_t kind, zend_object_transfer_default_fn default_fn) { + if (kind == ZEND_OBJECT_TRANSFER_RELEASE) { + http_server_config_t *shell = http_server_config_from_obj(object); + + http_server_shared_config_release(shell->frozen); + shell->frozen = NULL; + + return NULL; + } + if (kind == ZEND_OBJECT_TRANSFER) { http_server_config_t *src = http_server_config_from_obj(object); diff --git a/stubs/Room.php b/stubs/Room.php index 74ed9e95..984be960 100644 --- a/stubs/Room.php +++ b/stubs/Room.php @@ -96,6 +96,10 @@ public function send(string $message, ?int $timeoutMs = null): int {} * Suspends the calling coroutine until every worker answers or $timeoutMs * elapses. Must run on a worker thread (a request/WebSocket handler or a * spawned run coroutine); on the pool parent it returns the local count. + * + * A thread that never attached to the hub — a ThreadPool task the room was + * transferred into — gets 0, which reads the same as a room nobody joined. + * {@see trySend()} and {@see send()} report that thread honestly; this does not. */ public function subscriberCount(int $timeoutMs = 1000): int {}