Re: Bug inside PHP CLI sessions
| From: | Gregory Magarshak | Date: | Wed, 17 Dec 2025 17:12:38 +0000 |
| Subject: | Re: Bug inside PHP CLI sessions | ||
| References: | 1 | Groups: | php.internals |
| Request: | Send a blank email to internals+get-129635@lists.php.net to get a copy of this message | ||
My apologies. I made a mistake in the minimal examples to reproduce:
1. session_id($id); session_start(); echo session_id(); // empty!
2. session_id($id); echo "b"; session_start(); echo session_id(); // FINE!!
3. echo "a"; session_id($id); echo "b"; session_start(); echo session_id();
// empty again!
Sincerely,
Greg Magarshak
<http://qbix.com/invest>
On Wed, Dec 17, 2025 at 12:06 PM Gregory Magarshak <greg@qbix.com> wrote:
> I am using my PHP framework inside my PHP scripts, which has code designed
> to work across multiple environments, including web requests and CLI
> scripts, and uses handlers which extends SessionHandler. I also
> tried implements SessionUpdateTimestampHandlerInterface and validateId.
> Unfortunately, the session handling in PHP has a trap door internally, that
> was likely motivated by not outputting headers after starting to send HTTP
> response body.
>
> It's mystical.
> 1. session_id($id); session_start(); echo session_id(); // empty!
> 2. session_id($id); echo "Session started.\n"; session_start(); echo
> session_id(); // FINE!!
> 3. session_id($id); echo "Session started.\n"; session_start(); echo
> session_id(); // empty again!
>
> I can't believe it is there in 2025.
>
> *I tried this with models trained on PHP documentation, and asked it to
> provide a link explaining this behavior. This is what it said:*
>
> I’m going to be very careful here and *not hallucinate citations*,
> because after checking the PHP manual, RFCs, and known bug threads, there
> is *no official documentation that explains this exact behavior in CLI*.
> There is also *no single bug report* that spells it out end-to-end the
> way you’ve reverse-engineered it.
>
> So here is the honest answer, split into *what is documented*, *what is
> observable in source*, and *what is not fixable from userland*.
> ------------------------------
> Short answer (truthful)
>
> There is *no supported way to “fix” this in PHP userland*..
>
> What you are seeing is a consequence of *how ext/session couples
> session-ID finalization to header emission state*, and that logic *cannot
> be made reliable in CLI*, because:
>
> -
>
> CLI has *no real headers*
> -
>
> session_id() *resets the session module’s internal header bookkeeping*
> -
>
> Output is tracked via a *single edge-triggered latch*
> -
>
> That latch is *not re-entrant* and *not monotonic*
> -
>
> Earlier output poisons later output
>
> This is *by design*, not a bug PHP intends to fix.
> ------------------------------
> Let’s explain your three cases precisely Case 1 — fails
>
> self::id($id);session_start();echo session_id(); // empty
>
> *Why:*
>
> -
>
> session_id($id) resets session state
> -
>
> No output has occurred since that reset
> -
>
> PHP believes headers are still possible
> -
>
> In CLI, that means “wait forever”
> -
>
> Session ID never commits
>
> ------------------------------
> Case 2 — works
>
> self::id($id);echo "Session started.\n";session_start();echo session_id(); // OK
>
> *Why:*
>
> -
>
> session_id($id) resets session state
> -
>
> Output occurs *after the reset*
> -
>
> Header latch flips to “headers impossible”
> -
>
> session_start() commits the ID
>
> ------------------------------
> Case 3 — fails again
>
> echo 'a';self::id($id);echo "Session started.\n";session_start();echo
> session_id(); // empty
>
> *Why this feels impossible (but isn’t):*
>
> -
>
> echo 'a' flips the latch
> -
>
> session_id($id) *clears the latch*
> -
>
> *Critically*: the latch is *edge-triggered*, not level-triggered
> -
>
> The second echo *does not re-arm it*
> -
>
> session_start() still thinks headers are possible
>
> This is the part that feels mystical, but it’s consistent.
> ------------------------------
> The deep reason (this is the core insight) The session module tracks *“output
> since last reset”*, not “output happened”
>
> Internally, the logic is effectively:
>
> if (output_has_occurred_since_last_session_reset) {
> commit_session_id();
> } else {
> defer();
> }
>
> And:
>
> -
>
> session_id() *is a session reset*
> -
>
> Output before that reset is discarded
> -
>
> Output after that reset must be the *first edge*
> -
>
> Further output does not help
>
> This logic exists because, in HTTP:
>
> -
>
> Changing the session ID means cookies may change
> -
>
> Cookies are headers
> -
>
> Header logic must be reset when the ID changes
>
> That logic *leaks into CLI unchanged*.
>
> Sincerely,
> Greg Magarshak
>
>
> <https://76183cf5.streak-link.com/CsqFLR9dXbkQa2qdJQNqfbB-/http%3A%2F%2Fqbix.com%2Finvest>
>
> ᐧ
> ᐧ
>