Skip to content

Give a worker's session back before it takes the next request - #373

Merged
diolektor merged 1 commit into
mainfrom
fix/worker-session-stays-active-into-the-next-request
Sep 20, 2026
Merged

diolektor merged 1 commit into
mainfrom
fix/worker-session-stays-active-into-the-next-request

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

Problem

Session state on a worker thread — PS(session_status), PS(id), the array behind PS(http_session_vars), the save handler's open PS(mod_data), and the _SESSION entry the session module puts in EG(symbol_table) — is one set per thread, and the only code that gave any of it back was step 0 of oxphp_soft_reset(), which a worker reaches only on the blocking path, when nothing else is in flight. Three separate defects followed.

A request admitted while the worker was carrying another began inside its predecessor's session. Requests taken through oxphp_scheduler_tick() — which is every request that arrives while another is suspended in oxphp_async_await(), oxphp_sleep(), oxphp_usleep(), or a socket read under RUNTIME_HOOKS — go through oxphp_bridge_prepare_request() and oxphp_fiber_init_request_state(), neither of which touches the session. So session_start() found php_session_active, returned true without reading the new request's cookie, raised the "Ignoring session_start() because a session is already active" notice, and left $_SESSION holding the previous client's data under this client's name.

session_write_close() did not protect against it. It reaches php_session_flush(1), which resets PS(session_status) and nothing else; PS(id) stays installed, and php_session_start() consults the request's cookie only under if (!PS(id)). The next request therefore adopted the closed session's id, read that user's data out of the store, and — because the php_session_none branch sets PS(send_cookie) = 1 and php_session_reset_id() honours it — sent that id back in a Set-Cookie of its own. One client is moved onto another client's session, silently, with no notice raised anywhere.

The _SESSION symbol-table entry outlived its request on both paths, including the blocking one. php_session_track_init() hands the symbol table a reference of its own (Z_ADDREF_P then zend_hash_update_ind); the old step 0 dropped the PS() slot and left that entry in place, and _SESSION is not in oxphp_symbol_global_names either. A request could read the previous request's session array without calling session_start() at all — no concurrency and no tick path required.

What changed

  • ext/oxphp_fiber.c, ext/oxphp_fiber.h — one release helper, oxphp_session_release_request_state(), holding what step 0 used to do inline plus the symbol-table entry, the id, PS(session_vars), PS(mod_user_class_name) and PS(session_started_filename). It deliberately does not drop the nine session_set_save_handler() callables in PS(mod_user_names), matching upstream's own php_rshutdown_session_globals(), which says not to: a handler registered during bootstrap has to survive into the next request. oxphp_session_state_present() answers whether the thread carries any session state at all, as one predicate over four disjuncts rather than four separate premises.
  • ext/oxphp_fiber.c, oxphp_scheduler_tick() — the release now runs when a request is admitted, between oxphp_bridge_prepare_request() and oxphp_fiber_init_request_state(). That order matters for the same reason it does in oxphp_soft_reset(): the release executes PHP (the save handler's write, _SESSION's destructors if the module holds the last reference), and init_request_state() below it clears the EG(exception) and PG(last_error_*) that PHP may leave behind.
  • Ownership, so the fix does not introduce the inverse defect. Releasing on every admission would take a session away from a neighbour that is suspended inside it. Each fiber carries a session_touched flag, set when session state is present after one of its slices and was not already present when that slice began — so the fiber that made the state appear owns it, and a fiber that merely parked next to somebody else's leftovers does not. The release fires only when no live, uncompleted fiber has the flag. The present_at_entry snapshot is what distinguishes the two, and dropping it makes session_is_not_released_under_a_live_neighbour fail.
  • ext/oxphp_sapi.c — step 0 of oxphp_soft_reset() becomes a call to the shared helper, so the blocking path picks up the _SESSION removal.
  • Docsdocs/features/worker-mode.md gains the session line in the reset inventory plus the three boundaries below; docs/features/fiber-multiplexing.md names $_SESSION as not parked per fiber; docs/php/request-api.md closes the session in the worker-mode example; docs/features/early-response.md states that a session cannot be started after the response has gone; docs/php/superglobals.md corrects the $_SESSION row, which claimed the SAPI fills it.
  • Tests — five PHP tests under tests/php/fibers/ with eight fixtures: the session does not outlive its request across the tick path, nothing is left behind on the blocking path (seed plus probe, so the probe cannot pass vacuously), a session survives its own request's suspension, and a session is not released out from under a live neighbour.

