Skip to content

Let a paused request keep the time it started at - #303

Merged
diolektor merged 2 commits into
mainfrom
fix/request-time-not-saved-per-fiber
Aug 14, 2026
Merged

Let a paused request keep the time it started at#303
diolektor merged 2 commits into
mainfrom
fix/request-time-not-saved-per-fiber

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

What

A request that pauses in worker mode comes back knowing when it started.

Request::startTime() and oxphp_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 a sleep(), a socket read or an await, and resumed after the worker had served another request whole, came back reading 0.0 as 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_time joins the response flags already parked in fiber->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 hooks profile, 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 reports 0 from 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.
  • hooks profile: 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.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 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, so 0.0 can 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.

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).
@diolektor
diolektor merged commit 9ad1c7e into main Aug 14, 2026
7 checks passed
@diolektor
diolektor deleted the fix/request-time-not-saved-per-fiber branch August 14, 2026 18:16
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