Close a worker's request body by its resource, not by its address - #306
Merged
diolektor merged 1 commit intoAug 16, 2026
Merged
Conversation
Fix:
- Worker mode closed the buffered request body by the address it kept in SG(request_info).request_body, and that address stops being the body's the moment the application closes it. The body is a registered stream like any other, so get_resources('stream') hands it to a script with a reference of its own — enumerating open streams is how a handle leak gets hunted, and closing what looks like a stray one is the next step. The first release freed the php_stream while the address stood; the second landed in _php_stream_free()'s in_free guard, which reads freed-but-unreused memory and returns, so one such request showed nothing. Once the block was handed to something else, the end of the request closed a stream the script had opened and was still holding, and a second round on the same worker aborted the process with zend_mm_heap corrupted. Ownership is now held by the body's zend_resource with a reference of our own, and the close is gated on that resource still naming a stream (res->type == php_file_le_stream() && res->ptr) — true for a body nobody touched, false for one userland closed, whether by fclose() or by pclose(), and immune to address reuse because the reference keeps the resource and its number from being recycled underneath the test. Testing the address against the resource list does not answer this: after one allocation the address belongs to a new stream, and the check reports it as the live body.
- The claim is taken wherever a body appears without userland having run since: the per-request input rebuild, a wrapper over sapi_module.read_post, and a wrapper over the php:// opener. The last one is not for completeness — a multipart POST has no body until php://input is asked for, and rfc1867 has already consumed the request, so nothing is ever read through the SAPI for it. Without that capture every such request would leave a stream pair standing for the life of the worker.
- Nothing is read through SG(request_info).request_body until the claim already held confirms the address is still the body's; when the claim is dead, the claim step returns rather than dereferencing a pointer it knows to be stale. Without that order the php://temp opened after a closed body would be adopted as the body and closed by the end of the request — the defect again, from the code fixing it.
- The php:// wrapper is swapped inside the registry's own slot rather than unregistered and registered again. Registration appends, which moved php:// to the end of the table and changed the order stream_get_wrappers() reports, in every mode including the ones this has nothing to do with.
- A fiber parks and restores the claim with the body pointer, and a fiber destroyed while parked gives it back — otherwise the zend_resource would outlive the list holding it.
Tests:
- Two in the hooks profile, one worker. The first closes its own body, opens twelve php://temp in its place and asks the next request whether the resource list still has every one of them; on a build that closes by address a pair comes back destroyed, deterministically, and a second round corrupts the heap. It asks the list rather than the handles on purpose: a parked handle carries the only reference to its resource, so the broken build frees it and even is_resource() would read freed memory. The second sends three multipart POSTs and holds the open-stream count flat across them, which is the only thing that fails if the php:// opener stops being a capture point.
54 tests in the hooks profile (48 before), plus default 242, fibers 32, hooksclassic 9, worker 55.
diolektor
deleted the
fix/worker-mode-request-body-stream-reachable-from-userland
branch
August 16, 2026 05:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
A worker's request has no resource list of its own — the one it uses belongs to the worker and is destroyed once, at teardown — so the end of a request closes the buffered request body by hand. It did that by the address kept in
SG(request_info).request_body, and that address stops being the body's the moment the application closes it.The body is a registered stream like any other.
get_resources('stream')hands it to a script with a reference of its own, andfclose()on it works exactly as it does on a handle the script opened itself. That is not an exotic thing for an application to do: enumerating open streams is the ordinary way to hunt a handle leak, and closing what looks like a stray one is the next step.What follows is invisible for one request and destructive for the next:
fclose()frees thephp_streamand leavesSG(request_info).request_bodynaming the block. PHP marks the resource closed and keeps its slot (PHP_STREAM_FREE_KEEP_RSRC)._php_stream_free()'sin_freeguard, which reads freed memory and returns quietly — so a request that closes its body and stops shows no symptom at all.zend_mm_heap corrupted, taking every request the worker was serving with it.Checking liveness by address does not answer this, and that was measured rather than assumed: looking for an entry in
EG(regular_list)whoseptrequals the body finds the new stream at the same address and reports the body as live. Identity by address is not identity.Traditional, framework and SPA serving were never affected — there the destruction of the request's resource list releases the body, and it skips what is already closed.
The fix
Ownership moves from the address to the body's
zend_resource, held with a reference of our own (GC_ADDREF, given back withzend_list_delete). The close is gated on that resource still naming a stream:True for a body nobody touched, false for one userland closed —
fclose()andpclose()alike, since both go throughzend_list_close()and leavetype = -1,ptr = NULL. Address reuse cannot fool it either: the reference keeps thezend_resourceand its number from being recycled underneath the test.The claim is taken at every point where a body can appear without userland having run since:
sapi_module.read_post— everything bytes reach, including a second parse from userlandrequest_parse_body();php://opener — the bodyphp://inputcreates for a request that had none.The third is not there for completeness. A multipart POST has no body at all until
php://inputis asked for: rfc1867 has already consumed the request andSG(post_read)is set, so the stream the wrapper makes is never read through the SAPI. Without that capture point every such request would leave a stream pair standing for the life of the worker.Two details worth calling out for review:
SG(request_info).request_bodyuntil the claim already held confirms the address is still the body's, and when that claim is dead the step returns rather than dereferencing a pointer it knows to be stale. Without that order, the firstphp://tempopened after a closed body is adopted as the body and closed by the end of the request — the same defect, produced by the code fixing it.php://wrapper is installed. The registry's own slot is swapped (zend_hash_str_findon the global wrapper table, then the pointer), not unregistered and registered again. Registration appends, which movesphp://to the end of the table and changes the orderstream_get_wrappers()reports, in every mode including the ones this change has nothing to do with. Measured:…,phar,phpinstead of…,php,file,….A fiber parks and restores the claim alongside the body pointer, and a fiber destroyed while parked gives it back — otherwise the
zend_resourcewould outlive the list holding it.Deliberately not changed
request_parse_body()keeps today's behaviour: the claim moves to the new body, the old one is released but not closed. Closing it needs thephp://inputwrapper walk extended to it, which is a contract of its own.ts->innerstreamdangling. That reproduces in PHP-FPM as well, where the destruction of the resource list frees it a second time — upstream, not ours.fopen('php://input')after the body has been closed dereferences a dangling pointer inside php-src itself (php_stream_rewind(input->body)). Same story.Verification
Two tests in the
hooksprofile (PHP_WORKERS=1), each red on a build missing the matching half of the fix:hooks/test_body_stream_not_double_closed— a request closes its own body, opens twelvephp://tempin its place and parks them; the next request asks whether the resource list still holds every resource they registered. On the unfixed build a pair comes back destroyed, deterministically (retired_resource_ids: [7, 8], then[43, 44, 61, 62]on a second round), and a second round on the same worker aborts withzend_mm_heap corrupted/SIGABRT. On the fixed build: three consecutive rounds green, container log empty. The question goes to the resource list rather than to the parked handles on purpose — a parked handle carries the only reference to its resource (php_stream_to_zvalhands it over without one), so the broken build frees it and evenis_resource()would read freed memory.hooks/test_multipart_input_streams_bounded— three multipart POSTs, open-stream count flat across them. This is the only thing that fails if thephp://opener stops being a capture point.stream_get_wrappers()order checked against a build without the hook: identical (https,ftps,compress.zlib,php,file,glob,data,http,ftp,phar).Profiles:
hooks54/0/0,default242/0/0,fibers32/0/0,hooksclassic9/0/0,worker55/0/1 — the one error is the knownshared/test_channel_async_usevar_stressflake, unrelated to request bodies.Host:
cargo fmt -- --check,cargo clippy --no-default-features -- -D warnings,cargo test --no-default-features(27 binaries) all clean.