Repository navigation
feat: Rewrite sessions as a lazy, store-based layer - #398
Conversation
|
@abdulmajidcse check now |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy Nice direction with the lazy store-based sessions. A few things to fix before merge:
Blocking
- Logout can fail (
Session.php:324):expiredCookie()skips the Secure logic frommakeCookie(). Withsame_site=none, Chrome rejects the deletion cookie, so the user stays logged in. - A session for every visitor (
CsrfTokenMiddleware.php:133):token()on every GET makes the session non-blank, so every anonymous or crawler hit writes a session file. - No locking (
FileStore.php:83): concurrent requests on one session overwrite each other (lost cart items, lost flashes). - JSON in the cookie driver (
CookieStore.php:79): objects come back as arrays, and binary data throws (500). - Size check before encoding (
CookieStore.php:85): the cookie is measured before rawurlencode, so it can still go over 4KB and get dropped.
Minor
- In the HttpException path, a failed
commitSession()turns a 404 into a 500 (Application.php:1562). - Stateless paths never load
_token, so every form POST to them gets a 419. setId()doesn't destroy the old session.- Public session classes are removed in 4.x with no deprecation shim.
- The session is serialized twice per commit, and the sweep runs before the response is sent.
…ookie, lazy CSRF cookie)
|
@abdulmajidcse good catch, please check now |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy Thanks for the quick fixes. The logout cookie, cookie size, JSON safety, 404 path, setId(), double serialize and post-send sweep all look good, and the full suite passes on 86817d8 (5689 tests, 0 failures).
Two blocking issues remain. I reproduced both with failing tests:
1. Concurrent list appends still lose data (mergeChanges()): lists are merged index by index, so two requests that each append to the same list write the same index.
// stored cart = ['x']; A appends 'apple', B appends 'pear', both commit
// result: ["x","pear"], so 'apple' is lostSuggestion: when the values are lists (array_is_list()), replace them whole instead of merging.
2. A logged-out session comes back (mergeConcurrentChanges()): if a request is still running on session X when logout/invalidate() destroys X, its commit finds no record, skips the merge and writes X back, then re-sends the X cookie, so the user is logged in again.
Suggestion: inside synchronized(), if storedId was set but the record is now gone, don't write it again.
Non-blocking
- The lazy XSRF-TOKEN cookie means SPA/axios clients that never load a form page get a 419 on their first POST. They need a way to get the token, or an upgrade note.
- The shard lock gives up after 2s and saves without the lock, and nothing logs that.
|
@abdulmajidcse for 419 issue, we can use relaxable path and rest I will check |
|
@techmahedy Relaxable paths are fine for stateless/webhook routes. For session-authenticated SPA routes, excluding them from CSRF opens a CSRF hole, so they still need a way to get the XSRF-TOKEN (e.g. a |
|
@abdulmajidcse you are right in this case but till now doppar doesn't support spa natively. |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy Friendly reminder: the two blocking issues from my last review are still open on 86817d8. I re-ran everything today. The full suite passes (5689 tests, 0 failures), but both repro tests still fail:
- Concurrent list appends lose data (
Session.php:289,mergeChanges()): storedcart = ['x'], one request appendsappleand another appendspear, and the result is["x","pear"]. Fix: replace lists whole (array_is_list()) instead of merging by index. - Logged-out session is resurrected (
Session.php:274,mergeConcurrentChanges()): a request that started before logout writes the destroyed session back and re-sends the old cookie. Fix: insidesynchronized(), ifstoredIdwas set but the record is gone, don't write it again.
Agreed that the SPA/XSRF point can wait since Doppar doesn't support SPAs natively yet. Happy to re-test as soon as you push.
…logged-out session
|
@abdulmajidcse thanks, both are fixed and reproduced with failing tests first.
Non-blocking: the lock timeout and lock failures are logged, and |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy CI passes, but there are two session bugs that should be fixed before merge. Both come from the automatic rotation in load().
1. Logout can be undone. In mergeConcurrentChanges(), when the stored record is gone and id !== storedId, the session is saved anyway. After an automatic rotation the new id still holds the old session's data, including the logged-in user's key. So if a logout overlaps with a request that rotates the session, that request writes the login back and sends its cookie, and the user is signed in again.
2. Parallel requests split the session. When several requests load the same session after the rotation threshold, each calls regenerate(true) and gets its own new id. The first one to save destroys the old id, and the others each write a separate copy under their own id, so some changes are lost (flash messages, cart updates). A request that still sends the old cookie then finds no session and receives an expired cookie, which logs the user out.
Fixing #1 alone (dropping the write) makes #2 worse, so they need one fix. Two options:
- On automatic rotation, keep the old id for a short time (about 30–60s) with a pointer to the new one, so overlapping requests follow it.
- Do the rotation inside
synchronized()and mark the old record as rotated.
Smaller items, fine for a follow-up PR:
FileStore::ID_PATTERNuses$, so ids ending in\npass validation. Use\zor/D.lottery => nullor[]runs the sweep on every request instead of never.- Please add a CHANGELOG note that the XSRF-TOKEN cookie is now sent only when the session already has a token, and that session writes are not saved when a request ends with an uncaught exception.
… overlapping requests keep one session and a logout stays logged out
|
@abdulmajidcse thanks, both were real and come from the same root cause, so one fix covers them. Automatic rotation no longer happens in load(). It runs at save time inside synchronized(), and the old id becomes a pointer to the new one for 60s. Overlapping requests follow it and end up on one id, and a request still sending the old cookie follows it too instead of getting an expired cookie. A missing record is never written back. A logout or destroy() now ends the whole chain, so it can't be undone by a rotation in either order. An explicit regenerate() (login) leaves no pointer, so fixation protection is unchanged. I reproduced both with failing tests first, and also with real parallel processes: on the previous commit, 12 parallel requests split into 12 sessions (1 of 12 changes kept) and a logout was resurrected in 39 of 40 races. Now 12 of 12 are kept and there are 0 resurrections. Also fixed: ID_PATTERN now uses \z, and lottery => null/[] disables the sweep. |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy The rotation fix looks good, and CI passes on e9a49ec. The race from my last review didn't come up again. Two bugs should be fixed before merge. I reproduced both with failing tests on e9a49ec.
Blocking
1. A logout is lost if the request then throws (Application.php:1581). Only an HttpResponseException that carries a response commits the session on this path. My last review treated dropped writes as something for the CHANGELOG, but a logout shouldn't be dropped with them.
$session->forget('user');
$session->invalidate();
throw new RuntimeException('boom');
// the stored session still exists, with its data, so the old cookie still worksAuthenticate.php:370 is the real logout path, so an exception after that call, such as one from a listener or a DB write, leaves the user logged in. Suggestion: on this path, still apply a pending destroy or invalidate(), even if ordinary writes are dropped.
2. A blank commit deletes a concurrent save (Session.php:288). commitBlank() destroys the stored record without the lock and merge that the non-blank path uses.
// stored: ['url.intended' => '/x']; A and B both load it
$a->forget('url.intended');
$b->put('user', 7);
$b->commit($response);
$a->commit($response);
// the record is gone, so B's login is lost, and A's response expires the cookieSuggestion: inside synchronized(), only destroy if the record hasn't changed since this request loaded it. Otherwise merge as usual.
Non-blocking
Reproduced with failing tests:
- Session writes during a streamed response are lost (
Application.php:1552). The commit runs beforesend(), so aput()inside aStreamedResponsecallback is never saved. Fine if that's intended, but please document it. regenerate(true)aftersetId()leaves the session in use valid (Session.php:1002).previousId ??=keeps the first id, so after load A,setId(B),regenerate(), B survives with its data. Nothing in the framework callssetId(), so this only affects app code.setId()keeps the old id after following a rotation pointer (Session.php:1061).getId()returns the pointer's id, not the live one. It should use$record['id']. This also only affects app code.
From reading the code, not reproduced:
- Session changes in error views aren't saved (
Application.php:1565). The commit runs beforedispatchHttpException()renders the view, so a#csrftoken first generated in a 403 page is never stored, and that form should get a 419. - Validation still creates a session (
Rule.php:62).sanitize()flashes the input on every run, even when validation passes. Cookieless requests to routes outsidestatelesstherefore still write a session file and send a cookie, and the input stays on the server. Flashing only on failure would fix it. - Failed session saves aren't logged (
Application.php:1566,1589). Keeping the original exception is right, but areport()or log line in those catch blocks would make a lost save visible. The lock timeout is already logged. - Two wildcard matchers (
Session.php:189).isStateless()andRequestParser::is()match paths differently, so the same pattern can behave differently forstatelessand relaxable paths. One shared helper would avoid that.
The CHANGELOG note from my last review isn't in this PR yet, unless you plan to add it at release. Happy to re-test as soon as you push.
…d session instead of deleting it, save the session after the error page renders
|
@abdulmajidcse i tried to fix all the issues you mentioned, please check now |
abdulmajidcse
left a comment
There was a problem hiding this comment.
@techmahedy Thank you for the fixes! I re-checked 99373d5: the logout and blank-commit bugs are fixed, tests and CI pass, and parallel-request runs held up. This looks ready to merge from my side, so I will leave the merge to you.
@abdulmajidcse it was a great review; thumbs up |
Pull Request Checklist
Description
Replaces the native PHP session integration (
session_start()on every request,$_SESSIONby reference) with a request-scoped, lazy session backed by pluggable stores. This resolves the session problems raised in an external review of the framework and fixes two related defects found while verifying the change.Problem
An independent review reported the following, and each was traced to the code:
gc_probabilitywas never set, so garbage collection was disabled by the PHP defaultsession_write_close()was never calledcookiedriver still created server-side filessession_set_save_handler()was never called, so PHP fell back to its default file handlerWhat changed
Session core
Support\Sessionis lazy. Creating it reads nothing, and the stored session is looked up on first use. A session with no data is never persisted, and no cookie is sent for it.Application::dispatch()just before the response is sent, instead of at PHP shutdown.setId()is ignored.csrf_token()andtoken()create the token on first use.Stores (
Phaseolies\Session\Stores, contract:SessionStoreInterface)FileStore: 256 shard directories by ID prefix, atomic temp-file-and-rename writes,0600files, and a bounded sweep.CookieStore: AES-256-GCM through the existingEncryptionservice. It rejects the legacy unauthenticated format, caps the cookie at 4,000 bytes, and names the largest keys when the limit is exceeded.ArrayStore: in-memory, for tests.SessionManager::extend()registers custom drivers.Opting out and cleanup
statelessconfig (path patterns such asapi/*) andStatelessSessionmiddleware. A stateless request keeps a working in-memory session, stores nothing, and sends no cookie.lotteryconfig for a bounded, probabilistic sweep of expired sessions.session:gccommand.session:clearnow delegates to the active store.Cross-platform (Linux, macOS, Windows)
glob(), so storage paths containing[ ] { } * ?work.rename()retries and falls back to an in-place write, for transient Windows file locks.destroy()empties the file if it cannot be deleted, so a destroyed session can never remain valid..and spaces to_in incoming cookie names.clearstatcache()is called before time-based checks, for long-lived workers.Also: the CSRF check now reads the stored token instead of creating one, so a forged request without a session no longer starts a session.
Breaking changes
ConfigSession,Session\Handlers\*,AbstractSessionHandler, andSessionHandlerInterface. UseSessionManagerandSessionStoreInterface.$_SESSIONor callssession_start()must use$request->session(),session(), or theSessionfacade.sess_*files are removed byphp pool session:gc.setId()ignores values that are not well formed session IDs.cookiedriver now actually stores the session in the cookie (about 4 KB total, JSON-serializable values only).Checklist
4.x/session.md))