Skip to content

A Room transfers into another thread, and its transit shell releases what it owns - #151

Merged
EdmondDantes merged 2 commits into
mainfrom
feat/room-transfers
Aug 14, 2026
Merged

A Room transfers into another thread, and its transit shell releases what it owns#151
EdmondDantes merged 2 commits into
mainfrom
feat/room-transfers

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Needs true-async/php-src#20 and true-async/php-async#232, both merged; php-src is in true-async and true-async-stable.

A task closure that captured a Room used to receive an uninitialized object, because the class registered no transfer_obj handler. It has one now: 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. This is what a run executing in a pool thread needs before it can be told to stop or handed a person's answer.

The shell used to leak. thread_release_transferred_object frees the properties table, the class name and the allocation, and never free_obj — so a room's hub reference and its persistent topic had nobody to drop them. The two upstream PRs add ZEND_OBJECT_TRANSFER_RELEASE, and this one uses it:

  • Room drops the hub reference and frees the topic;
  • HttpServerConfig releases its frozen snapshot, which leaked the same way;
  • HttpServer refuses the kind on purpose — it owns its shells and frees them in http_server_release_worker_shell, so releasing here would double-free.

Room::subscriberCount() gets a caveat in the stub: from a thread that never attached to the hub — exactly where a transferred room runs — it returns 0, which reads the same as a room nobody joined. trySend() and send() report that thread honestly; the count does not.

default_fn is now given 0 for the allocation size in both directions. The default derives it from the handler offset and the property count; a literal sizeof(room_object) is correct only while the stub declares no properties, and stops covering the object the moment one appears.

Measurements

  • New test 069-room-transfers-to-pool.phpt: a pool task publishes through a captured room and a WebSocket client owned by the server thread receives it. Without the handler it fails with Room is uninitialized — the guard added in A Room holds the topic hub, not the server that minted it #150 turns what would be a NULL dereference into an exception.
  • valgrind on that test: definitely lost: 21,648 bytes in 2 blocks before the release kind, 0 bytes in 0 blocks after.
  • tests/phpt: 1 failed, pre-existing and unrelated — server/h3/045-h3-reload-reactor-pool.phpt, whose --EXPECTF-- ends in a %A that requires a newline run-tests has already trimmed.
  • ext/async on the same build: 3 failed, all three pre-existing (the ext/async symlink breaks their include of php-src helpers).

Re-running any of this needs -d extension_dir=$PWD/modules, or run-tests loads the installed extension instead of the one just built.

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.
…nsfer kind

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.
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Total lines: 81.77% → 81.78% (+0.00 pp)

File Baseline Current Δ Touched
src/core/http_connection.c 75.55% 75.26% -0.29 pp
src/http3/http3_callbacks.c 83.65% 84.09% +0.44 pp
src/http3/http3_static_response.c 76.37% 75.11% -1.27 pp
src/http_server_class.c 74.37% 74.57% +0.20 pp
src/http_server_config.c 92.45% 92.41% -0.04 pp
src/websocket/ws_session.c 90.32% 90.13% -0.19 pp

@EdmondDantes
EdmondDantes merged commit a81eb27 into main Aug 14, 2026
8 checks passed
@EdmondDantes
EdmondDantes deleted the feat/room-transfers branch August 14, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant