# Bug inside PHP CLI sessions

- Thread: https://externals.io/message/129634
- Original archive: https://news-web.php.net/php.internals/129634
- Started by Gregory Magarshak on 2025-12-17T17:06:33+00:00
- Messages: 3
- Reply by email to internals@lists.php.net
- More for agents: search with https://externals.io/search.md?q={query} · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

---

## #129634 — Gregory Magarshak — 2025-12-17T17:06:33+00:00

URL: https://externals.io/message/129634#129634

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>

ᐧ
ᐧ

---

## #129635 — Gregory Magarshak — 2025-12-17T17:12:38+00:00

URL: https://externals.io/message/129634#129635 · In reply to: #129634

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>





> 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>
>
> ᐧ
> ᐧ

---

## #129636 — Morgan — 2025-12-17T17:37:16+00:00

URL: https://externals.io/message/129634#129636 · In reply to: #129635

> 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>
> 
> 

I tried your minimal reproductions in the CLI and got undefined variable 
warnings from the $id value. Putting in a literal session id value I get

1. session_id('session17'); `session_start()`; echo `session_id()`;"

 > session17

2. session_id('session17'); echo 'b'; `session_start()`; echo `session_id()`;

 > bSession cannot be started after headers have already been sent (sent 
from Command line code on line 1) in Command line code on line 1

3. echo 'a'; session_id('session17'); echo 'b'; `session_start()`; echo 
 `session_id()`;

 > aSession ID cannot be changed after headers have already been sent
 > bSession cannot be started after headers have already been sent



I get the same results using `php -r`, `php -a`, and putting those lines 
in files and running them.

What else is your framework doing that is being left out of this setup?
