Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/src/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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.
Expand Down Expand Up @@ -340,7 +359,7 @@ export const initLanguage = () => {
);

unwatchCmsLanguages();
resolve();
settle();
},
{ deep: true },
);
Expand Down
44 changes: 44 additions & 0 deletions app/src/initLanguage.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, 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);
});
});
});
18 changes: 15 additions & 3 deletions app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
102 changes: 102 additions & 0 deletions playwright-tests/app/flows/boot-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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();

Check failure on line 49 in playwright-tests/app/flows/boot-recovery.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[app] › app/flows/boot-recovery.spec.ts:46:5 › App boot recovery › before scripts run › covers the viewport

1) [app] › app/flows/boot-recovery.spec.ts:46:5 › App boot recovery › before scripts run › covers the viewport Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('#boot-splash') Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('#boot-splash') 47 | await page.goto("/"); 48 | > 49 | await expect(page.locator("#boot-splash")).toBeVisible(); | ^ 50 | await expect(page.locator("#boot-splash")).toContainText("Loading"); 51 | }); 52 | }); at /home/runner/work/luminary/luminary/playwright-tests/app/flows/boot-recovery.spec.ts:49:50

Check failure on line 49 in playwright-tests/app/flows/boot-recovery.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[app] › app/flows/boot-recovery.spec.ts:46:5 › App boot recovery › before scripts run › covers the viewport

1) [app] › app/flows/boot-recovery.spec.ts:46:5 › App boot recovery › before scripts run › covers the viewport Error: expect(locator).toBeVisible() failed Locator: locator('#boot-splash') Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('#boot-splash') 47 | await page.goto("/"); 48 | > 49 | await expect(page.locator("#boot-splash")).toBeVisible(); | ^ 50 | await expect(page.locator("#boot-splash")).toContainText("Loading"); 51 | }); 52 | }); at /home/runner/work/luminary/luminary/playwright-tests/app/flows/boot-recovery.spec.ts:49:50
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({

Check failure on line 60 in playwright-tests/app/flows/boot-recovery.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[app] › app/flows/boot-recovery.spec.ts:54:3 › App boot recovery › reports an error when the database open is blocked

2) [app] › app/flows/boot-recovery.spec.ts:54:3 › App boot recovery › reports an error when the database open is blocked Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('#boot-splash .boot-splash-error') Expected: visible Timeout: 20000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 20000ms - waiting for locator('#boot-splash .boot-splash-error') 58 | await page.goto("/"); 59 | > 60 | await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ | ^ 61 | timeout: 20_000, 62 | }); 63 | await expect(page.getByRole("button", { name: "Reload" })).toBeVisible(); at /home/runner/work/luminary/luminary/playwright-tests/app/flows/boot-recovery.spec.ts:60:67

Check failure on line 60 in playwright-tests/app/flows/boot-recovery.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[app] › app/flows/boot-recovery.spec.ts:54:3 › App boot recovery › reports an error when the database open is blocked

2) [app] › app/flows/boot-recovery.spec.ts:54:3 › App boot recovery › reports an error when the database open is blocked Error: expect(locator).toBeVisible() failed Locator: locator('#boot-splash .boot-splash-error') Expected: visible Timeout: 20000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 20000ms - waiting for locator('#boot-splash .boot-splash-error') 58 | await page.goto("/"); 59 | > 60 | await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ | ^ 61 | timeout: 20_000, 62 | }); 63 | await expect(page.getByRole("button", { name: "Reload" })).toBeVisible(); at /home/runner/work/luminary/luminary/playwright-tests/app/flows/boot-recovery.spec.ts:60:67
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({

Check failure on line 94 in playwright-tests/app/flows/boot-recovery.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[app] › app/flows/boot-recovery.spec.ts:87:3 › App boot recovery › reports an error when the database open never answers

3) [app] › app/flows/boot-recovery.spec.ts:87:3 › App boot recovery › reports an error when the database open never answers Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('#boot-splash .boot-splash-error') Expected: visible Timeout: 30000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 30000ms - waiting for locator('#boot-splash .boot-splash-error') 92 | 93 | // Bounded by the boot-path open timeout rather than waiting forever. > 94 | await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ | ^ 95 | timeout: 30_000, 96 | }); 97 | await expect(page.locator("html")).toHaveAttribute( at /home/runner/work/luminary/luminary/playwright-tests/app/flows/boot-recovery.spec.ts:94:67

Check failure on line 94 in playwright-tests/app/flows/boot-recovery.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[app] › app/flows/boot-recovery.spec.ts:87:3 › App boot recovery › reports an error when the database open never answers

3) [app] › app/flows/boot-recovery.spec.ts:87:3 › App boot recovery › reports an error when the database open never answers Error: expect(locator).toBeVisible() failed Locator: locator('#boot-splash .boot-splash-error') Expected: visible Timeout: 30000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 30000ms - waiting for locator('#boot-splash .boot-splash-error') 92 | 93 | // Bounded by the boot-path open timeout rather than waiting forever. > 94 | await expect(page.locator("#boot-splash .boot-splash-error")).toBeVisible({ | ^ 95 | timeout: 30_000, 96 | }); 97 | await expect(page.locator("html")).toHaveAttribute( at /home/runner/work/luminary/luminary/playwright-tests/app/flows/boot-recovery.spec.ts:94:67
timeout: 30_000,
});
await expect(page.locator("html")).toHaveAttribute(
"data-render-state",
"error",
);
});
});
74 changes: 50 additions & 24 deletions shared/src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, action: string): Promise<T> {
return new Promise<T>((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<void>((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(() => {
Expand Down Expand Up @@ -979,27 +996,36 @@ 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<number>((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", () => {});
db.close();
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<number>;
};
});

/**
* Concatenate Shared Library index with the external index, to avoid having duplicate indexes
Expand Down
Loading
Loading