Skip to content

Say which object a Request's attributes and caches belong to - #374

Merged
diolektor merged 1 commit into
mainfrom
fix/request-api-docs-call-attributes-a-per-request-store
Sep 20, 2026
Merged

diolektor merged 1 commit into
mainfrom
fix/request-api-docs-call-attributes-a-per-request-store

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

Problem

The request API documentation described the attributes container as per-request state — created for the request, shared across everything handling it, reset when the next one begins — and offered it as the way to pass data from middleware to a route handler "without using global variables".

The container is a property of one Request object, and oxphp_http_request() ends in a bare object_init_ex: it builds a new Request on every call, with a lazily created container of its own. So the pattern the page recommends is the one that does not work:

oxphp_http_request()->attributes()->set('tenant_id', $id);
oxphp_http_request()->attributes()->get('tenant_id');   // null

No exception, no warning, no log line — the default comes back. The two code examples on the page both pass $request along and are correct, which is what makes this worth fixing rather than noticing: the copy-pasteable path works, and only the prose around it sends a reader down the other one.

Scope

The card that started this named two lines. Grepping for the shape of the claim rather than the symptom turned up eight carriers in four files:

File What it said
docs/php/request-api.md overview, attributes(), the attributes paragraph, AttributesInterface, payload(), and two paragraphs under Worker Mode
oxphp.stub.php four docblocks — this ships as oxphp/stubs, so it is what a user's IDE reads
docs/php/functions.md the factory's one-line description
ext/oxphp_sapi.c the comment on the factory itself

Two of those are about caching rather than attributes, and were wrong in the same way. Only query() and payload() cache anything; headers(), cookies() and files() build their array afresh on every call, and there are exactly three cache properties on the object. The overview called them one cache held "for the duration of the request", and the parsed-body paragraph said the same thing about payload() on its own.

The Worker Mode paragraph was wrong in the other direction

It said the previous request's object "becomes invalid" and that all caches on it are "cleared automatically". Neither happens, and the first is the more damaging of the two because it tells a reader they would notice.

oxphp_req_is_active() is consulted in exactly one place, the factory. The soft reset between requests does not touch objects, and worker mode has no php_request_shutdown between requests to finalize them. A Request held past its handler therefore keeps working: method(), path(), header() and the other uncached readers answer out of whatever request the worker thread is serving at the moment they are called, while each cached item holds whatever it took on its own first call — which, for one never made before, is the current request's data. A framework stashing the object across requests gets a silent mix, not a failure.

The section now states that, says plainly that nothing on a Request is cleared when the next request begins, and links to the existing list of what the soft reset does cover.

What is deliberately not touched

The ### Attributes anchor is unchanged — the superglobals page links to it, and that page's sentence ("for as long as you pass that object along") was already correct. The two code examples are unchanged for the same reason. No signatures move, so no arginfo regeneration. CHANGELOG.md is untouched: this corrects text that shipped, and the Unreleased section makes no claim about attributes or Request caches.

Guard

A docs change cannot turn a test red, so there is no red-to-green here and this PR does not claim one. What the new test does is two things.

It was written and run before the prose, as the instrument the prose is written from: every sentence in the new text rests on its output rather than on a reading of the C. Nine assertions — two factory calls return two objects; attributes() is cached per object; a second Request carries its own container; a write through one call reads back as the default through another by get(), has() and all(); a passed object shares one container; a Fiber handed the object writes into that same container; a Fiber calling the factory itself gets an empty one.

And it stays as a regression guard in the other direction: if oxphp_http_request() ever becomes the singleton the old documentation promised, the first three assertions go red.

One premise is not covered by it. The Worker Mode wording above rests on reading the code, not on a test: pinning a Request held across two requests deterministically would need a compose profile with a single-worker pool, which is more machinery than the sentence is worth.

Verification

  • cargo fmt -- --check, cargo clippy --no-default-features -- -D warnings, cargo test --no-default-features — clean
  • scripts/gen-llms-txt.sh --check — up to date (58 pages); llms-full.txt is regenerated, not hand-edited
  • ./tests/run_all.sh --profile=default --no-build — 246 passed, 0 failed, 0 errors

Left for separate changes

Three things found while reading payload() and UploadedFile, all out of scope here and filed:

  • The Content-Type table for payload() is printed twice (the page and the stub) and promises returns the code does not produce. A JSON scalar body is dropped — only IS_ARRAY is kept, on both the direct and the fallback decode path — and reads back as null, indistinguishable from the invalid-JSON case the table lists separately. The stub additionally promises an object, which cannot happen: the decoder is called with PHP_JSON_OBJECT_AS_ARRAY.
  • The form-body branch of payload() is gated on the superglobals flag, so with SUPERGLOBALS_ENABLED=false it returns null — while the same page advertises that the object API works regardless. Whether that gate is right is an open question: on the HTTP path the flag demonstrably withholds $_SERVER and the query string, but read_post, read_cookies, content_type and content_length are handed over unconditionally and nothing clears PG(http_globals). Either the "Always available" row is false or the "Empty arrays" row is, and there is no test profile with that flag to say which.
  • UploadedFile::type() is documented as "cached on first call", which is literally true and reads as a per-request cache. file() builds a new UploadedFile every call, so two type() calls through it are two mime_content_type() calls — two file reads.

Docs:
  - The attributes container was described as per-request state that is reset with each new request and shared "without using global variables". It lives on the `Request` instance, and `oxphp_http_request()` returns a new instance on every call, so a write made through one call reads back as the default through another — no exception, no warning, no log line, which made the one advertised way to avoid a global the one that silently does not work. The pages now say the container belongs to the object and reaches exactly as far as the object is passed, and show the failing pattern as an anti-example beside the working one. A Fiber is not a special case of either half: handed the object it writes into the same container, calling the factory itself it gets one of its own.
  - Nothing is reset between requests, because there is nothing to reset — the container goes when the object holding it does. The worker-mode section said the opposite twice: that the previous request's object becomes invalid, and that all caches on it are cleared automatically. A held object is not invalidated and nothing reports it as stale, so the sentence also told a reader they would notice. Its uncached readers go on answering out of whatever request the worker thread is serving at the moment of the call, while each cached item holds whatever it took on its own first call. The section now says that, and points at the list of what the soft reset between requests does cover.
  - Of the full-array calls only `query()` and `payload()` cache anything; `headers()`, `cookies()` and `files()` build their array afresh on every call. The overview and the parsed-body paragraph both described these as one cache held for the duration of the request, the second of them still doing so after the rest of the page had been corrected.
  - The same claims stood in `oxphp/stubs`, which is what a user's IDE reads, and in the comment on the factory itself.

Tests:
  - A characterization test pins both halves, so the code cannot quietly become the singleton the old text promised: two factory calls give two objects with two containers, a write through one is invisible through the other by `get()`, `has()` and `all()`, and an object passed along — including into a Fiber — gives one container.

246 tests in the default profile.
@diolektor
diolektor merged commit 5535a26 into main Sep 20, 2026
7 checks passed
@diolektor
diolektor deleted the fix/request-api-docs-call-attributes-a-per-request-store branch September 20, 2026 13:55
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