Fix re-login hang on auth page after logout#5535
Conversation
…tate) MatrixService is a singleton whose client-ready barrier (#clientReadyDeferred) was only ever fulfilled once, at boot in loadSDK(). Logout stays in-app (a router transition, not a page reload) and its finally runs resetState(), which recreates the client synchronously but then replaced #clientReadyDeferred with a fresh, never-fulfilled Deferred. The next login awaits that deferred in createRealmSession(), so it never resolves and the auth page hangs. Fulfill the freshly created deferred when _client exists, restoring the invariant "the deferred is fulfilled whenever _client exists" while keeping the memory-cleanup intent of a stale-reference-free deferred. Add an integration regression test covering re-login after a resetState(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files ± 0 1 suites ±0 5m 21s ⏱️ - 2h 54m 11s Results for commit c7f5e4a. ± Comparison against earlier commit f9f2ee2. Realm Server Test Results 1 files ± 0 1 suites ±0 11m 48s ⏱️ - 5m 19s Results for commit c7f5e4a. ± Comparison against earlier commit f9f2ee2. |
resetState() now leaves the client-ready barrier untouched instead of recreating and immediately fulfilling it — a once-fulfilled deferred stays fulfilled, and the reset recreates the client synchronously whenever the SDK is loaded, so the two are equivalent. The new matrix E2E test covers the full in-app logout -> login-form -> re-login flow that the host integration test can't: logout is a router transition, not a page reload, so the second login runs against the client that resetState() recreated. The test waits for logout's transition to index-root before filling the form, since that transition remounts the login form and would wipe an earlier fill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host centralized session teardown (ResetService.resetAll fanning out resetState to ~30 services) but had no symmetric re-initialization. Because logout is an in-app router transition (no page reload), anything torn down in resetState whose re-establishment lived in a one-shot site (constructor, boot) stayed broken after an in-app logout -> re-login. Evolve ResetService into SessionService: a single tracked isAuthenticated authority (written only by MatrixService), symmetric notifySessionStarted / notifySessionEnded broadcasts, replay-on-register so sessionStarted runs exactly once per participant per session regardless of construction timing, and per-participant error isolation on both edges. Fixes two re-login bugs that were misclassifications under the two-scope model: - AI assistant panel spun forever: loadRoomsTask was armed only in the constructor / openPanel click. It now re-arms via sessionStarted -> ensureRoomsLoaded. - Billing push updates went silently dead: realm-server.resetState wiped the app-scoped eventSubscribers map. That wipe is removed; billing repopulates its session-scoped data via sessionStarted. Also reclassifies matrix-sdk-loader (app-scoped SDK, no longer a participant) and documents message-service's subscriber wipe. MatrixService is no longer a participant (it orchestrates the broadcasts), ending the double-reset on logout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two regressions from the session-lifecycle refactor, both in the AI panel: - The panel constructor registered with SessionService before calling resetState(). register() can synchronously replay sessionStarted(), which arms loadRoomsTask; resetState() then cancelled it. Reorder so resetState() runs first and the replayed arm survives — this is what enters a room when the panel renders already-open. - openPanel() went through ensureRoomsLoaded(), whose "leave the current room alone" guard is only meant for the passive session-start hook. On an explicit open it must always re-run enterRoomInitially so the panel reselects the right room (the persisted room may have changed while closed). Restore the unconditional loadRoomsTask.perform(). Also move the Bug 3 subscriber-preservation regression test from a bare unit test to an integration test: the realm-server constructor's fire-and-forget fetchCatalogRealms() rejects without the virtual network that the mock-matrix harness wires up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The <Room> component's constructor hydrated persisted optimistic sends synchronously, which writes the room's tracked `_events`. Once a room is entered earlier in boot (the session-start hook now loads rooms during the route model rather than at panel-component construction), that write lands in the same render computation that already read `_events` in the parent panel, tripping Ember's backtracking assertion. Schedule the hydration in `afterRender` (guarded against teardown) so the write happens in a separate computation. Behavior is unchanged — the failed-bubble / retry-alert state still appears once rendering settles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rors in tests - BillingService.loadSubscriptionData() coalesces concurrent callers into a single in-flight /_user fetch, so the session-start hook and component mounts no longer stack duplicate requests at boot - SessionService rethrows participant errors under isTesting() after the full broadcast completes: production keeps per-participant isolation, while a throwing resetState() in tests fails the responsible test instead of silently leaking state into a later one - Document that session participants must be app-lifetime singletons (no unregister exists) and that all isAuthenticated writes are driven by MatrixService Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
loadSubscriptionData() always fetches fresh again, so callers reacting to a server-side change (billing-notification pushes, out-of-credits reloads) never get satisfied by a response that predates the change. The boot-time dedupe moves to initializeSubscriptionData(), which coalesces the session-start hook and <WithSubscriptionData/> mounts onto one in-flight /_user fetch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coalescing added for a presumed boot double-fetch caused a regression: <WithSubscriptionData/> only mounts inside the profile popover / settings modal (both closed at boot), so there was never a boot double-fetch to dedupe. Routing its mount through initializeSubscriptionData() made it early-return on already-present data, so the popover stopped re-fetching on open and displayed stale credits after a billing-notification push (account-popover acceptance test). Restore origin/main behavior: loadSubscriptionData() always fetches fresh (pushes, error-reloads, and every popover mount observe current server state), initializeSubscriptionData() is the simple present-or-load guard. The only net change vs main stays the additive sessionStarted() eager repopulate on re-login. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…teardown, async participant-error surfacing Addresses review findings on the SessionService refactor: - start() now broadcasts notifySessionStarted() only on the not-authenticated -> authenticated edge. start() re-runs on an already established session (the /connect route runs it every visit); the old unconditional broadcast re-fired every participant's sessionStarted() with no intervening session end, breaking the "exactly once per established session" contract and duplicating re-arm work. - logout() tears down MatrixService's own session state synchronously, before the awaited network logout, instead of deferring it to the finally. Deferring left a window where an in-flight /sync response could run against already-reset participants, and where a re-login completing during the await would be silently wiped by the deferred reset. The old client is captured before the reset, so the network logout is unaffected. - SessionService re-raises participant errors asynchronously (floated rejection) instead of throwing synchronously. Both broadcast sites call the notify methods inside their own try/catch, which swallowed the synchronous throw and let a broken participant pass in tests. The floated rejection can't be caught by the callers and is failed by the harness's unhandledrejection hook. Exposed via an overridable seam so unit tests observe the surfaced errors without the rejection failing them. - Broadcast loops iterate a snapshot of participants so a sessionStarted() that lazily first-injects another participant can't cause a double invocation in one broadcast. - setupRenderingTest resets the AI panel before MatrixService so an in-flight loadRoomsTask is cancelled before resetState() replaces the initial-sync barrier, avoiding a teardown hang. - Document why billing loadSubscriptionData() stays always-fresh and is deliberately not coalesced (coalescing was tried and reverted because it showed stale credits in the profile popover). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f9f2ee2d92
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // an explicit catch: the SessionService broadcast's try/catch only guards | ||
| // synchronous throws, so an unhandled fetch rejection would otherwise | ||
| // escape here. | ||
| this.loadSubscriptionData().catch((e) => { |
There was a problem hiding this comment.
Coalesce the billing load started by session replay
When BillingService is first touched by the index route after matrixService.start(), session.register(this) replays sessionStarted() because session.isAuthenticated is already true. This line starts loadSubscriptionData() fire-and-forget; immediately afterward routes/index.gts calls initializeSubscriptionData() and, since subscriptionData is still null while the first request is in flight, issues a second _user request. The same duplicate fetch happens after re-login because resetState() clears the data before the session-start broadcast, so this should share an in-flight/initialized path or avoid the eager load when the route will initialize it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖]
Traced and confirmed — efficiency, not correctness. We're keeping this as-is rather than coalescing. The eager load is the only path that repopulates the cleared subscription data on re-login (where the index route's model() may not re-run), and coalescing was tried and reverted earlier on this branch because routing the profile popover through the present-or-load path showed stale credits after a billing-notification push.
Documented the accepted duplicate at the sessionStarted() call site in c7f5e4a so the decision is visible here rather than only on loadSubscriptionData().
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖]
Review lens: I focused on the session-lifecycle contract this PR introduces — whether every resetState() teardown has a matching re-establishment, whether the app-scoped vs. session-scoped reclassifications are correct, and whether the new broadcast edges (start-success, logout, replay-on-register) each fire exactly once without racing an in-flight /sync or a re-login. I checked out the head branch and traced each edge through the code.
Bottom line: no blocking issues. The refactor is sound and all three bug fixes are real and verified. Auth-state accounting is consistent (every isAuthenticated write routes through MatrixService, and #clientReadyDeferred correctly survives resetState()), no lingering reset-service references remain anywhere, and the ~20 trivial reset → session renames are behavior-preserving — those services define no sessionStarted(), so replay-on-register is a no-op for them, making the blast radius genuinely small. One minor efficiency nit and two test-coverage follow-ups, all non-blocking.
What lands right (mechanistically):
- Bug 1 — verified:
#clientReadyDeferredis fulfilled exactly once inloadSDK(), andcreateRealmSession()awaits it. Leaving it untouched inresetState()(which recreates_clientsynchronously whenever the SDK is loaded) preserves the "fulfilled whenever a client exists" invariant; the old fresh-Deferredstranded every post-logout await forever. - Bug 3 — verified:
subscribeEvent()has exactly one caller (BillingService's constructor, an app-lifetime singleton), so preservingeventSubscribersacrossresetState()is correct. The integration test driveshandleEvent()after aresetState()and asserts delivery — a real pin, not a shape assertion. - Edge-guarded broadcast — capturing
wasAuthenticatedbeforesetPostLoginCompleted(true)is the correct ordering; a/connectre-run ofstart()on an already-established session correctly no-ops the broadcast. - Synchronous logout teardown — capturing
clientbeforeresetState()keeps the network logout pointed at the old client while closing the window where an in-flight/syncor a re-login-during-await could act on a half-reset session. - The inline comments throughout are unusually good: they explain the why at each seam, which is what makes a refactor this broad reviewable.
On the open Codex thread (billing double-fetch): confirmed real and traced — it's a minor efficiency issue, not correctness. Mechanism plus a narrow fix that avoids the previously-reverted stale-popover regression is in the inline comment on billing-service.ts.
Recommendations (all non-blocking):
- Billing boot double-fetch — see inline on
billing-service.tssessionStarted(). - Add a unit test for the snapshot-iteration double-invocation guard — see inline on
session.tsnotifySessionStarted(). The same test file could also pin the start-success edge guard (assert a secondstart()on an established session does not re-broadcast). - Consider whether the test-only participant-error surfacing (
reraiseParticipantErrorsInTests→ floatedPromise.reject) can misattribute failures: it runs insidenotifySessionEnded()in theafterEachteardown, so a rejection floated there may be observed by the next test'sunhandledrejectionhook rather than the one whoseresetState()actually threw. The loud-failure intent is right; worth confirming the attribution is deterministic. CI is currently green.
Adjacent, out of scope: the ai-assistant-panel-relogin-test drives matrixService.resetState() before session.notifySessionEnded(), the reverse of logout()'s real order (notifySessionEnded() then resetState()). The end state is identical so the test is valid, but the accompanying comment describes the production order. Not asked of this PR.
Generated by Claude Code
| // an explicit catch: the SessionService broadcast's try/catch only guards | ||
| // synchronous throws, so an unhandled fetch rejection would otherwise | ||
| // escape here. | ||
| this.loadSubscriptionData().catch((e) => { |
There was a problem hiding this comment.
[Claude Code 🤖]
Confirmed: a duplicate /_user fetch fires at boot. Verified end-to-end, non-blocking, low severity. This is the Codex P2 lead, traced.
The mechanism. The index route's model() does await this.matrixService.start(); start() fires notifySessionStarted() on the auth edge, which reaches BillingService.sessionStarted() here and kicks off loadSubscriptionData() fire-and-forget (fetch #1). A few lines later the same model() calls this.billingService.initializeSubscriptionData(); subscriptionData is still null because fetch #1 hasn't resolved, so it falls through to a second loadSubscriptionData() (fetch #2). Two GETs to /_user, same server state, last-writer-wins on _subscriptionData — so this is efficiency, not correctness, and it's newly introduced here (before, only the route fetched).
Why the documented anti-coalescing rationale doesn't quite cover this. The comment on loadSubscriptionData() says coalescing was reverted because it made the profile popover show stale credits. But the popover mounts <WithSubscriptionData>, whose constructor calls loadSubscriptionData() directly, not initializeSubscriptionData(). So the boot double-fetch lives entirely on the sessionStarted() + route-initializeSubscriptionData() pair. Coalescing those two (in-flight tracking inside initializeSubscriptionData(), leaving loadSubscriptionData() always-fresh for the popover) would drop the duplicate without reintroducing the stale-popover regression.
Scope. Non-blocking follow-up. Don't just delete the eager load — it's genuinely needed on re-login, where the route model() may not re-run and sessionStarted() is the only thing that repopulates the cleared data. If one extra boot request isn't worth the machinery, leaving it is defensible; flagging so the choice is deliberate rather than incidental.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖]
Agreed on all points, and thanks for tracing the narrower initializeSubscriptionData()-only coalescing — you're right that it would avoid the reverted stale-popover regression (the popover calls loadSubscriptionData() directly). We're still choosing to leave it: one duplicate /_user at boot isn't worth reintroducing the in-flight machinery, and the eager load has to stay for the re-login repopulation path.
Made the choice explicit in c7f5e4a — added a comment at sessionStarted() noting the accepted duplicate and pointing at the revert rationale on loadSubscriptionData().
| // register() and appends to `participants`. Iterating the live array would | ||
| // then reach that late registrant here *and* it already ran via register()'s | ||
| // replay — a double sessionStarted() in one broadcast. | ||
| for (let p of [...this.participants]) { |
There was a problem hiding this comment.
[Claude Code 🤖]
Confirmation + a test-coverage gap on this exact guard. Iterating a snapshot ([...this.participants]) is the right call, and the inline comment explains the mechanism precisely: since isAuthenticated is set true at the top of this method, a participant whose sessionStarted() lazily first-injects another participant service triggers that service's constructor → register(), and register() replays sessionStarted() immediately (because the session is already established). Without the snapshot, the live-array loop would then also reach that late registrant — a double sessionStarted() in one broadcast.
The gap. session-test.ts covers replay-on-register, error isolation on both edges, and isAuthenticated flips — but nothing exercises this double-invocation guard. Reverting to for (let p of this.participants) would pass the entire existing suite while silently re-introducing the double invocation (extra re-arm work, no crash) — exactly the kind of regression that needs a pin.
Concrete test. Register participant A whose sessionStarted() calls session.register(B); after notifySessionStarted(), assert B.startedCount === 1 (not 2). The same shape covers the notifySessionEnded() snapshot on the teardown edge.
Class: follow-up (test coverage), non-blocking.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖]
Added in c7f5e4a. Two unit tests pin the guard on both edges:
notifySessionStarted(): register A whosesessionStarted()callssession.register(B), then assertB.startedCount === 1— the replay only, not a second invocation from the loop.notifySessionEnded(): symmetric shape asserting a participant registered mid-teardown is not reached by the sameresetState()pass.
Reverting either broadcast to a live-array for…of now fails these.
… snapshot-guard + edge-guard tests Non-blocking follow-ups from the approving review: - SessionService buffers participant errors in tests instead of floating a rejection. A floated Promise.reject surfaced non-deterministically — the unhandledrejection could be attributed to the next test rather than the one whose participant threw. The test harness (setup.ts afterEach) now drains the buffer after the teardown broadcast and fails the responsible test deterministically, and it still can't be swallowed by the broadcast callers' try/catch. - Add unit tests pinning the snapshot-iteration guard on both broadcast edges: a participant registered mid-broadcast gets exactly one sessionStarted() (the replay), not a second from the loop; and one registered mid-teardown is not reset by the same notifySessionEnded() pass. Reverting to a live-array loop would silently regress these. - Add an integration test pinning the start-success edge guard: a second start() on an already-authenticated session (the /connect re-run) does not re-broadcast sessionStarted(). - Document at BillingService.sessionStarted() that the cold-boot duplicate /_user fetch (alongside the index route's initializeSubscriptionData) is an accepted cost, not a bug to coalesce: the eager load is what repopulates the cleared data on re-login, and coalescing was tried and reverted because it regressed the profile popover to stale credits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
[Claude Code 🤖] Thanks for the thorough pass. Addressed the follow-ups in c7f5e4a (all non-blocking):
On the adjacent note — |
Problem
After an in-app logout → re-login (logout is a
router.transitionTo, not a page reload), several things break. They share one root cause: the host centralizes session teardown but not session re-initialization.ResetService.resetAll()fannedresetState()out to ~30 services on logout, but there was no symmetric "session started" broadcast — re-initialization lived in one-shot sites (service constructors, boot-onlyloadSDK(), the index route's start guard, user clicks). So anything torn down inresetState()whose re-establishment lived in a one-shot site stayed broken for the life of the singleton after re-login.Three confirmed instances of this family:
#clientReadyDeferred(a boot-only barrier) was recreated unfulfilled byresetState(); the next login'screateRealmSession()awaited it forever.loadRoomsTaskwas armed only in the panel service constructor and theopenPanel()click. Logout cancelled it and clearedcurrentRoomId; nothing re-armed it, soisReadynever became true (perpetual loading indicator).RealmServerService.resetState()wiped theeventSubscribersmap, butBillingServicesubscribes tobilling-notificationonly in its constructor — which never re-runs after an in-app logout.Approach
Classify every piece of host state as app-scoped (page-load → page-unload: SDK bundle, event-binding tables, subscriber wiring) or session-scoped (login → logout: tokens, room caches, subscription data, current room). The enforceable contract:
All three bugs are misclassifications under this model.
ResetServicebecomesSessionService— a single session authority:@tracked isAuthenticated, the single source of truth for "a session is established" (written only byMatrixService, read everywhere;MatrixService.postLoginCompletedfolds into it).notifySessionStarted()/notifySessionEnded()broadcasts.MatrixService.start()fires the former only on the unauthenticated → authenticated edge — so re-runningstart()on an already-established session (e.g. the/connectroute, which runs it on every visit) doesn't re-fire participants;logout()fires the latter (replacingresetAll()).sessionStarted(), so participants need no constructor arming and the hook runs exactly once per participant per session regardless of construction timing.resetState()/sessionStarted()no longer skips the rest of the registry).MatrixServiceis the orchestrator, not a participant, which also ends the double-resetState()it previously ran on logout. On logout it now tears down its own session state synchronously, before the awaited network logout, so an interleaved re-login or a late/syncresponse can't act on a half-reset session.The fixes
resetState()leaves the once-fulfilled#clientReadyDeferredalone rather than recreating it unfulfilled.sessionStarted()→ensureRoomsLoaded(); the remount-dependent constructor arming is removed.openPanel()keeps performingloadRoomsTaskunconditionally (an explicit open must reselect the persisted/latest room);ensureRoomsLoaded()'s "leave the current room alone" guard is only for the passive session-start hook.RealmServerService.resetState()no longer clears the app-scopedeventSubscribers;BillingService.sessionStarted()repopulates its session-scoped data on re-login.Audit / hygiene
matrix-sdk-loaderis removed from the participant registry — the loaded SDK bundle is app-scoped and must survive logout (itsresetState()clearing was a latent bug).message-service's subscriber-map wipe is kept (its callbacks are component/resource-scoped and die with the<Auth/>swap) but documented as a test-environment safety net, distinct from the app-scoped wiring inrealm-server.resetState()implementations are confirmed either explicitly re-driven bystart()or lazily derived, so they need no hook.Testing
session-test.ts(broadcasts reach all participants, error isolation both directions,isAuthenticatedflips, replay semantics);ai-assistant-panel-relogin-test.ts(panel re-enters a room after logout → re-login instead of spinning);realm-server-subscriber-test.ts(subscribers surviveresetState()). Bug 1'smatrix-service-relogin-test.tsremains.pnpm linton@cardstack/hostis green (lint:js,lint:types/glint,lint:hbs).Out of scope
Reload-on-logout (
window.location.replace('/')instead of an in-app transition, matching ember-simple-auth's default) is a separate product decision. The session registry here is needed regardless (tests, the failed-bootstart() → logout()path, any future soft-logout).🤖 Generated with Claude Code