Skip to content

Fix sporadic AssertionError when user storage is pruned during an in-flight request - #6146

Merged
falkoschindler merged 3 commits into
zauberzeug:mainfrom
evnchn:fix/6145-storage-prune-race
Jul 8, 2026
Merged

Fix sporadic AssertionError when user storage is pruned during an in-flight request#6146
falkoschindler merged 3 commits into
zauberzeug:mainfrom
evnchn:fix/6145-storage-prune-race

Conversation

@evnchn

@evnchn evnchn commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Fixes #6145.

app.storage.user sporadically raises AssertionError: 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 storage RequestTrackingMiddleware created is removed before the handler reads app.storage.user, and the assert at nicegui/storage.py:137 fires.

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 (a collections.Counter):

  • RequestTrackingMiddleware.dispatch increments the counter for the session before handling and decrements it in a finally (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 a set) 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 user property (the pre-#4074 behavior)? Since #4074, user-storage creation is async (await ... initialize(), needed for the Redis backend), while the user property is synchronous and cannot await. 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.user during handler execution — i.e. all @ui.page / FastAPI handlers, which build their response eagerly. It does not extend the guard to a user-supplied StreamingResponse whose async generator reads app.storage.user lazily after the handler returns (Starlette streams that body after the middleware's dispatch returns); 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_request drives the real RequestTrackingMiddleware.dispatch + real prune_user_storage() + real app.storage.user, firing a prune tick while the request is in flight:

  • Without the fix: fails with AssertionError at storage.py:137.
  • With the fix: passes (request reads its storage; the same idle aged session is still pruned once the request completes — control assertion).

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:

pre-commit run --files ...  → Passed
mypy ./nicegui              → Success: no issues found in 243 source files
pylint ./nicegui            → 10.00/10
pytest (3 new tests)        → 3 passed

Progress

  • The PR title is a short phrase starting with a verb.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added.
  • Documentation has been added/updated or is not necessary.
  • No breaking changes to the public API.

…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>
@evnchn

evnchn commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up @falkoschindler — putting this on your radar since it came in via Slack (joko’s report). It fixes the app.storage.user prune race in #6145: prune_user_storage() can remove a session’s user storage mid-request for clients with no connected WebSocket (HTTP-only / AI agents, closed-tab revisits), tripping the assert at storage.py:137 → sporadic 500.

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.

@falkoschindler falkoschindler added the bug Type/scope: Incorrect behavior in existing functionality label Jul 8, 2026
@falkoschindler
falkoschindler self-requested a review July 8, 2026 11:24
@falkoschindler falkoschindler self-assigned this Jul 8, 2026
@falkoschindler falkoschindler added the review Status: PR is open and needs review label Jul 8, 2026
@falkoschindler falkoschindler added this to the 3.15 milestone Jul 8, 2026
falkoschindler and others added 2 commits July 8, 2026 13:56
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 falkoschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a continue guard, which minimizes the diff against main) and binds the counter once in RequestTrackingMiddleware.dispatch, so the increment and the finally-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_INTERVAL so the test can shrink it to 0.1 s, and a real FastAPI endpoint stays in flight over several prune ticks and must still read app.storage.user. It reproduces the original HTTP 500 AssertionError when the in-flight guard is removed and passes with it.

Two questions:

  1. This PR is still in draft mode — anything missing from your side, or can we mark it ready for review?
  2. 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.

@evnchn

evnchn commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

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

@evnchn

evnchn commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

@falkoschindler — chased my own shower-thought before handing it to you.

TL;DR:
No, there's nothing existing to reuse — the Counter is the minimal form of the very thing I was half-remembering. Closing the question; undrafting, good to go.

The three things NiceGUI already has that touch "a request" each miss for a different structural reason:

  • request_contextvar is context-local — the 10 s prune tick runs in a separate task, so it can't see it (that's the whole point of a ContextVar).
  • request.state.responded genuinely is an in-flight flag, but it lives on each Request object and nothing aggregates it by session.
  • Client.instances (which prune_user_storage() already consults) only covers page/WS clients — plain FastAPI handlers create no Client, and reading app.storage.user from one of those is exactly the HTTP-only case the fix targets.

So _active_request_sessions is that idea aggregated by session: request.state.responded is the same "in-flight" notion at per-request granularity; the Counter is it rolled up to a per-session registry that the prune task can actually see.

The three existing mechanisms, and why each can't be the reuse target
Existing mechanism What it is Why prune_user_storage() can't reuse it
request_contextvar (storage.py:22) ContextVar[Request], set per request in the middleware Context-local by design; the prune tick runs in a different task → .get() returns None. A ContextVar can never be a cross-context "who's in flight" registry.
request.state.responded (storage.py:36/47/125) Per-request bool, flipped FalseTrue around call_next A real in-flight marker — but it's per-Request, and there's no registry of live requests to scan. Only read inside storage.py; nothing aggregates it by session.
Client.instances (app.py:420) Page-client map; prune already derives client_session_ids from it for the WS-connected case Only covers clients with a page/WS lifecycle. @app.get/FastAPI handlers create no Client, so this structurally misses the HTTP-only path the fix targets.

Two hard constraints force a dedicated structure rather than reuse:

  1. Must be visible to the prune task → rules out request_contextvar (context-local) and request.state (per-object).
  2. Must cover every request type, not just page-clients → rules out Client.instances.

_active_request_sessions (a Counter, so two concurrent same-session requests each pin it and it only unpins on the last completion) is the smallest structure satisfying both.

Undrafting — good to go on my end; the simplified test and the other commits all still stand.

@evnchn
evnchn marked this pull request as ready for review July 8, 2026 14:43
@falkoschindler
falkoschindler added this pull request to the merge queue Jul 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 8, 2026
@falkoschindler
falkoschindler added this pull request to the merge queue Jul 8, 2026
Merged via the queue into zauberzeug:main with commit ae53192 Jul 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

prune_user_storage() can delete user storage mid-request, causing sporadic HTTP 500

2 participants