From d9c723e68c13b3ca4c1a934b36923fee67a6893f Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Mon, 13 Jul 2026 16:51:42 +0300 Subject: [PATCH 1/3] retry vault read on resume-window IPC failure to prevent false sign-out --- .../src/lib/global/controllers/evault.ts | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts index 0dd611f80..b12ea54f7 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts @@ -581,18 +581,54 @@ export class VaultController { } get vault() { - return this.#store - .get>("vault") - .then((vault) => { - if (!vault) { + return this.#readVaultResilient(); + } + + /** + * Read the persisted vault, distinguishing "no vault" (genuinely logged out + * → resolves `undefined` with no throw) from a transient store IPC failure. + * + * On iOS the `ipc://localhost` custom protocol is briefly torn down right + * after the app resumes from background (the WebView logs "IPC custom + * protocol failed, Tauri will now use the postMessage interface instead" and + * "Fetch API cannot load ipc://localhost/plugin:store|get due to access + * control checks"). During that window every `invoke` — including + * `plugin:store|get` — throws. The old getter swallowed that throw into + * `undefined`, which is indistinguishable from "never logged in", so route + * guards (deep-link handler, scan-qr, the app guard) redirected the user to + * /login: the intermittent unexpected sign-out. + * + * We retry the read on THROW so the resume window passes before we ever + * report "no vault". A genuine logout is a resolved-`undefined`, not a + * throw, so it returns immediately with no retry penalty. The getter still + * never throws and still returns `Record | undefined` — callers are + * unchanged. + */ + async #readVaultResilient(): Promise | undefined> { + const maxAttempts = 10; + const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const vault = + await this.#store.get>("vault"); + return vault ?? undefined; + } catch (error) { + if (attempt === maxAttempts) { + console.error( + "Failed to get vault after retries (store IPC unavailable):", + error, + ); return undefined; } - return vault; - }) - .catch((error) => { - console.error("Failed to get vault:", error); - return undefined; - }); + console.warn( + `[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`, + ); + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs), + ); + } + } + return undefined; } // Getters for internal properties From a8db125ada312f5b63448229db4adc7f11949641 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 14 Jul 2026 13:46:43 +0300 Subject: [PATCH 2/3] test(eid-wallet): cover VaultController.vault resume-retry and read coalescing --- infrastructure/eid-wallet/package.json | 1 + .../src/lib/global/controllers/evault.spec.ts | 86 +++++++++++++++++++ .../eid-wallet/src/test/env-static-public.ts | 6 ++ infrastructure/eid-wallet/vitest.config.ts | 20 +++++ 4 files changed, 113 insertions(+) create mode 100644 infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts create mode 100644 infrastructure/eid-wallet/src/test/env-static-public.ts create mode 100644 infrastructure/eid-wallet/vitest.config.ts diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index e74e37030..2c830ea8b 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -14,6 +14,7 @@ "lint": "npx @biomejs/biome lint --write ./src", "check-lint": "npx @biomejs/biome lint ./src", "tauri": "tauri", + "test": "vitest run", "storybook": "svelte-kit sync && storybook dev -p 6006", "build-storybook": "storybook build", "build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7", diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts new file mode 100644 index 000000000..dcf6f92db --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// evault.ts imports these at runtime; mock them so the module loads in a plain +// Node test env without pulling Tauri / wallet-sdk internals. +vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() })); +vi.mock("../../services/NotificationService", () => ({ + default: { + getInstance: () => ({ registerDevice: vi.fn(async () => true) }), + }, +})); + +import { VaultController } from "./evault"; + +const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" }; + +/** Build a VaultController whose only exercised dependency is `store.get`. */ +function makeController(get: (key: string) => Promise) { + const store = { get } as unknown as ConstructorParameters< + typeof VaultController + >[0]; + const userController = { + user: Promise.resolve(undefined), + } as unknown as ConstructorParameters[1]; + return new VaultController(store, userController); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("VaultController.vault — #readVaultResilient retry behavior", () => { + it("returns immediately for a resolved undefined (genuine logout), no retry", async () => { + const get = vi.fn(async () => undefined); + const vc = makeController(get); + + await expect(vc.vault).resolves.toBeUndefined(); + expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent + }); + + it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => { + vi.useFakeTimers(); + const get = vi.fn(async () => { + throw new Error( + "Fetch API cannot load ipc://localhost/plugin:store|get", + ); + }); + const vc = makeController(get); + + const result = vc.vault; + await vi.runAllTimersAsync(); // skip the 200ms backoffs + + await expect(result).resolves.toBeUndefined(); + expect(get).toHaveBeenCalledTimes(10); // maxAttempts + }); + + it("recovers the vault when the store IPC comes back mid-retry", async () => { + vi.useFakeTimers(); + let calls = 0; + const get = vi.fn(async () => { + if (calls++ < 6) throw new Error("IPC custom protocol failed"); + return VAULT; + }); + const vc = makeController(get); + + const result = vc.vault; + await vi.runAllTimersAsync(); + + await expect(result).resolves.toEqual(VAULT); + expect(get).toHaveBeenCalledTimes(7); + }); + + it("coalesces concurrent reads into a single in-flight store read", async () => { + const get = vi.fn(async () => VAULT); + const vc = makeController(get); + + const [a, b] = await Promise.all([vc.vault, vc.vault]); + + expect(a).toEqual(VAULT); + expect(b).toEqual(VAULT); + expect(get).toHaveBeenCalledTimes(1); // both callers shared one read + + // ...and a later, independent access performs a fresh read. + await vc.vault; + expect(get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/infrastructure/eid-wallet/src/test/env-static-public.ts b/infrastructure/eid-wallet/src/test/env-static-public.ts new file mode 100644 index 000000000..cf0f58a65 --- /dev/null +++ b/infrastructure/eid-wallet/src/test/env-static-public.ts @@ -0,0 +1,6 @@ +// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts). +// Real values are injected by SvelteKit at build time; tests only need the +// symbols to exist so modules that import them can load. +export const PUBLIC_EID_WALLET_TOKEN = ""; +export const PUBLIC_PROVISIONER_URL = ""; +export const PUBLIC_REGISTRY_URL = ""; diff --git a/infrastructure/eid-wallet/vitest.config.ts b/infrastructure/eid-wallet/vitest.config.ts new file mode 100644 index 000000000..9116b0662 --- /dev/null +++ b/infrastructure/eid-wallet/vitest.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +// Dedicated, isolated test config — intentionally does NOT load the app's +// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node +// environment and stub the few runtime-only imports (see `$env` alias below; +// heavier deps are mocked per-spec with `vi.mock`). +export default defineConfig({ + resolve: { + alias: { + "$env/static/public": fileURLToPath( + new URL("./src/test/env-static-public.ts", import.meta.url), + ), + }, + }, + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + }, +}); From 1e1c3307297af060bc36bcf107c57ac272601c86 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 14 Jul 2026 13:47:07 +0300 Subject: [PATCH 3/3] fix(eid-wallet): retry vault read on resume-window IPC failure to prevent false sign-out --- .../src/lib/global/controllers/evault.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts index b12ea54f7..244274d8f 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts @@ -580,29 +580,32 @@ export class VaultController { } } + #vaultReadInFlight: Promise | undefined> | null = + null; + get vault() { - return this.#readVaultResilient(); + // Coalesce concurrent reads so the resume window doesn't spin one + // retry loop per caller; cleared on settle so later reads are fresh. + if (this.#vaultReadInFlight) return this.#vaultReadInFlight; + const read = this.#readVaultResilient().finally(() => { + this.#vaultReadInFlight = null; + }); + this.#vaultReadInFlight = read; + return read; } /** - * Read the persisted vault, distinguishing "no vault" (genuinely logged out - * → resolves `undefined` with no throw) from a transient store IPC failure. + * Read the persisted vault, distinguishing "no vault" (resolved `undefined` + * → genuine logout) from a transient store IPC failure (a throw). * - * On iOS the `ipc://localhost` custom protocol is briefly torn down right - * after the app resumes from background (the WebView logs "IPC custom - * protocol failed, Tauri will now use the postMessage interface instead" and - * "Fetch API cannot load ipc://localhost/plugin:store|get due to access - * control checks"). During that window every `invoke` — including - * `plugin:store|get` — throws. The old getter swallowed that throw into - * `undefined`, which is indistinguishable from "never logged in", so route - * guards (deep-link handler, scan-qr, the app guard) redirected the user to - * /login: the intermittent unexpected sign-out. + * On iOS the `ipc://localhost` protocol is briefly torn down after the app + * resumes from background, so `plugin:store|get` throws. The old getter + * swallowed that into `undefined` — indistinguishable from "never logged + * in" — so route guards redirected to /login: the intermittent sign-out. * - * We retry the read on THROW so the resume window passes before we ever - * report "no vault". A genuine logout is a resolved-`undefined`, not a - * throw, so it returns immediately with no retry penalty. The getter still - * never throws and still returns `Record | undefined` — callers are - * unchanged. + * We retry on THROW so the resume window passes before reporting "no vault". + * A genuine logout resolves `undefined` (no throw) and returns immediately. + * Still never throws, still returns `Record | undefined` — callers unchanged. */ async #readVaultResilient(): Promise | undefined> { const maxAttempts = 10;