One user. Eight requests. One at a time.
An interactive PHP 8.5 simulator for a concurrency failure that aggregate service health can miss: sibling requests sharing one session ID serialize behind the session lock while workers, errors, and fleet-wide utilization still look ordinary.
The lab turns that hidden mutex into request waterfalls, privacy-safe trace correlation, lock acquisition wait, lock residency, occupied-worker time, and a per-session latency tail. It is designed to show what telemetry.sh can reveal when a fleet dashboard says “healthy” but one user journey is frozen.
PHP’s default file-based session handler locks a session file once the session opens. Another request for the same session cannot access it until the first request terminates or explicitly closes the session. That consistency boundary can accidentally include database work, API calls, template rendering, and anything else that happens before shutdown.
The simulator replays one same-session burst through four boundaries:
| Policy | Lock boundary | Operational tradeoff |
|---|---|---|
| Lock through response | Session read, application work, downstream call, and write all remain inside the lock | Consistent writes; serialized sibling requests and occupied workers |
| Early write + close | Required mutation is persisted before slow work, then session_write_close() releases ownership |
Practical when later code never expects another persisted session mutation |
| Read and close | A read-only snapshot uses session_start(['read_and_close' => true]) |
Minimal lock residency; later session changes are intentionally not written |
| Stateless claims | Identity claims are verified without opening server-side session state | Maximum concurrency; expiry, revocation, and claim freshness move into the design |
With the default scenario—eight tabs arriving within 35 ms, 24 PHP workers, 260 ms of application work, and a roughly 640 ms downstream call—the deterministic model produces:
| Policy | p99 response | Lock-wait p99 | Blocked worker time | Average fleet utilization |
|---|---|---|---|---|
| Lock through response | 7,156 ms | 6,249 ms | 25,070 ms | 52.7% |
| Early write + close | 977 ms | 70 ms | 280 ms | 59.2% |
| Read and close | 920 ms | 7 ms | 28 ms | 59.8% |
| Stateless claims | 914 ms | 0 ms | 0 ms | 59.6% |
The bad policy has the lowest average fleet utilization because the private queue extends the observation window. That is the point: a normal aggregate can coexist with an awful per-session tail.
These are analytical results, not timings captured from PHP-FPM or a production session backend. Use the lab to form a hypothesis, then validate it with real request and session telemetry.
No framework, Composer dependency, database, or asset build is required.
make check
make runOpen http://127.0.0.1:8080.
Or run the non-root container:
docker build -t session-serialization-lab .
docker run --rm -p 8080:8080 session-serialization-labThe container uses PHP 8.5.8 on Alpine, runs as 10001:10001, and starts four
CLI server workers. PHP’s built-in server is used only to serve this
dependency-free educational lab.
The browser calls:
curl 'http://127.0.0.1:8080/api/simulate?tabs=8&workers=24&downstream_ms=640'Integer query parameters (the API clamps each value to the model’s safe range):
tabsworkerswork_msdownstream_mssession_read_mssession_write_msarrival_spread_msbaseline_utilization_percentburst_secondseed
Print the default result without starting a server:
php bin/simulate.php --jsonDo not record a raw session ID or cookie. Derive a stable, keyed, one-way fingerprint with an operationally managed secret, and use that privacy-safe value only for the correlation window you need.
Useful fields:
session.key_hash
session.lock.wait_ms
session.lock.held_ms
session.lock.release_reason
http.request.session_siblings
worker.blocked_reason
worker.queue_ms
user_journey.p99_ms
session.write.after_close
Suggested investigation:
- Group concurrent requests by the privacy-safe session fingerprint.
- Split worker admission, session acquisition, session ownership, application work, downstream calls, and response time into separate spans.
- Find application or downstream spans nested inside session ownership.
- Compare the affected session’s tail with the fleet-wide endpoint percentile.
- Verify that an early close does not produce attempted writes after release.
The instrumentation boundary is the lesson. A session_start span without
separate wait and held durations cannot tell you whether the handler was slow
or whether another request owned it.
Requests arrive in a deterministic burst and acquire the earliest available worker. A locking policy then waits for exclusive session ownership. That wait continues to occupy the assigned worker. The selected policy determines how long ownership lasts and whether application work runs inside or outside it.
Intentional simplifications:
- requests acquire the session lock fairly in arrival order;
- one session is modeled, with configurable background fleet utilization;
- application and downstream duration vary deterministically by seed;
- worker admission and session acquisition are modeled separately;
- storage, network, garbage collection, and scheduler contention are omitted;
- stateless claim verification is represented by a fixed two-millisecond cost;
- session-write correctness is described as a policy constraint, not proven by this performance model.
- PHP
session_write_close()manual - PHP session management and locking guidance
- PHP session basic usage and file-lock behavior
- PHP connection handling
- PHP 8.5 release
- PHP 8.5 for the deterministic model, JSON API, router, and tests
- semantic HTML, modern CSS, vanilla JavaScript, and Canvas 2D
- no framework, Composer package, database, or remote browser asset
- security headers, strict input normalization, and deterministic fixtures
- non-root Alpine container and two-lane GitHub Actions CI