diff --git a/web/src/components/settings/License.spec.ts b/web/src/components/settings/License.spec.ts index 10b84304c9..4835fa236e 100644 --- a/web/src/components/settings/License.spec.ts +++ b/web/src/components/settings/License.spec.ts @@ -1938,14 +1938,99 @@ describe("License.vue", () => { expect(licenseServer.get_license).toHaveBeenCalledTimes(3); }); - it("drops a pending refetch on unmount", async () => { - vi.mocked(licenseServer.get_license).mockResolvedValue(response({})); + const deferred = () => { + let resolve: (value: AxiosResponse) => void = () => {}; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; + }; + const errorToasts = () => + mockToast.mock.calls.filter(([options]) => options?.variant === "error").length; + + it("drops a pending refetch on unmount, including one a boundary set during a refetch", async () => { + const inFlight = deferred(); + vi.mocked(licenseServer.get_license) + .mockResolvedValueOnce(response({})) + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(response({})); wrapper = createWrapper(); await flushPromises(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(2500); + expect(licenseServer.get_license).toHaveBeenCalledTimes(2); + wrapper.vm.refetchAtBoundary(graceEndUs); + inFlight.resolve(response({})); + await flushPromises(); wrapper.unmount(); await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 3); - expect(licenseServer.get_license).toHaveBeenCalledTimes(1); + expect(licenseServer.get_license).toHaveBeenCalledTimes(2); + }); + + it("schedules nothing when a refetch in flight finishes after unmount", async () => { + const inFlight = deferred(); + vi.mocked(licenseServer.get_license) + .mockResolvedValueOnce(response({})) + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(response({})); + wrapper = createWrapper(); + await flushPromises(); + await vi.advanceTimersByTimeAsync(2500); + wrapper.unmount(); + inFlight.resolve(response({})); + await flushPromises(); + await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 3); + expect(licenseServer.get_license).toHaveBeenCalledTimes(2); + }); + + it("keeps a single retry chain when a boundary arrives during a refetch", async () => { + const inFlight = deferred(); + vi.mocked(licenseServer.get_license) + .mockResolvedValueOnce(response({})) + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(response({})); + wrapper = createWrapper(); + await flushPromises(); + await vi.advanceTimersByTimeAsync(2500); + expect(licenseServer.get_license).toHaveBeenCalledTimes(2); + wrapper.vm.refetchAtBoundary(graceEndUs); + inFlight.resolve(response({})); + await flushPromises(); + await vi.advanceTimersByTimeAsync(1000); + expect(licenseServer.get_license).toHaveBeenCalledTimes(3); + for (let minute = 1; minute <= 3; minute++) { + await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS); + expect(licenseServer.get_license).toHaveBeenCalledTimes(3 + minute); + } + }); + + it("toasts only the first failure of a boundary retry chain", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const gets = () => vi.mocked(licenseServer.get_license).mock.calls.length; + vi.mocked(licenseServer.get_license) + .mockResolvedValueOnce(response({})) + .mockRejectedValue({ response: { status: 503 } }); + wrapper = createWrapper(); + await flushPromises(); + mockToast.mockClear(); + + await vi.advanceTimersByTimeAsync(2500 + 5000); + expect(gets()).toBeGreaterThan(1); + expect(errorToasts()).toBe(1); + + const userReload = wrapper.vm.reloadLicenseData(); + await vi.advanceTimersByTimeAsync(5000); + await userReload; + expect(errorToasts()).toBe(2); + + const beforeRetries = gets(); + await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 3); + expect(gets()).toBeGreaterThan(beforeRetries + 2); + expect(errorToasts()).toBe(2); + + wrapper.vm.refetchAtBoundary(graceEndUs + 1); + await vi.advanceTimersByTimeAsync(1000 + 5000); + expect(errorToasts()).toBe(3); + await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 2); + expect(errorToasts()).toBe(3); + consoleErrorSpy.mockRestore(); }); }); diff --git a/web/src/components/settings/License.vue b/web/src/components/settings/License.vue index efe2144564..60eb44e678 100644 --- a/web/src/components/settings/License.vue +++ b/web/src/components/settings/License.vue @@ -721,17 +721,21 @@ export default defineComponent({ }; // Only the first load may swap the page for the spinner; later reloads would unmount the form. - const loadLicenseData = async ({ background = false } = {}) => { + const loadLicenseData = async ({ background = false, quiet = false } = {}) => { try { if (!background) loading.value = true; licenseData.value = await queryClient.fetchQuery(licenseQuery()); if (!background) checkAndAutoFillLicenseFromUrl(); + return true; } catch (error) { console.error("Error loading license data:", error); - toast({ - variant: "error", - message: t("about.failed_to_load_license_info"), - }); + if (!quiet) { + toast({ + variant: "error", + message: t("about.failed_to_load_license_info"), + }); + } + return false; } finally { if (!background) loading.value = false; } @@ -742,26 +746,36 @@ export default defineComponent({ let boundaryRefetch: ReturnType | undefined; let boundaryUs = 0; let boundaryRetries = 0; + let boundaryChain = 0; + let boundaryErrorShown = false; let unmounted = false; const clearBoundaryRefetch = () => { if (boundaryRefetch !== undefined) clearTimeout(boundaryRefetch); boundaryRefetch = undefined; }; + const scheduleBoundaryRefetch = (chain: number, delayMs: number) => { + clearBoundaryRefetch(); + boundaryRefetch = setTimeout(() => refetchAfterBoundary(chain), delayMs); + }; // the server re-evaluates on its own tick, so a decision from before the boundary is fetched again - const refetchAfterBoundary = async () => { + const refetchAfterBoundary = async (chain: number) => { boundaryRefetch = undefined; - await reloadLicenseData(); + const loaded = await loadLicenseData({ background: true, quiet: boundaryErrorShown }); + if (!loaded && chain === boundaryChain) boundaryErrorShown = true; + // a boundary that arrived during the fetch started a newer chain, which owns the timer + if (unmounted || chain !== boundaryChain) return; const evaluatedAt = Number(licenseData.value?.evaluated_at ?? 0); - if (unmounted || !(evaluatedAt > 0 && evaluatedAt < boundaryUs)) return; + if (!(evaluatedAt > 0 && evaluatedAt < boundaryUs)) return; if (boundaryRetries >= LICENSE_BOUNDARY_RETRIES) return; boundaryRetries++; - boundaryRefetch = setTimeout(refetchAfterBoundary, LICENSE_BOUNDARY_RETRY_MS); + scheduleBoundaryRefetch(chain, LICENSE_BOUNDARY_RETRY_MS); }; const refetchAtBoundary = (atUs: number) => { boundaryUs = Math.max(boundaryUs, atUs); boundaryRetries = 0; - clearBoundaryRefetch(); - boundaryRefetch = setTimeout(refetchAfterBoundary, LICENSE_BOUNDARY_DEBOUNCE_MS); + boundaryChain++; + boundaryErrorShown = false; + scheduleBoundaryRefetch(boundaryChain, LICENSE_BOUNDARY_DEBOUNCE_MS); }; onBeforeUnmount(() => { unmounted = true;