From e7c206b804e7e9e17d82b233c87c0c54679c0a51 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 1 Jul 2026 10:15:34 +0300 Subject: [PATCH 1/3] fix(evoting): stop false "failed to create session" error after a successful vote --- .../api/src/controllers/SigningController.ts | 21 +++++- .../src/components/signing-interface.tsx | 71 ++++++++++++++++--- platforms/evoting/client/src/lib/pollApi.ts | 10 +++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/platforms/evoting/api/src/controllers/SigningController.ts b/platforms/evoting/api/src/controllers/SigningController.ts index 02934eeb2..f90a70594 100644 --- a/platforms/evoting/api/src/controllers/SigningController.ts +++ b/platforms/evoting/api/src/controllers/SigningController.ts @@ -90,10 +90,29 @@ export class SigningController { res.write("data: " + JSON.stringify({ type: "connected", sessionId }) + "\n\n"); // Subscribe to session updates - const unsubscribe = this.ensureService().subscribeToSession(sessionId, (data) => { + const service = this.ensureService(); + const unsubscribe = service.subscribeToSession(sessionId, (data) => { res.write("data: " + JSON.stringify(data) + "\n\n"); }); + // Replay the current terminal state on (re)connect. The completion event is + // pushed only once, at callback time. On mobile the browser suspends this SSE + // stream while the eID Wallet is foregrounded, so a client that reconnects + // after signing would otherwise never learn the vote succeeded (and would show + // a misleading error or hang until expiry). Re-emitting is idempotent on the client. + try { + const session = await service.getSession(sessionId); + if (session?.status === "completed") { + res.write("data: " + JSON.stringify({ type: "signed", status: "completed", sessionId }) + "\n\n"); + } else if (session?.status === "security_violation") { + res.write("data: " + JSON.stringify({ type: "security_violation", status: "security_violation", error: "eName verification failed", sessionId }) + "\n\n"); + } else if (session?.status === "expired") { + res.write("data: " + JSON.stringify({ type: "expired", status: "expired", sessionId }) + "\n\n"); + } + } catch (error) { + console.error("Error replaying signing session status on connect:", error); + } + // Handle client disconnect req.on("close", () => { unsubscribe(); diff --git a/platforms/evoting/client/src/components/signing-interface.tsx b/platforms/evoting/client/src/components/signing-interface.tsx index 35d0189ae..ae195b95d 100644 --- a/platforms/evoting/client/src/components/signing-interface.tsx +++ b/platforms/evoting/client/src/components/signing-interface.tsx @@ -35,6 +35,43 @@ export function SigningInterface({ const { toast } = useToast(); const { user } = useAuth(); const hasCreatedSession = useRef(false); + // Guards the completion path so it runs exactly once, whether the "signed" signal + // arrives via the live SSE push or via reconciliation after a reconnect. + const hasCompleted = useRef(false); + + const finishAsSigned = (voteId?: string) => { + if (hasCompleted.current) return; + hasCompleted.current = true; + setStatus("signed"); + toast({ + title: "Vote Signed!", + description: "Your vote has been successfully signed and submitted", + }); + onSigningComplete(voteId ?? ""); + }; + + // Re-fetch the authoritative session status. Called when the SSE stream errors and + // when the tab regains focus: on mobile, opening the eID Wallet backgrounds this page + // and the browser suspends the SSE stream, so the one-shot completion push can be + // missed. This recovers the real outcome instead of showing a false error. + const reconcileSession = async (sid: string) => { + if (!sid || hasCompleted.current) return; + try { + const session = await pollApi.getSigningSession(sid); + if (!session) return; + if (session.status === "completed") { + finishAsSigned(session.voteId); + } else if (session.status === "security_violation") { + setStatus("security_violation"); + } else if (session.status === "expired") { + setStatus("expired"); + } + } catch (error) { + // Non-fatal: the SSE stream auto-reconnects (and re-emits terminal state), and + // the next visibilitychange will retry. + console.error("Failed to reconcile signing session:", error); + } + }; const createSession = async () => { if (!user?.id || hasCreatedSession.current || status === "error") { @@ -77,6 +114,25 @@ export function SigningInterface({ }; }, [eventSource]); + // Recover a completion that landed while we were backgrounded. Mobile browsers + // suspend the SSE stream while the eID Wallet is foregrounded, so the one-shot + // "signed" push can arrive (and be lost) before we return. Re-check the authoritative + // status whenever the tab regains focus/visibility. + useEffect(() => { + if (!sessionId) return; + const recover = () => { + if (document.visibilityState === "visible") { + reconcileSession(sessionId); + } + }; + document.addEventListener("visibilitychange", recover); + window.addEventListener("focus", recover); + return () => { + document.removeEventListener("visibilitychange", recover); + window.removeEventListener("focus", recover); + }; + }, [sessionId]); + const startSSEConnection = (sessionId: string) => { // Prevent multiple SSE connections if (eventSource) { @@ -98,13 +154,7 @@ export function SigningInterface({ const data = JSON.parse(e.data); if (data.type === "signed" && data.status === "completed") { - setStatus("signed"); - - toast({ - title: "Vote Signed!", - description: "Your vote has been successfully signed and submitted", - }); - onSigningComplete(data.voteId); + finishAsSigned(data.voteId); } else if (data.type === "expired") { setStatus("expired"); toast({ @@ -128,7 +178,12 @@ export function SigningInterface({ newEventSource.onerror = (error) => { console.error("SSE connection error:", error); - setStatus("error"); + // A dropped SSE stream is NOT a session-creation failure: the session already + // exists and the vote may already be signed (opening the eID Wallet backgrounds + // this page on mobile, suspending the stream). Reconcile the real status instead + // of showing the misleading "Failed to create signing session" error; the browser + // also auto-reconnects the stream on its own. + reconcileSession(sessionId); }; setEventSource(newEventSource); diff --git a/platforms/evoting/client/src/lib/pollApi.ts b/platforms/evoting/client/src/lib/pollApi.ts index a175aebdb..d3b0be936 100644 --- a/platforms/evoting/client/src/lib/pollApi.ts +++ b/platforms/evoting/client/src/lib/pollApi.ts @@ -302,6 +302,16 @@ export const pollApi = { return response.data; }, + // Get a signing session's current status. Used to reconcile after the SSE stream + // drops (e.g. the mobile browser suspended it while the eID Wallet was foregrounded), + // so a completion that happened while disconnected is still picked up. + getSigningSession: async ( + sessionId: string + ): Promise<{ status: string; voteId?: string } | null> => { + const response = await apiClient.get(`/api/signing/sessions/${sessionId}`); + return response.data; + }, + // Delegation methods // Check if a poll can have delegation From 008e05a7a9a447c06dd0629c8dba39faac711bb9 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 1 Jul 2026 13:37:41 +0300 Subject: [PATCH 2/3] register SSE close handler before await to prevent subscriber leak --- .../api/src/controllers/SigningController.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/platforms/evoting/api/src/controllers/SigningController.ts b/platforms/evoting/api/src/controllers/SigningController.ts index f90a70594..35c6402c8 100644 --- a/platforms/evoting/api/src/controllers/SigningController.ts +++ b/platforms/evoting/api/src/controllers/SigningController.ts @@ -95,6 +95,14 @@ export class SigningController { res.write("data: " + JSON.stringify(data) + "\n\n"); }); + // Clean up the subscription when the client disconnects. Registered before the + // getSession() await below so a disconnect during that await can't slip past + // listener registration and leak the subscriber. + req.on("close", () => { + unsubscribe(); + res.end(); + }); + // Replay the current terminal state on (re)connect. The completion event is // pushed only once, at callback time. On mobile the browser suspends this SSE // stream while the eID Wallet is foregrounded, so a client that reconnects @@ -112,12 +120,6 @@ export class SigningController { } catch (error) { console.error("Error replaying signing session status on connect:", error); } - - // Handle client disconnect - req.on("close", () => { - unsubscribe(); - res.end(); - }); } // Handle signed payload callback from eID Wallet From bf3610b2437cf610d9635d7fb4b3e73a5c3a840b Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 1 Jul 2026 13:43:40 +0300 Subject: [PATCH 3/3] return null from getSigningSession on 404 instead of throwing --- platforms/evoting/client/src/lib/pollApi.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/platforms/evoting/client/src/lib/pollApi.ts b/platforms/evoting/client/src/lib/pollApi.ts index d3b0be936..07b365c31 100644 --- a/platforms/evoting/client/src/lib/pollApi.ts +++ b/platforms/evoting/client/src/lib/pollApi.ts @@ -1,4 +1,5 @@ import { apiClient } from "./apiClient"; +import { isAxiosError } from "axios"; export interface Poll { id: string; @@ -308,8 +309,18 @@ export const pollApi = { getSigningSession: async ( sessionId: string ): Promise<{ status: string; voteId?: string } | null> => { - const response = await apiClient.get(`/api/signing/sessions/${sessionId}`); - return response.data; + try { + const response = await apiClient.get(`/api/signing/sessions/${sessionId}`); + return response.data; + } catch (error) { + // A 404 means the session no longer exists (expired and cleaned up, or the + // API lost its in-memory sessions on restart) — nothing to reconcile, so + // return null. Any other error propagates to the caller. + if (isAxiosError(error) && error.response?.status === 404) { + return null; + } + throw error; + } }, // Delegation methods