diff --git a/app/src/globalConfig.ts b/app/src/globalConfig.ts index b688273c00..252d49ac4d 100644 --- a/app/src/globalConfig.ts +++ b/app/src/globalConfig.ts @@ -274,8 +274,27 @@ export const appLanguageAsRef = computed(() => appLanguagesPreferredAsRef.value[ /** * Initialize the language settings. If no user preferred language is set, the browser preferred language is used if it is supported. Otherwise, the CMS default language is used. */ +/** + * Language docs arrive only through sync, which is gated on the socket connecting, so a + * client that starts offline with an empty database would otherwise wait here forever — + * stranding the splash, sync startup and analytics behind it. + */ +const LANGUAGE_BOOT_TIMEOUT_MS = 5_000; + export const initLanguage = () => { return new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(bootTimeout); + resolve(); + }; + + // Let boot continue without languages. The watcher below is deliberately left + // live in that case, so a later sync still normalizes the preferred/synced sets. + const bootTimeout = setTimeout(settle, LANGUAGE_BOOT_TIMEOUT_MS); + // Language is a fully-synced type, so this HybridQuery reads from IndexedDB // only. Constructed at app scope (never disposed) — its output ref feeds the // shared cmsLanguages list. @@ -340,7 +359,7 @@ export const initLanguage = () => { ); unwatchCmsLanguages(); - resolve(); + settle(); }, { deep: true }, ); diff --git a/app/src/initLanguage.spec.ts b/app/src/initLanguage.spec.ts new file mode 100644 index 0000000000..b0bcf9562d --- /dev/null +++ b/app/src/initLanguage.spec.ts @@ -0,0 +1,44 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, afterEach } from "vitest"; +import { db } from "luminary-shared"; +import { appLanguageIdsAsRef, initLanguage } from "@/globalConfig"; +import { mockLanguageDtoEng } from "./tests/mockdata"; +import waitForExpect from "wait-for-expect"; + +/** + * `initLanguage()` is awaited in main.ts between `app.mount()` and + * `isAppLoading.value = false`, so a promise that never settles here leaves the app on + * the splash with sync, analytics and `markAppReady()` all stranded behind it. Language + * docs arrive only through sync, which is gated on the socket, so an offline cold start + * has no way to satisfy it. + */ + +const settledWithin = (promise: Promise, ms: number) => + Promise.race([ + promise.then(() => "settled" as const), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), ms)), + ]); + +describe("initLanguage", () => { + afterEach(async () => { + await db.docs.clear(); + }); + + it("resolves without Language docs so an offline cold start still boots", async () => { + await db.docs.clear(); + + expect(await settledWithin(initLanguage(), 15_000)).toBe("settled"); + }); + + it("still normalizes the preferred languages once a later sync delivers them", async () => { + await db.docs.clear(); + + await initLanguage(); + await db.docs.bulkPut([mockLanguageDtoEng]); + + // The watcher is left live when boot continued without languages. + await waitForExpect(() => { + expect(appLanguageIdsAsRef.value).toContain(mockLanguageDtoEng._id); + }); + }); +}); diff --git a/app/src/main.ts b/app/src/main.ts index 80137642ac..601ed5bc7f 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -32,6 +32,19 @@ app.use(createPinia()); initSentry(app); +/** + * Backstop for a startup that stalls instead of failing. Nothing below rejects on a + * promise that simply never settles, so without this the user sits on the boot splash + * and nothing is reported. Comfortably above the ~30s worst case of a slow silent + * token refresh so a slow-but-working boot is not flagged. + */ +const BOOT_TIMEOUT_MS = 45_000; + +const bootWatchdog = setTimeout(() => { + markAppError(); + Sentry?.captureMessage(`App boot did not complete within ${BOOT_TIMEOUT_MS}ms`, "error"); +}, BOOT_TIMEOUT_MS); + /** * Content sync window. Installed (standalone) sessions sync the full corpus (no * cutoff). Browser-tab sessions sync only the last ~1 month; content with @@ -62,9 +75,6 @@ async function Startup() { contentPublishDateCutoff: installedStandalone ? undefined // no cutoff → full corpus : Date.now() - BROWSER_CONTENT_SYNC_WINDOW_MS, - }).catch((err) => { - console.error(err); - Sentry?.captureException(err); }); // Keep the CMS-managed default-affinity baseline/config in sync with the local @@ -132,10 +142,12 @@ async function Startup() { isAppLoading.value = false; initAppTitle(i18n); initAnalytics(); + clearTimeout(bootWatchdog); markAppReady(); } Startup().catch((err) => { + clearTimeout(bootWatchdog); console.error(err); Sentry?.captureException(err); markAppError(); diff --git a/playwright-tests/app/flows/boot-recovery.spec.ts b/playwright-tests/app/flows/boot-recovery.spec.ts new file mode 100644 index 0000000000..47644ba4fa --- /dev/null +++ b/playwright-tests/app/flows/boot-recovery.spec.ts @@ -0,0 +1,102 @@ +import type { Page } from "@playwright/test"; +import { appTest as test, expect } from "../../fixtures/test"; + +/** + * The app mounts only after the database, sync and auth have initialised, so a boot that + * stalls in that window leaves the user with nothing reported. These cover the recovery + * paths — bounded database opens, and a language wait that gives up — through the boot + * splash the build injects, so they need that splash deployed to run green. + */ + +/** + * Replaces the IndexedDB open the boot path starts with. `blocked` is what a browser + * reports when another connection still holds the database; `silent` never answers at + * all, which is the case no event can rescue. + */ +async function breakIndexedDbOpen(page: Page, mode: "blocked" | "silent") { + await page.addInitScript((stubMode) => { + Object.defineProperty(window.indexedDB, "open", { + configurable: true, + value: () => { + const request: Record = {}; + if (stubMode === "blocked") { + setTimeout(() => { + (request.onblocked as (() => void) | undefined)?.(); + }, 0); + } + return request; + }, + }); + }, mode); +} + +test.describe("App boot recovery", () => { + test("removes the boot splash once the app mounts", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.locator("#boot-splash")).toHaveCount(0); + }); + + test.describe("before scripts run", () => { + // Approximates the pre-mount window: the splash has to stand on its own markup + // and CSS, with no help from the bundle. + test.use({ javaScriptEnabled: false }); + + test("covers the viewport", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator("#boot-splash")).toBeVisible(); + await expect(page.locator("#boot-splash")).toContainText("Loading"); + }); + }); + + test("reports an error when the database open is blocked", async ({ + page, + }) => { + await breakIndexedDbOpen(page, "blocked"); + await page.goto("/"); + + await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByRole("button", { name: "Reload" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute( + "data-render-state", + "error", + ); + }); + + test("does not strand the splash when the socket never connects", async ({ + page, + }) => { + // Only the socket is blocked — REST stays reachable. Language docs are a + // fully-synced type whose sync is gated on `isConnected`, so a client that + // cannot open a socket never receives them, and `initLanguage()` is awaited + // between mount and the splash being cleared. + await page.route("**/socket.io/**", (route) => route.abort()); + await page.goto("/"); + + // Mounted: the static splash is gone, so the boot path got past app.mount(). + await expect(page.locator("#boot-splash")).toHaveCount(0); + + // The app must still become usable offline rather than sitting on the splash. + await expect(page.getByRole("main")).toBeVisible({ timeout: 30_000 }); + }); + + test("reports an error when the database open never answers", async ({ + page, + }) => { + await breakIndexedDbOpen(page, "silent"); + await page.goto("/"); + + // Bounded by the boot-path open timeout rather than waiting forever. + await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator("html")).toHaveAttribute( + "data-render-state", + "error", + ); + }); +}); diff --git a/shared/src/db/database.ts b/shared/src/db/database.ts index 5d9567f96d..5ec67ff5c6 100644 --- a/shared/src/db/database.ts +++ b/shared/src/db/database.ts @@ -874,30 +874,47 @@ class Database extends Dexie { export let db: Database; +/** + * IndexedDB gives no answer at all when an open is blocked by another connection, so + * every open in the boot path is bounded. + */ +const DB_OPEN_TIMEOUT_MS = 10_000; + +function withDbTimeout(promise: Promise, action: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out trying to ${action}`)), + DB_OPEN_TIMEOUT_MS, + ); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + export async function initDatabase() { const _v: number = await getDbVersion(); db = new Database(_v, config.docsIndex); - // Open the database and wait for it to be ready - await new Promise((resolve) => { - if (db.isOpen()) { - resolve(); - return; - } - - db.on("ready", () => { - resolve(); - }); - - if (!db.isOpen()) { - db.open(); - } - }); - + // Registered before open(): a blocked upgrade is what this reports, and it is + // raised during the open, not after it. db.on("blocked", () => { console.error("Database blocked"); }); + // Awaiting open() rather than the "ready" event means a rejected open is raised + // as a boot error instead of leaving this promise unsettled. + if (!db.isOpen()) { + await withDbTimeout(db.open(), "open the database"); + } + // Compute FTS corpus stats on startup. // Uses setTimeout(0) to avoid Dexie PSD zone deadlocks during initialization. setTimeout(() => { @@ -979,13 +996,21 @@ async function resetGroupSyncListForRecovery() { } /** - * Get IndexDB version before DB class is initialized + * Get IndexDB version before DB class is initialized. Rejects rather than staying + * unsettled when the open is blocked or fails, because this runs before the app is + * mounted and an unsettled promise here strands the user on the boot screen. * @returns IndexDB Version */ -export const getDbVersion = async () => { - const request = indexedDB.open(dbName); - return new Promise((resolve) => { +export const getDbVersion = () => + new Promise((resolve, reject) => { + const request = indexedDB.open(dbName); + const timeout = setTimeout( + () => reject(new Error("Timed out reading the database version")), + DB_OPEN_TIMEOUT_MS, + ); + request.onsuccess = (event: any) => { + clearTimeout(timeout); const db = event.target.result; const version: number = (db && db.version) || 0; db.addEventListener("close", () => {}); @@ -993,13 +1018,14 @@ export const getDbVersion = async () => { resolve(version); }; request.onblocked = () => { - console.error("Database blocked"); + clearTimeout(timeout); + reject(new Error("Database blocked while reading the database version")); }; request.onerror = () => { - console.error("Database error"); + clearTimeout(timeout); + reject(new Error("Database error while reading the database version")); }; - }) as unknown as Promise; -}; + }); /** * Concatenate Shared Library index with the external index, to avoid having duplicate indexes diff --git a/shared/src/db/dbOpenFailure.spec.ts b/shared/src/db/dbOpenFailure.spec.ts new file mode 100644 index 0000000000..c859f15727 --- /dev/null +++ b/shared/src/db/dbOpenFailure.spec.ts @@ -0,0 +1,75 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import Dexie from "dexie"; +import { getDbVersion, initDatabase } from "./database"; +import { initConfig } from "../config"; + +// The boot-path database opens run before the app is mounted, so a promise that never +// settles here strands the user on the boot splash with nothing reported. +const originalIndexedDb = globalThis.indexedDB; + +function stubOpen(fire: "onsuccess" | "onblocked" | "onerror" | "never") { + const request: Record void) | undefined> = {}; + (globalThis as any).indexedDB = { + open: () => { + if (fire !== "never") { + setTimeout(() => { + if (fire === "onsuccess") { + request.onsuccess?.({ + target: { result: { version: 70, addEventListener() {}, close() {} } }, + }); + } else { + request[fire]?.(); + } + }, 0); + } + return request; + }, + }; +} + +describe("getDbVersion", () => { + afterEach(() => { + vi.useRealTimers(); + (globalThis as any).indexedDB = originalIndexedDb; + }); + + it("resolves the version when the open succeeds", async () => { + stubOpen("onsuccess"); + await expect(getDbVersion()).resolves.toBe(70); + }); + + it("rejects when the open is blocked by another connection", async () => { + stubOpen("onblocked"); + await expect(getDbVersion()).rejects.toThrow(/blocked/i); + }); + + it("rejects when the open errors", async () => { + stubOpen("onerror"); + await expect(getDbVersion()).rejects.toThrow(/error/i); + }); + + it("rejects when the open never answers at all", async () => { + vi.useFakeTimers(); + stubOpen("never"); + + const assertion = expect(getDbVersion()).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(10_000); + await assertion; + }); +}); + +describe("initDatabase", () => { + afterEach(() => vi.restoreAllMocks()); + + it("rejects when the database open fails", async () => { + initConfig({ + cms: false, + docsIndex: "[type+postType]", + apiUrl: "http://localhost:12345", + }); + vi.spyOn(Dexie.prototype, "open").mockRejectedValue(new Error("VersionError")); + + await expect(initDatabase()).rejects.toThrow("VersionError"); + }); +});