Boundaries this does not remove, now documented

  • Genuinely overlapping requests still share one session: a request admitted while a neighbour is live in a session is handed that session, including by adoption of its id after session_write_close().
  • The write still happens when the worker next has an idle moment rather than when the request that opened the session returns, so session_write_close() remains what puts the write — and the release of the save handler's lock — where the application puts it. That lock is exclusive and blocking, so a request elsewhere for the same id waits in the save handler until it is released. Filed separately.
  • Sharing is counted from the moment a request is taken, so a request that suspended before any session existed is not held to be in one that appeared while it slept.

Alternatives rejected

Per-fiber PS() isolation — correctness, not cost. ps_files_open() takes a blocking flock(LOCK_EX) and holds it until s_close. Today a second request with the same PHPSESSID reuses the active session; with per-fiber PS(mod_data) it would go for a second flock on the same file while the holder is suspended and cannot release until it resumes — a deadlock of the whole worker thread.

Releasing whenever fiber_count == 0 — that is the condition the blocking path already uses, and it is exactly the case that was never broken. It leaves the tick path untouched.

A single owner fiber id in a __thread slot — one id cannot express "two fibers have both been in this session", which is the state the boundary above describes, and it needs a sentinel that has to stay distinct from every id the scheduler can mint.

Verification

  • ./tests/run_all.sh --profile=fibers — 22/22. RED first, against an image built without the fix: the new tests failed on the notice, on the adopted id, on the neighbour's data, and on $_SESSION being defined before session_start().
  • ./tests/run_all.sh --profile=worker 64/64, --profile=session 245/245, --profile=hooks 57/57, --profile=sapi 245/245.
  • 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).
  • Each conjunct of the admission rule was mutated individually against a private image and the table has no empty cells, with one exception recorded here rather than left implicit: the zend_try around zend_delete_global_variable() is defensive and unproven. An earlier attempt to prove it established the opposite of what it assumed — objects left in $_SESSION are destructed before a release is entered, not during one — so the guard stays because the call can run a user save handler's shutdown path, not because a test drives PHP through it.

Fix:
  - A request admitted while a worker was carrying another one began inside its predecessor's session: `session_start()` answered true without reading the new request's cookie, and `$_SESSION` held the previous client's data under this client's name. Session state is one set per worker thread, and the only code that gave it back ran on the path a worker takes when it has nothing else in flight; the event loop's admission prepared a request without touching it.
  - `session_write_close()` was not the protection it looked like. It closes the session but leaves its id installed, and PHP consults the cookie only when no id is installed, so the request admitted next adopted the closed session's id instead, read that user's data out of the store, and got the same id back in a `Set-Cookie` of its own — one client moved onto another client's session with no notice raised anywhere.
  - The `$_SESSION` entry outlived its request on both admission paths, not only the busy one: the session module hands the symbol table a reference of its own beside the one it keeps, and only the latter was released, so a request could read the previous request's array without calling `session_start()` at all.
  - Both paths now share one release, which also takes `$_SESSION` out of the symbol table. The blocking path runs it unconditionally, because by its own condition the worker is carrying nothing. The event loop asks first, through a flag on the fiber carried by the request that opened the session and by every request admitted while one was standing: counting from admission rather than from authorship is what stops a session being taken out from under a request that was handed it, and reading the flag from a snapshot made before the slice rather than from the state after it is what stops a request that parked before any session existed from holding one for as long as it runs.
  - Per-fiber isolation of the session module's state was rejected on correctness rather than cost: the default save handler takes a blocking exclusive lock held until the handler is closed, so a second request for the same id would wait on a lock whose holder is suspended and cannot return until resumed, deadlocking the thread. Overlapping requests therefore still share a session, and that boundary is written down rather than left to be discovered.

Docs:
  - Worker mode names the session among what is reset between requests, with the boundaries an operator can hit: the close happens when the worker next has an idle moment, so the save handler's lock is held until then and a request for the same session elsewhere waits inside the handler; requests that overlap share the session; and a request that parked before any session existed is not in one and must not read `$_SESSION` after waking. The worker-mode session example and the early-response page close the session on every path out, and fiber multiplexing names `$_SESSION` rather than describing it by class.

Tests:
  - Five tests under the `fibers` profile, each asserting the mechanism rather than the symptom: a request admitted through the event loop finds no notice, no inherited id and no `$_SESSION`; a pair of sequential requests shows the symbol-table entry gone on the blocking path too; a request suspended with its own session still has it after waking; and a session standing under a live neighbour is not released beneath it.

22 fibers-profile tests.
@diolektor
diolektor merged commit 1ca543d into main Sep 20, 2026
7 checks passed
@diolektor
diolektor deleted the fix/worker-session-stays-active-into-the-next-request branch September 20, 2026 07:29
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