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 { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProfile } from "./contexts/ProfileContext";
|
||||
import AppShell from "./components/layout/AppShell";
|
||||
|
|
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
|
|||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||
import ErrorPage from "./components/shared/ErrorPage";
|
||||
import RequireFeature from "./components/shared/RequireFeature";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
import { startProfileConnection } from "./utils/startupConnection";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||
const [dbReady, setDbReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProfile && !isLoading) {
|
||||
setDbReady(false);
|
||||
setStartupError(null);
|
||||
cancelledRef.current = false;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
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);
|
||||
};
|
||||
if (!activeProfile || isLoading) return;
|
||||
return startProfileConnection({
|
||||
connect: connectActiveProfile,
|
||||
setDbReady,
|
||||
setStartupError,
|
||||
timeoutMessage: t("error.startupTimeout"),
|
||||
});
|
||||
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||
|
||||
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