Let a paused request keep the time it started at - #303
Merged
Conversation
Fix: - A request that pauses in worker mode now comes back knowing when it started. The start time lives in the bridge's per-thread context — one slot for every request the worker multiplexes — and was not part of what a fiber carries across a suspension. A neighbouring request's setup overwrote it and that request's teardown zeroed it, so a request resuming after the worker had served another one whole read 0.0 as its own start: `$request->startTime()` reported the epoch and `microtime(true) - $request->startTime(true)` measured the time since 1970. If the neighbour was still in flight it read that request's start instead, which is wrong more quietly. The value now travels with the fiber, saved next to the response flags already parked there and re-installed before the fiber runs again. - Parked as the context slot rather than as part of the request data the Rust side already carries, because `oxphp_server_info()['request_time']` reads that slot directly: the cheaper-looking option would have fixed `$request->startTime()` and left the two answering differently. `$_SERVER['REQUEST_TIME']` was correct throughout — it is frozen into the superglobals, which do travel across a pause — which is what made the split visible from inside a single request. Tests: - The suspension test now snapshots the start time before the hooked sleep and checks it after the resume: through both readers, against `$_SERVER['REQUEST_TIME']` on either side of the pause, and as the elapsed time an application would compute from it. The pre-suspend checks are load-bearing — without them the post-resume comparison passes on two zeroes. Against a build without the fix it fails with 0 from both readers and an elapsed time of 1.79e9 seconds. 43 tests in the hooks profile (42 passed, 1 failed without the fix; 43 passed with it).
Tests: - A request that resumes while a neighbour is still parked had no coverage. The existing suspension test lets its intruder finish inside the window, and a finished request erases the slot the start time lives in — so it only ever proved a resumed request does not read zero. The new test parks a neighbour for longer than the outer request's own suspensions and wakes into that: against a build without the parking, the outer comes back holding the neighbour's start time, 4.6ms off its own. The whole-second comparison against `$_SERVER['REQUEST_TIME']` does not catch that gap; the exact ones do. - Same test suspends twice, with a neighbour that ends inside the second window. A fix that carried the start time across one suspension but dropped it on the next passed every existing assertion. - The inheritance check reads the start time at the resume rather than at the end of the test. By the end the neighbour has finished and erased the slot, so the comparison would have been satisfied by the zero the defect leaves — passing for the reason it exists to catch. Docs: - The comment on the start-time callback listed three points that put the slot back to 0.0 as if they were exhaustive. They no longer are: a resume re-installs the value after all of them, and it then stands for as long as that fiber stays parked, so PHP the worker runs outside any request — a deferred promise drain, a destructor reached by GC — reads the parked request's time rather than zero. Reading 0.0 as "no request exists" is what that comment now warns against. - The comment on the parked field called `$_SERVER['REQUEST_TIME']` a value that travels with the superglobals, which reads as though it were an independent source. It is a third read of the same slot, stamped once at the start of the request — which is the only reason it stayed correct while the other two lied. Both stamp sites are named now, because moving either past the point where a request can park would break it too. 44 tests in the hooks profile (42 passed, 2 failed without the parking; 44 passed with it).
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.
What
A request that pauses in worker mode comes back knowing when it started.
Request::startTime()andoxphp_server_info()['request_time']read the receiving time out of the bridge's per-thread context — one slot for every request a worker multiplexes — and that slot was not part of the state a fiber carries across a suspension. It has two writers on the Rust side: a request's setup stamps it, and a request's teardown zeroes it. So a request that paused on asleep(), a socket read or an await, and resumed after the worker had served another request whole, came back reading0.0as its own start time.microtime(true) - $request->startTime(true)then measured the time since 1970, and$request->startTime()reported the epoch.If the neighbour was still in flight rather than finished, nothing had zeroed anything and the resumed request read that request's start time instead — wrong by milliseconds rather than by decades, which is the harder half to notice.
$_SERVER['REQUEST_TIME']was correct throughout, so the two ways of asking a request when it began disagreed from the pause onwards — and the one an application is more likely to reach for was the wrong one.How
double bridge_request_timejoins the response flags already parked infiber->php_state; the save copies it out of the context and the restore installs it back before the fiber runs again. Both accessors were already exported, so no new symbols.Parked as the context slot rather than as part of the per-request data the Rust side already carries:
oxphp_server_info()['request_time']reads that slot directly, so the cheaper-looking option would have fixed$request->startTime()and left the two answering differently.Not affected: traditional, framework and SPA serving (nothing else runs on the thread between the start of a request and the end of it), and async task fibers (they carry no HTTP request and never touch this state).
Tests
Two cases in the
hooksprofile, both proven against a build without the parking.The existing suspension test gains the start time to its snapshot, checked after the resume through both readers, against
$_SERVER['REQUEST_TIME']on either side of the pause, and as the elapsed time an application would compute. Its intruder finishes inside the window, so it covers the erased slot: without the fix it reports0from both readers and an elapsed time of 1.79e9 seconds.A new test covers what that one structurally cannot. It parks a neighbour for longer than its own suspensions and wakes into that, so nothing has been erased. Without the fix the outer request comes back holding the neighbour's start time, 4.6 ms off its own — close enough that the whole-second comparison against
$_SERVER['REQUEST_TIME']still passes, which is why the exact comparisons carry that case. The same test suspends twice, with a neighbour that ends inside the second window: a fix that carried the value across one suspension but dropped it on the next would pass every assertion the first test makes.The inheritance check reads the start time at the resume rather than at the end of the test. By the end the neighbour has finished and erased the slot, so the comparison would have been satisfied by the very zero the defect leaves behind.
Pre-suspension assertions in both tests are load-bearing: without them the post-resume comparisons pass on two zeroes.
Verification
cargo fmt -- --check,cargo clippy --no-default-features -- -D warnings,cargo clippy --features php --tests -- -D warnings,cargo test --no-default-features— all clean, 955 unit tests.hooksprofile: 42 passed / 2 failed without the parking, 44 passed with it. Both images built with the legacy builder and confirmed to ship different extension binaries.Also in here
Two comments this change made stale or misleading, corrected in the same branch.
The comment on the start-time callback listed the points that put the slot back to
0.0as if they were exhaustive. They no longer are: a resume re-installs the value after all of them, and it then stands for as long as that fiber stays parked — so PHP the worker runs outside any request in that window, such as a deferred promise drain or a destructor reached by GC, reads the parked request's time rather than zero. That is correct for the fiber that owns it and stale for anything else, so0.0can no longer be read as "no request exists".The comment on the parked field described
$_SERVER['REQUEST_TIME']as a value carried by the superglobals, which reads as though it were an independent source. It is a third read of the same slot, stamped once at the very start of the request — which is the only reason it stayed correct while the other two lied. Both stamp sites are named now, since moving either past the point where a request can already park would break it too.