Skip to content

Keep the slot filter_input(INPUT_ENV) reads for the whole worker - #302

Merged
diolektor merged 1 commit into
mainfrom
fix/worker-mode-filter-input-env-empty
Aug 13, 2026
Merged

Keep the slot filter_input(INPUT_ENV) reads for the whole worker#302
diolektor merged 1 commit into
mainfrom
fix/worker-mode-filter-input-env-empty

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

What

In worker mode filter_input(INPUT_ENV, …), filter_input_array(INPUT_ENV) and filter_has_var(INPUT_ENV, …) reported that no environment variables exist, for every name, on every request after the one that first built $_ENV on that worker. They answer again, and report the process environment — the same thing they report in every other SAPI.

Why it happened

ext/filter does not read $_ENV for INPUT_ENV. php_filter_get_storage() reads IF_G(env_array) and falls back to PG(http_globals)[TRACK_VARS_ENV], treating a non-array there as "storage not initialized". The fallback is the only path that exists: IF_G(env_array) is written only by the SAPI input filter with PARSE_ENV, and nothing ever calls the input filter with that argument — _php_import_environment_variables() registers through php_register_variable_quick(), which bypasses it.

Worker mode pins $_ENV so a .env loader's values survive a bootstrap that runs once per worker, and pinning works by disarming the auto-global. With the callback disarmed nothing rebuilds that slot, while the per-request cleanup destroyed it along with the other five. So the slot sat undefined for the rest of the worker's life.

The change

PG(http_globals)[TRACK_VARS_ENV] is worker state, not request state. Three loops that walk the six slots now skip that index:

  • the per-request destroy loop in oxphp_reset_request_context_globals()
  • the suspend snapshot in oxphp_fiber_save_php_state()
  • the resume restore in oxphp_fiber_restore_php_state()

The fourth such loop, in the request-finalize path, already skipped it.

These three go together or not at all. With only the first, a pausing request carries the array off and the request served inside that window finds it missing; a second request pausing while it is missing saves that emptiness and hands it back over the array the first one restored, taking the API away from every request the worker serves afterwards — permanently, and only under concurrency.

Contract

What the three functions report is the process environment. Values the application wrote into $_ENV are not among them: writing to $_ENV separates that array from the engine's copy by copy-on-write, and the filter extension goes on reading the engine's. That is true of PHP everywhere, not something worker mode introduces.

One worker-mode difference remains, and it is the one $_ENV already had: the environment reported is the one captured when $_ENV was first built on that worker, so a putenv() call made afterwards is not in it. getenv() reads the live environment and is unaffected.

This covers INPUT_ENV only and claims nothing about INPUT_GET, INPUT_POST or INPUT_COOKIE, which read storage of their own.

Traditional, framework and SPA serving were never affected — the reset that disarms _ENV runs only on worker-mode dispatch paths.

Tests

tests/php/hooks/test_env_filter_input.php with tests/php/hooks/fixture_inner_env_filter.php, three lines in the hooks profile (PHP_WORKERS=1, so every request lands on one worker):

line covers
?mode=warm materialises $_ENV, checks its own precondition, seeds a $_ENV write
?mode=expect the destroy loop
?mode=suspend the suspend snapshot (inner self-request reads the slot from inside the parked window) and the resume restore (outer request reads it again afterwards)

Two details that keep the test honest. The precondition in ?mode=warm compares $_ENV['PATH'] against getenv('PATH') rather than asserting a non-empty array — an earlier test in the profile seeds a key onto the same worker, so "non-empty" holds whatever variables_order says, and a build importing no environment would pass that line and fail the reads below it, which reads as a server fault instead of configuration. And the $_ENV write seeded there is asserted to be invisible through INPUT_ENV, paired with a positive check that it is still in $_ENV — without the positive half the negative one passes on a build where the write never arrived.

Verification

Two images from the same Dockerfile, differing only by the ext/ change (oxphp_sapi.so sha256 2b1d72d0… vs 05cc8b1e…, so the green run is not a stale image):

RED   (without the fix)  hooks: 41 passed, 2 failed, 0 errors (43)
GREEN (with the fix)     hooks: 43 passed, 0 failed, 0 errors (43)

RED fails on exactly the reported defect:

