Fix sporadic AssertionError when user storage is pruned during an in-flight request - #6146
Conversation
…flight request prune_user_storage() removes a session's user storage when the session has no connected WebSocket client and its storage is older than 10 s. If that 10 s timer tick fires while an HTTP request for the session is still being handled, the storage is gone before the handler reads app.storage.user, raising the assert in Storage.user and returning HTTP 500. Affects HTTP-only clients (e.g. AI agents), revisits from a closed tab, and long-lived sessions polled over HTTP. Track in-flight requests per session in Storage._active_request_sessions (incremented in RequestTrackingMiddleware, decremented in a finally) and skip those sessions in prune_user_storage, so storage created for a request in flight is not pruned out from under it. Closes zauberzeug#6145 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Heads-up @falkoschindler — putting this on your radar since it came in via Slack (joko’s report). It fixes the Verified with an MRE + a regression test that fails without the fix and passes with it; Codex-reviewed; all draft-eligible CI green (full browser suite still gated behind draft). Deliberately kept as a draft — no rush to review or merge, just making sure you know it exists. |
Restore the original control flow in prune_user_storage (nested condition instead of a continue guard) to minimize the diff against main, and bind the active-request Counter once in RequestTrackingMiddleware so the increment and the finally-decrement visibly share the same object and three pylint disables can go away. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract the hardcoded 10 s prune timing into USER_STORAGE_PRUNE_INTERVAL so a test can shrink it, and replace the three implementation-coupled tests (fake requests, manual prune calls, internal counters) with a single Screen test: a real FastAPI endpoint stays in flight over several prune intervals and must still read app.storage.user. Fails with the original HTTP 500 AssertionError when the in-flight guard is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
falkoschindler
left a comment
There was a problem hiding this comment.
Thanks for tracking this down and for the clean fix, @evnchn! The approach is exactly right: the middleware's increment and the prune's check-and-pop both run synchronously between await points on the same loop, so a pinned session can never be popped mid-request, the finally guarantees cleanup even when the handler raises, and the Counter correctly keeps a session pinned until the last of several overlapping requests completes.
While reviewing, I pushed two commits directly to the branch — please have a look whether you agree:
- 6c9bc47 restores the original control flow in
prune_user_storage()(nested condition instead of acontinueguard, which minimizes the diff against main) and binds the counter once inRequestTrackingMiddleware.dispatch, so the increment and thefinally-decrement visibly share the same object and three pylint disables could go. - dfe367e replaces the three white-box tests with a single end-to-end Screen test: the hardcoded 10 s is extracted into
USER_STORAGE_PRUNE_INTERVALso the test can shrink it to 0.1 s, and a real FastAPI endpoint stays in flight over several prune ticks and must still readapp.storage.user. It reproduces the original HTTP 500AssertionErrorwhen the in-flight guard is removed and passes with it.
Two questions:
- This PR is still in draft mode — anything missing from your side, or can we mark it ready for review?
- Are you fine with dropping the two edge-case tests (marker cleared when the handler raises, concurrent same-session requests) in favor of the one readable end-to-end test? They pinned implementation details (fake requests, private counters), which tend to get rewritten together with the implementation and then don't guard much. If you agree, the PR description needs a small update to match (one test instead of three) — feel free to adjust it, otherwise I'm happy to.
|
@falkoschindler i have a shower though: am sure we have other things which track which requests are in flight? Maybe we can reuse those. Unless I mis-remembered... After that I have no issues, including the simplified test and the other commits. |
|
@falkoschindler — chased my own shower-thought before handing it to you. TL;DR: The three things NiceGUI already has that touch "a request" each miss for a different structural reason:
So The three existing mechanisms, and why each can't be the reuse target
Two hard constraints force a dedicated structure rather than reuse:
Undrafting — good to go on my end; the simplified test and the other commits all still stand. |
Motivation
Fixes #6145.
app.storage.usersporadically raisesAssertionError: user storage for <id> should be created before accessing it(→ HTTP 500).prune_user_storage()runs on a 10 s timer and removes any session that has no connected WebSocket client and whose user-storage is older than 10 s. If that tick fires while an HTTP request for the session is still being handled, the storageRequestTrackingMiddlewarecreated is removed before the handler readsapp.storage.user, and the assert atnicegui/storage.py:137fires.It hits sessions with no live WS client: HTTP-only clients (e.g. AI agents / scripts), revisits from a closed tab, and long-lived sessions polled over HTTP. It self-heals on the next request, so it shows up as intermittent 500s.
Implementation
Track the number of in-flight HTTP requests per session id in
Storage._active_request_sessions(acollections.Counter):RequestTrackingMiddleware.dispatchincrements the counter for the session before handling and decrements it in afinally(so it is cleared even if the handler raises).prune_user_storage()skips any session id currently in that counter, so storage backing a request in flight is never pruned out from under it.A
Counter(not aset) so that two concurrent requests for the same session each pin it, and the session is only unpinned once the last one completes.Why not lazy-re-create in the
userproperty (the pre-#4074 behavior)? Since #4074, user-storage creation is async (await ... initialize(), needed for the Redis backend), while theuserproperty is synchronous and cannotawait. Creation therefore lives in the async middleware; keeping the storage alive for the duration of the request is the change that fits that constraint.Scope: this covers requests that read
app.storage.userduring handler execution — i.e. all@ui.page/ FastAPI handlers, which build their response eagerly. It does not extend the guard to a user-suppliedStreamingResponsewhose async generator readsapp.storage.userlazily after the handler returns (Starlette streams that body after the middleware'sdispatchreturns); that path is outside the reported issue and unchanged by this PR.Verification — works/broken differential + local gates
The added test
test_user_storage_survives_prune_during_requestdrives the realRequestTrackingMiddleware.dispatch+ realprune_user_storage()+ realapp.storage.user, firing a prune tick while the request is in flight:AssertionErroratstorage.py:137.Two more tests cover Codex-review edge cases: the in-flight marker is cleared when the handler raises (no leak), and two concurrent same-session requests both pin the session against pruning.
Local gates on the branch:
Progress