diff --git a/gui/src/components/use-main-device-reauth.ts b/gui/src/components/use-main-device-reauth.ts index 626e004a5c1..44d18b9645f 100644 --- a/gui/src/components/use-main-device-reauth.ts +++ b/gui/src/components/use-main-device-reauth.ts @@ -181,6 +181,12 @@ export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) { const dto = await res.json().catch(() => ({})) as FlowDto; if (!isCurrent() || flowRef.current !== flowId) return; if (!res.ok) { + if (res.status === 404 && dto.code === "unknown_flow") { + stopPolling(); + flowRef.current = null; + setState({ phase: "failed", code: "request_failed" }); + return; + } // Ownership continues from the Cancel click, including while DELETE // is unresolved. Never offer a replacement POST during that window, // and keep the existing poll cadence so a later terminal status remains observable. diff --git a/gui/tests/main-device-reauth-ownership.test.tsx b/gui/tests/main-device-reauth-ownership.test.tsx index 917cb5f80d1..e90315560b3 100644 --- a/gui/tests/main-device-reauth-ownership.test.tsx +++ b/gui/tests/main-device-reauth-ownership.test.tsx @@ -315,6 +315,17 @@ for (const failure of ["network", "http", "nonterminal"] as const) { }); } +test("unknown flow status stops polling after a failed cancellation", async () => { + await mount(); + await beginFlow("A"); + await invoke(() => hook.cancel()); + await reply(take("DELETE", "A"), { code: "unavailable" }, 503); + await act(async () => { for (const wake of sleepers.splice(0)) wake(); }); + await reply(take("GET", "A"), { code: "unknown_flow" }, 404); + expect(hook.state).toEqual({ phase: "failed", code: "request_failed" }); + expect(sleepers).toHaveLength(0); +}); + test("two successful cancellation replies complete the same flow only once", async () => { await mount(); await beginFlow("A");