✗ filter_has_var(INPUT_ENV, PATH) on a later request of the same worker
    — expected: true, actual: false
✗ filter_input(INPUT_ENV, PATH) on a later request of the same worker
    — expected: /usr/local/sbin:…:/bin, actual: null
✗ filter_input_array(INPUT_ENV) carries PATH on a later request of the same worker
    — expected: /usr/local/sbin:…:/bin, actual: null
✗ inner request was served while this one was parked
    — expected: contains 'INNER-OK', actual: HTTP/1.0 500 Internal Server Error
      INNER-FAIL: filter_has_var(INPUT_ENV, PATH) = false while another request is parked; …

Other profiles on the green image: fibers 32/32, async 36/36, worker 53 passed with 3 errors — the known shared/* stress flakes, which give the same three on the red image.

Host: cargo fmt -- --check, cargo clippy --no-default-features -- -D warnings, cargo test --no-default-features all clean. The diff touches no Rust.

Fix:
  - filter_input(INPUT_ENV, …), filter_input_array(INPUT_ENV) and filter_has_var(INPUT_ENV, …) answer again in worker mode. The filter extension does not read $_ENV for INPUT_ENV — it reads an array the engine keeps for itself, and reports "no such variable" for anything else it finds there. That array is the only source it has: the one it would prefer is filled by an input filter nothing ever invokes for ENV. Worker mode pins $_ENV so a .env loader's values survive a bootstrap that runs once per worker, and pinning means the callback is disarmed, so nothing rebuilds the array once the per-request cleanup destroys it — from the request after the one that first built $_ENV, all three functions answered NULL, NULL and false for every name the environment plainly had. The cleanup now leaves that one slot standing, as the end of a request already did.
  - A request that pauses no longer takes it with it, which is the same hole seen from the other side. The suspend snapshot moved all six slots out and the resume put all six back, so the request served inside a paused window found the array missing exactly as before — and a second request pausing while it was missing would save that emptiness and hand it back over the array the first one restored, taking the API away from every request the worker served afterwards. That is why this and the cleanup change together or not at all: either alone is worse than the defect, and the failure of the pair is concurrency-only.
  - What the three functions report is the process environment, as PHP reports it everywhere: values the application wrote into $_ENV are not among them, because writing to $_ENV gives that array its own copy while the filter extension goes on reading the engine's. One worker-mode difference stays, the one $_ENV already has — the environment reported is the one captured when $_ENV was first built, so a later putenv() is not in it, while getenv() reads live.

Docs:
  - The superglobals reference stated that these three functions report nothing in worker mode, and the release notes published the same thing as a deliberate consequence of pinning $_ENV. Both now describe what the functions read and what they do not, folded into the entry about the pinning itself rather than added beside it, since the two would otherwise ship in one release saying opposite things. Both also say the note covers INPUT_ENV alone and claims nothing about INPUT_GET, INPUT_POST or INPUT_COOKIE, which read storage of their own.

Tests:
  - Three lines in the runtime-hooks profile, one worker: read the environment through all three entry points on a later request, and again from inside a paused window and after resuming. Each line covers one of the three places the array can be lost, and a first line materialises $_ENV before them — until it exists the callback is still armed and rebuilds the array on demand, which would make the rest pass on a build with the defect.
  - The paused-window line carries the discriminating check: its inner self-request asserts the environment from inside the window rather than only reporting that it ran, so a snapshot that carried the array off fails there instead of passing quietly.
  - The first line checks its own precondition against a key the environment has rather than against a non-empty $_ENV. An earlier test in the profile seeds a key onto the same worker, so a non-empty array is true whatever variables_order says — and a build importing no environment at all would then pass that line and fail the reads below it, which reads as a server fault rather than as configuration.
  - It also writes a key into $_ENV that the two reading lines assert is not visible through INPUT_ENV, which is what pins the documented half of the contract that rests on copy-on-write. Paired with a positive check that the key is still in $_ENV, without which it would pass on a build where the write never arrived.

3 integration tests.
@diolektor
diolektor merged commit f38ad55 into main Aug 13, 2026
7 checks passed
@diolektor
diolektor deleted the fix/worker-mode-filter-input-env-empty branch August 13, 2026 23:00
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