From 0f4e6d1e9ec2dd131f86cf0a26d6f2981a85145d Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:21:28 +1000 Subject: [PATCH] fix(ui): recover hung QR acknowledgements --- .../pages/custodian/custodian-page.qr.test.ts | 41 +++++++++++++++++++ .../custodian/custodian-session-store.ts | 17 ++++++++ .../pages/custodian/custodian-wizard-step.ts | 14 ++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/custodian/custodian-page.qr.test.ts b/ui/src/pages/custodian/custodian-page.qr.test.ts index d814ead8db9e..85946c3eee3b 100644 --- a/ui/src/pages/custodian/custodian-page.qr.test.ts +++ b/ui/src/pages/custodian/custodian-page.qr.test.ts @@ -252,6 +252,47 @@ describe("custodian QR wizard step", () => { expect(page.querySelector('[role="alert"]')).toBeNull(); }); + it("recovers through polling when a sent acknowledgement never settles", async () => { + vi.useFakeTimers(); + let acknowledgementSignal: AbortSignal | undefined; + const acknowledgement = new Promise(() => {}); + const request = vi + .fn() + .mockResolvedValueOnce(qrResult()) + .mockImplementationOnce( + async ( + _method: string, + _params: unknown, + options?: { onSent?: () => void; signal?: AbortSignal }, + ): Promise => { + acknowledgementSignal = options?.signal; + options?.onSent?.(); + return await acknowledgement; + }, + ) + .mockResolvedValueOnce(terminalResult("Device linked after recovery.")); + const { page } = await mountPage(createContext(request).context); + await vi.advanceTimersByTimeAsync(0); + + page.querySelector(".custodian__wizard-step .btn.primary")?.click(); + await vi.advanceTimersByTimeAsync(0); + await page.updateComplete; + + expect(request).toHaveBeenCalledTimes(2); + expect(page.store.sending).toBe(true); + expect(page.querySelector(".wizard-step__qr")).toBeNull(); + + await vi.advanceTimersByTimeAsync(1_000); + await page.updateComplete; + + expect(acknowledgementSignal?.aborted).toBe(true); + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" }); + expect(page.store.sending).toBe(false); + expect(page.textContent).toContain("Device linked after recovery."); + expect(page.store.hasUnresolvedQuestion()).toBe(false); + }); + it("clears a failed acknowledgement error when recovery returns the active QR", async () => { vi.useFakeTimers(); let rejectAcknowledgement!: (error: Error) => void; diff --git a/ui/src/pages/custodian/custodian-session-store.ts b/ui/src/pages/custodian/custodian-session-store.ts index 7629ad7f6473..dceaa5799c5c 100644 --- a/ui/src/pages/custodian/custodian-session-store.ts +++ b/ui/src/pages/custodian/custodian-session-store.ts @@ -98,6 +98,7 @@ export class CustodianSessionStore { } }, onPoll: (client, stepId, presentationGeneration) => { + this.retirePendingQrAcknowledgement(stepId); void this.requestReply( client, { sessionId: this.sessionId, pollStepId: stepId }, @@ -640,6 +641,22 @@ export class CustodianSessionStore { this.questionReplyUncertain = false; } + private retirePendingQrAcknowledgement(stepId: string): void { + const pendingStepId = + this.retryParams?.wizardAnswer?.stepId ?? this.retryParams?.wizardCancel?.stepId; + if (!this.sending || pendingStepId !== stepId) { + return; + } + // QR owner state is authoritative after delivery. Release the unanswered request before + // polling so a lost reply cannot retain the sending gate or overwrite the observation. + this.requestAbort?.abort(); + this.requestAbort = null; + this.requestEpoch += 1; + this.sending = false; + this.retryParams = null; + this.emit(); + } + private clearConversation(): void { this.qrScheduler.clear(); this.messages = []; diff --git a/ui/src/pages/custodian/custodian-wizard-step.ts b/ui/src/pages/custodian/custodian-wizard-step.ts index 60a80ee608d7..bfdcfe591e5c 100644 --- a/ui/src/pages/custodian/custodian-wizard-step.ts +++ b/ui/src/pages/custodian/custodian-wizard-step.ts @@ -59,21 +59,33 @@ export class CustodianQrScheduler { clearTimeout(this.pollTimer); this.pollTimer = null; } + const presentationGeneration = this.presentationGeneration; let delivered = false; return (outcome) => { if (!isCurrent()) { return; } + const presentationCurrent = this.presentationGeneration === presentationGeneration; if (outcome !== "rejected" && !delivered) { delivered = true; + if (!presentationCurrent) { + // The authoritative reply already replaced scheduler state, but clients without an + // onSent hook still need the acknowledged credential removed from the transcript. + this.callbacks.onExpire(stepId, true); + return; + } // A received acknowledgement may already have scheduled observation of this step. // Clear only pre-response state so the replacement settlement poll survives. if (this.stepId === stepId && this.pollTimer === null) { this.clear(); } this.callbacks.onExpire(stepId, true); + // Delivery only proves that the Gateway received the answer. Observe owner state so a + // lost or hung reply cannot leave the UI waiting for the general chat timeout. + this.schedulePoll(client, stepId); + return; } - if (outcome !== "sent") { + if (presentationCurrent && outcome !== "sent") { this.schedulePoll(client, stepId); } };