From 12d188b2dd824cdb6e122dbfb1241a09a572d97a Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 17 Jul 2026 15:26:17 +0700 Subject: [PATCH 1/2] Fix re-login hang after logout (fulfill clientReadyDeferred on resetState) 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) --- packages/host/app/services/matrix-service.ts | 9 +++ .../matrix-service-relogin-test.ts | 75 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 packages/host/tests/integration/matrix-service-relogin-test.ts diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 329faef5cf..6c8c7d18cf 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -2100,7 +2100,16 @@ export default class MatrixService extends Service { this._systemCard = undefined; this._systemCardLoadFailed = false; this.startedAtTs = -1; + // The client is recreated synchronously above, so re-establish the + // client-ready invariant here: `loadSDK()` (the only other place that + // fulfills this) runs once at boot and never re-runs, so a fresh deferred + // left pending would strand every post-logout `createRealmSession()` await + // (CS-12207). Guard on `_client` since `createClient` is a no-op when the + // SDK hasn't loaded yet. this.#clientReadyDeferred = new Deferred(); + if (this._client) { + this.#clientReadyDeferred.fulfill(); + } } private teardownClient() { diff --git a/packages/host/tests/integration/matrix-service-relogin-test.ts b/packages/host/tests/integration/matrix-service-relogin-test.ts new file mode 100644 index 0000000000..2891add057 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-relogin-test.ts @@ -0,0 +1,75 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { rawTimeout } from 'ember-concurrency'; +import { module, test } from 'qunit'; + +import { baseRealm } from '@cardstack/runtime-common'; + +import type MatrixService from '@cardstack/host/services/matrix-service'; + +import { + testRealmURL, + setupIntegrationTestRealm, + setupLocalIndexing, +} from '../helpers'; + +import { setupBaseRealm } from '../helpers/base-realm'; + +import { setupMockMatrix } from '../helpers/mock-matrix'; + +import { setupRenderingTest } from '../helpers/setup'; + +// CS-12207: `MatrixService` is a singleton whose client-ready barrier +// (`#clientReadyDeferred`) is only fulfilled once, at boot, inside `loadSDK()`. +// Logout stays in-app (a router transition, not a page reload) and its +// `finally` runs `resetState()`, which recreates the client synchronously but +// then replaces `#clientReadyDeferred` with a fresh, never-fulfilled deferred. +// The next login awaits that deferred in `createRealmSession()`, so it hangs +// forever and the auth page never progresses. This test locks down the +// invariant that `createRealmSession()` still resolves after a `resetState()`. +module( + 'Integration | matrix-service | re-login after logout', + function (hooks) { + setupRenderingTest(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + setupBaseRealm(hooks); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + test('createRealmSession resolves after resetState (logout) instead of hanging', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + await matrixService.ready; + + // `logout()` recreates the client and installs a fresh + // `#clientReadyDeferred` via `resetState()` in its `finally`. Drive that + // reset directly so the test doesn't depend on the full logout network + // roundtrip / router transition. + matrixService.resetState(); + + const TIMEOUT = Symbol('timeout'); + let result = await Promise.race([ + matrixService.createRealmSession(new URL(testRealmURL)), + rawTimeout(2000).then(() => TIMEOUT), + ]); + + assert.notStrictEqual( + result, + TIMEOUT, + 'createRealmSession settles after resetState — the re-created clientReadyDeferred is fulfilled rather than left pending', + ); + }); + }, +); From 730b3dfbdc6b4cc13f4ec21da5ea08eee908a465 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 17 Jul 2026 20:53:59 +0700 Subject: [PATCH 2/2] Simplify logout fix and add E2E re-login coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/host/app/services/matrix-service.ts | 16 ++++------ .../matrix-service-relogin-test.ts | 19 ++++++----- packages/matrix/tests/login.spec.ts | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 6c8c7d18cf..a02fb0c1de 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -2100,16 +2100,12 @@ export default class MatrixService extends Service { this._systemCard = undefined; this._systemCardLoadFailed = false; this.startedAtTs = -1; - // The client is recreated synchronously above, so re-establish the - // client-ready invariant here: `loadSDK()` (the only other place that - // fulfills this) runs once at boot and never re-runs, so a fresh deferred - // left pending would strand every post-logout `createRealmSession()` await - // (CS-12207). Guard on `_client` since `createClient` is a no-op when the - // SDK hasn't loaded yet. - this.#clientReadyDeferred = new Deferred(); - if (this._client) { - this.#clientReadyDeferred.fulfill(); - } + // `#clientReadyDeferred` is deliberately left alone. It gates on "the SDK + // has loaded and a client exists" — a condition this reset preserves, since + // the client is recreated synchronously above whenever the SDK is loaded. + // Replacing it with a fresh deferred would strand every post-logout + // `createRealmSession()` await: `loadSDK()`, the only fulfiller, runs once + // at boot and never re-runs. } private teardownClient() { diff --git a/packages/host/tests/integration/matrix-service-relogin-test.ts b/packages/host/tests/integration/matrix-service-relogin-test.ts index 2891add057..75f75ee1e0 100644 --- a/packages/host/tests/integration/matrix-service-relogin-test.ts +++ b/packages/host/tests/integration/matrix-service-relogin-test.ts @@ -20,14 +20,14 @@ import { setupMockMatrix } from '../helpers/mock-matrix'; import { setupRenderingTest } from '../helpers/setup'; -// CS-12207: `MatrixService` is a singleton whose client-ready barrier +// `MatrixService` is a singleton whose client-ready barrier // (`#clientReadyDeferred`) is only fulfilled once, at boot, inside `loadSDK()`. // Logout stays in-app (a router transition, not a page reload) and its -// `finally` runs `resetState()`, which recreates the client synchronously but -// then replaces `#clientReadyDeferred` with a fresh, never-fulfilled deferred. -// The next login awaits that deferred in `createRealmSession()`, so it hangs -// forever and the auth page never progresses. This test locks down the -// invariant that `createRealmSession()` still resolves after a `resetState()`. +// `finally` runs `resetState()`, so the barrier must survive that reset: if it +// were replaced with a fresh, never-fulfilled deferred, the next login's +// `createRealmSession()` would await it forever and the auth page would never +// progress. This test locks down the invariant that `createRealmSession()` +// still resolves after a `resetState()`. module( 'Integration | matrix-service | re-login after logout', function (hooks) { @@ -53,9 +53,8 @@ module( let matrixService = getService('matrix-service') as MatrixService; await matrixService.ready; - // `logout()` recreates the client and installs a fresh - // `#clientReadyDeferred` via `resetState()` in its `finally`. Drive that - // reset directly so the test doesn't depend on the full logout network + // `logout()` runs `resetState()` in its `finally`. Drive that reset + // directly so the test doesn't depend on the full logout network // roundtrip / router transition. matrixService.resetState(); @@ -68,7 +67,7 @@ module( assert.notStrictEqual( result, TIMEOUT, - 'createRealmSession settles after resetState — the re-created clientReadyDeferred is fulfilled rather than left pending', + 'createRealmSession settles after resetState — the clientReadyDeferred barrier survives the reset rather than being left pending', ); }); }, diff --git a/packages/matrix/tests/login.spec.ts b/packages/matrix/tests/login.spec.ts index 34ccae82d7..cedb7f8594 100644 --- a/packages/matrix/tests/login.spec.ts +++ b/packages/matrix/tests/login.spec.ts @@ -358,6 +358,38 @@ test.describe('Login', () => { await expect(page.locator('[data-test-login-btn]')).toBeVisible(); }); + test('it can log back in after logout without a page reload', async ({ + page, + }) => { + await login(page, username, password, { url: appURL }); + await assertLoggedIn(page, { + displayName: username, + userId: credentials.userId, + }); + + await logout(page); + await assertLoggedOut(page); + // The login form renders as soon as auth is cleared, but logout finishes + // asynchronously with a router transition to index-root that remounts the + // form. Wait for that transition to land (the path leaves the realm route) + // before filling, or the fields get wiped mid-fill — faster than any real + // user could type. + await page.waitForURL((url) => url.pathname === '/'); + + // Logout stays in-app (a router transition, not a page reload), so this + // second login runs against the matrix client that logout's `resetState()` + // recreated rather than a freshly booted one. + await page.locator('[data-test-username-field]').fill(username); + await page.locator('[data-test-password-field]').fill(password); + await page.locator('[data-test-login-btn]').click(); + + await expect(page.locator('[data-test-workspace-chooser]')).toHaveCount(1); + await assertLoggedIn(page, { + displayName: username, + userId: credentials.userId, + }); + }); + test('it shows an error when invalid credentials are provided', async ({ page, }) => {