Give a worker's session back before it takes the next request - #373
Merged
diolektor merged 1 commit intoSep 20, 2026
Merged
Conversation
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
deleted the
fix/worker-session-stays-active-into-the-next-request
branch
September 20, 2026 07:29
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.
Problem
Session state on a worker thread —
PS(session_status),PS(id), the array behindPS(http_session_vars), the save handler's openPS(mod_data), and the_SESSIONentry the session module puts inEG(symbol_table)— is one set per thread, and the only code that gave any of it back was step 0 ofoxphp_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 inoxphp_async_await(),oxphp_sleep(),oxphp_usleep(), or a socket read underRUNTIME_HOOKS— go throughoxphp_bridge_prepare_request()andoxphp_fiber_init_request_state(), neither of which touches the session. Sosession_start()foundphp_session_active, returnedtruewithout reading the new request's cookie, raised the "Ignoring session_start() because a session is already active" notice, and left$_SESSIONholding the previous client's data under this client's name.session_write_close()did not protect against it. It reachesphp_session_flush(1), which resetsPS(session_status)and nothing else;PS(id)stays installed, andphp_session_start()consults the request's cookie only underif (!PS(id)). The next request therefore adopted the closed session's id, read that user's data out of the store, and — because thephp_session_nonebranch setsPS(send_cookie) = 1andphp_session_reset_id()honours it — sent that id back in aSet-Cookieof its own. One client is moved onto another client's session, silently, with no notice raised anywhere.The
_SESSIONsymbol-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_Pthenzend_hash_update_ind); the old step 0 dropped thePS()slot and left that entry in place, and_SESSIONis not inoxphp_symbol_global_nameseither. A request could read the previous request's session array without callingsession_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)andPS(session_started_filename). It deliberately does not drop the ninesession_set_save_handler()callables inPS(mod_user_names), matching upstream's ownphp_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, betweenoxphp_bridge_prepare_request()andoxphp_fiber_init_request_state(). That order matters for the same reason it does inoxphp_soft_reset(): the release executes PHP (the save handler's write,_SESSION's destructors if the module holds the last reference), andinit_request_state()below it clears theEG(exception)andPG(last_error_*)that PHP may leave behind.session_touchedflag, 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. Thepresent_at_entrysnapshot is what distinguishes the two, and dropping it makessession_is_not_released_under_a_live_neighbourfail.ext/oxphp_sapi.c— step 0 ofoxphp_soft_reset()becomes a call to the shared helper, so the blocking path picks up the_SESSIONremoval.docs/features/worker-mode.mdgains the session line in the reset inventory plus the three boundaries below;docs/features/fiber-multiplexing.mdnames$_SESSIONas not parked per fiber;docs/php/request-api.mdcloses the session in the worker-mode example;docs/features/early-response.mdstates that a session cannot be started after the response has gone;docs/php/superglobals.mdcorrects the$_SESSIONrow, which claimed the SAPI fills it.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
session_write_close().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.Alternatives rejected
Per-fiber
PS()isolation — correctness, not cost.ps_files_open()takes a blockingflock(LOCK_EX)and holds it untils_close. Today a second request with the samePHPSESSIDreuses the active session; with per-fiberPS(mod_data)it would go for a secondflockon 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
__threadslot — 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$_SESSIONbeing defined beforesession_start()../tests/run_all.sh --profile=worker64/64,--profile=session245/245,--profile=hooks57/57,--profile=sapi245/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).zend_tryaroundzend_delete_global_variable()is defensive and unproven. An earlier attempt to prove it established the opposite of what it assumed — objects left in$_SESSIONare 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.