Sessions implement their own little session-local key-value store as a serialized blob. The blob is read at the start of the request and written at the end of the request, leaving an enormous critical section for concurrency issues. If one request writes one key, and a different concurrent request writes another key, whichever writes the blob last will win and the other key will disappear.
Session::set() says:
public function set( $key, $value ) { $data = &$this->backend->getData(); if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) { $data[$key] = $value; $this->backend->dirty(); } }
For example, if you log in, and then open two edit pages in two different tabs sufficiently quickly, you will always get a CSRF error when submitting one of them. I have reproduced this by inserting a sleep at the end of SessionBackend::__construct().
Really it would be better to have a two-level key/value store. Session::set() would just write to the backend immediately with a key that is composed of the session ID and $key.
There are some groups of keys that should probably be written together, for example wsUserID and wsUserName. These could be in a single blob written with a single Session::set() call.
Session::get() would still read and validate the session metadata before fetching a subkey, and so would still be able to implement O(1) invalidation of subkeys of a session. We could also use a session store that supports bulk deletions.
(I've talked about this problem a few times over the years, but I don't think I've properly documented it before. Related discussions are at T267270 and T270225.)