feat(profiles): name the copy of a stopped catch-up, one copy per profile, startup error cleared (#395) #436
3 changed files with 191 additions and 42 deletions
51
src/App.tsx
51
src/App.tsx
|
|
@ -1,5 +1,5 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useProfile } from "./contexts/ProfileContext";
|
import { useProfile } from "./contexts/ProfileContext";
|
||||||
import AppShell from "./components/layout/AppShell";
|
import AppShell from "./components/layout/AppShell";
|
||||||
|
|
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
|
||||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||||
import ErrorPage from "./components/shared/ErrorPage";
|
import ErrorPage from "./components/shared/ErrorPage";
|
||||||
import RequireFeature from "./components/shared/RequireFeature";
|
import RequireFeature from "./components/shared/RequireFeature";
|
||||||
|
import { startProfileConnection } from "./utils/startupConnection";
|
||||||
const STARTUP_TIMEOUT_MS = 10_000;
|
|
||||||
const MAX_RETRIES = 3;
|
|
||||||
const RETRY_DELAY_MS = 1_000;
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||||
const [dbReady, setDbReady] = useState(false);
|
const [dbReady, setDbReady] = useState(false);
|
||||||
const [startupError, setStartupError] = useState<string | null>(null);
|
const [startupError, setStartupError] = useState<string | null>(null);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const cancelledRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeProfile && !isLoading) {
|
if (!activeProfile || isLoading) return;
|
||||||
setDbReady(false);
|
return startProfileConnection({
|
||||||
setStartupError(null);
|
connect: connectActiveProfile,
|
||||||
cancelledRef.current = false;
|
setDbReady,
|
||||||
|
setStartupError,
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutMessage: t("error.startupTimeout"),
|
||||||
setStartupError(t("error.startupTimeout"));
|
});
|
||||||
}, STARTUP_TIMEOUT_MS);
|
|
||||||
|
|
||||||
const attemptConnect = async (attempt: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await connectActiveProfile();
|
|
||||||
if (cancelledRef.current) return;
|
|
||||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
||||||
setDbReady(true);
|
|
||||||
} catch (err) {
|
|
||||||
if (cancelledRef.current) return;
|
|
||||||
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
|
|
||||||
if (attempt < MAX_RETRIES) {
|
|
||||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
||||||
if (!cancelledRef.current) return attemptConnect(attempt + 1);
|
|
||||||
} else {
|
|
||||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
||||||
setStartupError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attemptConnect(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelledRef.current = true;
|
|
||||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
||||||
};
|
|
||||||
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
|
||||||
116
src/utils/startupConnection.test.ts
Normal file
116
src/utils/startupConnection.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
MAX_RETRIES,
|
||||||
|
RETRY_DELAY_MS,
|
||||||
|
STARTUP_TIMEOUT_MS,
|
||||||
|
startProfileConnection,
|
||||||
|
} from "./startupConnection";
|
||||||
|
|
||||||
|
// The startup of App.tsx: what the error page and the app see while the
|
||||||
|
// active profile opens (#395).
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve: () => void = () => {};
|
||||||
|
let reject: (e: unknown) => void = () => {};
|
||||||
|
const promise = new Promise<void>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(connect: () => Promise<void>) {
|
||||||
|
const setDbReady = vi.fn();
|
||||||
|
const setStartupError = vi.fn();
|
||||||
|
const cleanup = startProfileConnection({
|
||||||
|
connect,
|
||||||
|
setDbReady,
|
||||||
|
setStartupError,
|
||||||
|
timeoutMessage: "startup timeout",
|
||||||
|
});
|
||||||
|
// The state React would hold: the last value each setter received.
|
||||||
|
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1]?.[0];
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
setDbReady,
|
||||||
|
setStartupError,
|
||||||
|
error: () => last(setStartupError),
|
||||||
|
ready: () => last(setDbReady),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startProfileConnection", () => {
|
||||||
|
it("shows the app once the profile opens in time", async () => {
|
||||||
|
const s = setup(async () => {});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(s.ready()).toBe(true);
|
||||||
|
expect(s.error()).toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS);
|
||||||
|
expect(s.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the error page after the delay while the profile is still opening", async () => {
|
||||||
|
const opening = deferred();
|
||||||
|
const s = setup(() => opening.promise);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS - 1);
|
||||||
|
expect(s.error()).toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(s.error()).toBe("startup timeout");
|
||||||
|
expect(s.ready()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the error page when the profile opens after the delay (#395)", async () => {
|
||||||
|
// A catch-up of a large profile can outlast the delay (ADR 0021).
|
||||||
|
const opening = deferred();
|
||||||
|
const s = setup(() => opening.promise);
|
||||||
|
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS + 500);
|
||||||
|
expect(s.error()).toBe("startup timeout");
|
||||||
|
|
||||||
|
opening.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(s.error()).toBeNull();
|
||||||
|
expect(s.ready()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries, then shows the last error", async () => {
|
||||||
|
const connect = vi.fn(async () => {
|
||||||
|
throw new Error("no database");
|
||||||
|
});
|
||||||
|
const s = setup(connect);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_RETRIES);
|
||||||
|
|
||||||
|
expect(connect).toHaveBeenCalledTimes(MAX_RETRIES);
|
||||||
|
expect(s.error()).toBe("no database");
|
||||||
|
expect(s.ready()).toBe(false);
|
||||||
|
// The delay is disarmed: the real error stays.
|
||||||
|
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS);
|
||||||
|
expect(s.error()).toBe("no database");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing more once cleaned up", async () => {
|
||||||
|
const opening = deferred();
|
||||||
|
const s = setup(() => opening.promise);
|
||||||
|
s.cleanup();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS);
|
||||||
|
opening.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(s.setStartupError.mock.calls).toEqual([[null]]);
|
||||||
|
expect(s.setDbReady.mock.calls).toEqual([[false]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/utils/startupConnection.ts
Normal file
66
src/utils/startupConnection.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Opening the active profile at startup, out of App.tsx so it can be tested
|
||||||
|
// without rendering the router (#395).
|
||||||
|
|
||||||
|
/** Delay after which the startup error page shows while the profile opens. */
|
||||||
|
export const STARTUP_TIMEOUT_MS = 10_000;
|
||||||
|
export const MAX_RETRIES = 3;
|
||||||
|
export const RETRY_DELAY_MS = 1_000;
|
||||||
|
|
||||||
|
export interface StartupConnection {
|
||||||
|
/** Opens the active profile (`connectActiveProfile`). */
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
setDbReady: (ready: boolean) => void;
|
||||||
|
setStartupError: (error: string | null) => void;
|
||||||
|
/** Shown when the opening outlasts `STARTUP_TIMEOUT_MS`. */
|
||||||
|
timeoutMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the profile, retrying up to `MAX_RETRIES` times, and show the error
|
||||||
|
* page after `STARTUP_TIMEOUT_MS` while it is still opening. The opening goes
|
||||||
|
* on past the delay (a catch-up of a large profile can, ADR 0021): when it
|
||||||
|
* then succeeds, the error is cleared and the app shows (#395). Returns the
|
||||||
|
* cleanup of the effect that calls it.
|
||||||
|
*/
|
||||||
|
export function startProfileConnection({
|
||||||
|
connect,
|
||||||
|
setDbReady,
|
||||||
|
setStartupError,
|
||||||
|
timeoutMessage,
|
||||||
|
}: StartupConnection): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
setDbReady(false);
|
||||||
|
setStartupError(null);
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setStartupError(timeoutMessage);
|
||||||
|
}, STARTUP_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const attemptConnect = async (attempt: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
if (cancelled) return;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
// The delay may have shown the error page already.
|
||||||
|
setStartupError(null);
|
||||||
|
setDbReady(true);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||||
|
if (!cancelled) return attemptConnect(attempt + 1);
|
||||||
|
} else {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
setStartupError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void attemptConnect(1);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue