Skip to content

Commit 1337847

Browse files
authored
fix: retry vault read on resume-window IPC failure to prevent false sign-out (#1085)
* retry vault read on resume-window IPC failure to prevent false sign-out * test(eid-wallet): cover VaultController.vault resume-retry and read coalescing * fix(eid-wallet): retry vault read on resume-window IPC failure to prevent false sign-out
1 parent 9faa3af commit 1337847

5 files changed

Lines changed: 162 additions & 10 deletions

File tree

infrastructure/eid-wallet/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"lint": "npx @biomejs/biome lint --write ./src",
1515
"check-lint": "npx @biomejs/biome lint ./src",
1616
"tauri": "tauri",
17+
"test": "vitest run",
1718
"storybook": "svelte-kit sync && storybook dev -p 6006",
1819
"build-storybook": "storybook build",
1920
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
// evault.ts imports these at runtime; mock them so the module loads in a plain
4+
// Node test env without pulling Tauri / wallet-sdk internals.
5+
vi.mock("wallet-sdk", () => ({ syncPublicKeyToEvault: vi.fn() }));
6+
vi.mock("../../services/NotificationService", () => ({
7+
default: {
8+
getInstance: () => ({ registerDevice: vi.fn(async () => true) }),
9+
},
10+
}));
11+
12+
import { VaultController } from "./evault";
13+
14+
const VAULT = { ename: "user@w3id.example", uri: "https://evault.example" };
15+
16+
/** Build a VaultController whose only exercised dependency is `store.get`. */
17+
function makeController(get: (key: string) => Promise<unknown>) {
18+
const store = { get } as unknown as ConstructorParameters<
19+
typeof VaultController
20+
>[0];
21+
const userController = {
22+
user: Promise.resolve(undefined),
23+
} as unknown as ConstructorParameters<typeof VaultController>[1];
24+
return new VaultController(store, userController);
25+
}
26+
27+
afterEach(() => {
28+
vi.useRealTimers();
29+
});
30+
31+
describe("VaultController.vault — #readVaultResilient retry behavior", () => {
32+
it("returns immediately for a resolved undefined (genuine logout), no retry", async () => {
33+
const get = vi.fn(async () => undefined);
34+
const vc = makeController(get);
35+
36+
await expect(vc.vault).resolves.toBeUndefined();
37+
expect(get).toHaveBeenCalledTimes(1); // no retry when the key is simply absent
38+
});
39+
40+
it("retries thrown store errors up to maxAttempts, then returns undefined without throwing", async () => {
41+
vi.useFakeTimers();
42+
const get = vi.fn(async () => {
43+
throw new Error(
44+
"Fetch API cannot load ipc://localhost/plugin:store|get",
45+
);
46+
});
47+
const vc = makeController(get);
48+
49+
const result = vc.vault;
50+
await vi.runAllTimersAsync(); // skip the 200ms backoffs
51+
52+
await expect(result).resolves.toBeUndefined();
53+
expect(get).toHaveBeenCalledTimes(10); // maxAttempts
54+
});
55+
56+
it("recovers the vault when the store IPC comes back mid-retry", async () => {
57+
vi.useFakeTimers();
58+
let calls = 0;
59+
const get = vi.fn(async () => {
60+
if (calls++ < 6) throw new Error("IPC custom protocol failed");
61+
return VAULT;
62+
});
63+
const vc = makeController(get);
64+
65+
const result = vc.vault;
66+
await vi.runAllTimersAsync();
67+
68+
await expect(result).resolves.toEqual(VAULT);
69+
expect(get).toHaveBeenCalledTimes(7);
70+
});
71+
72+
it("coalesces concurrent reads into a single in-flight store read", async () => {
73+
const get = vi.fn(async () => VAULT);
74+
const vc = makeController(get);
75+
76+
const [a, b] = await Promise.all([vc.vault, vc.vault]);
77+
78+
expect(a).toEqual(VAULT);
79+
expect(b).toEqual(VAULT);
80+
expect(get).toHaveBeenCalledTimes(1); // both callers shared one read
81+
82+
// ...and a later, independent access performs a fresh read.
83+
await vc.vault;
84+
expect(get).toHaveBeenCalledTimes(2);
85+
});
86+
});

infrastructure/eid-wallet/src/lib/global/controllers/evault.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -580,19 +580,58 @@ export class VaultController {
580580
}
581581
}
582582

