fix(ui): recover hung QR acknowledgements

This commit is contained in:
jesse-merhi
2026-08-12 01:21:28 +10:00
parent 0fbe90f1f3
commit 0f4e6d1e9e
3 changed files with 71 additions and 1 deletions
@@ -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<never>(() => {});
const request = vi
.fn()
.mockResolvedValueOnce(qrResult())
.mockImplementationOnce(
async (
_method: string,
_params: unknown,
options?: { onSent?: () => void; signal?: AbortSignal },
): Promise<never> => {
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<HTMLButtonElement>(".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;
@@ -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 = [];
@@ -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);
}
};