583+
#vaultReadInFlight: Promise<Record<string, string> | undefined> | null =
584+
null;
585+
583586
get vault() {
584-
return this.#store
585-
.get<Record<string, string>>("vault")
586-
.then((vault) => {
587-
if (!vault) {
587+
// Coalesce concurrent reads so the resume window doesn't spin one
588+
// retry loop per caller; cleared on settle so later reads are fresh.
589+
if (this.#vaultReadInFlight) return this.#vaultReadInFlight;
590+
const read = this.#readVaultResilient().finally(() => {
591+
this.#vaultReadInFlight = null;
592+
});
593+
this.#vaultReadInFlight = read;
594+
return read;
595+
}
596+
597+
/**
598+
* Read the persisted vault, distinguishing "no vault" (resolved `undefined`
599+
* → genuine logout) from a transient store IPC failure (a throw).
600+
*
601+
* On iOS the `ipc://localhost` protocol is briefly torn down after the app
602+
* resumes from background, so `plugin:store|get` throws. The old getter
603+
* swallowed that into `undefined` — indistinguishable from "never logged
604+
* in" — so route guards redirected to /login: the intermittent sign-out.
605+
*
606+
* We retry on THROW so the resume window passes before reporting "no vault".
607+
* A genuine logout resolves `undefined` (no throw) and returns immediately.
608+
* Still never throws, still returns `Record | undefined` — callers unchanged.
609+
*/
610+
async #readVaultResilient(): Promise<Record<string, string> | undefined> {
611+
const maxAttempts = 10;
612+
const retryDelayMs = 200; // ~2s total budget covers the IPC-dead window
613+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
614+
try {
615+
const vault =
616+
await this.#store.get<Record<string, string>>("vault");
617+
return vault ?? undefined;
618+
} catch (error) {
619+
if (attempt === maxAttempts) {
620+
console.error(
621+
"Failed to get vault after retries (store IPC unavailable):",
622+
error,
623+
);
588624
return undefined;
589625
}
590-
return vault;
591-
})
592-
.catch((error) => {
593-
console.error("Failed to get vault:", error);
594-
return undefined;
595-
});
626+
console.warn(
627+
`[VaultController] vault read failed (attempt ${attempt}/${maxAttempts}), store IPC likely re-initializing after resume — retrying...`,
628+
);
629+
await new Promise((resolve) =>
630+
setTimeout(resolve, retryDelayMs),
631+
);
632+
}
633+
}
634+
return undefined;
596635
}
597636

598637
// Getters for internal properties
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Test stub for SvelteKit's `$env/static/public` (aliased in vitest.config.ts).
2+
// Real values are injected by SvelteKit at build time; tests only need the
3+
// symbols to exist so modules that import them can load.
4+
export const PUBLIC_EID_WALLET_TOKEN = "";
5+
export const PUBLIC_PROVISIONER_URL = "";
6+
export const PUBLIC_REGISTRY_URL = "";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { fileURLToPath } from "node:url";
2+
import { defineConfig } from "vitest/config";
3+
4+
// Dedicated, isolated test config — intentionally does NOT load the app's
5+
// Svelte/Tauri/Tailwind plugins. Unit tests here run in a plain Node
6+
// environment and stub the few runtime-only imports (see `$env` alias below;
7+
// heavier deps are mocked per-spec with `vi.mock`).
8+
export default defineConfig({
9+
resolve: {
10+
alias: {
11+
"$env/static/public": fileURLToPath(
12+
new URL("./src/test/env-static-public.ts", import.meta.url),
13+
),
14+
},
15+
},
16+
test: {
17+
environment: "node",
18+
include: ["src/**/*.spec.ts"],
19+
},
20+
});

0 commit comments

Comments
 (0)