Skip to content

Commit 897b00a

Browse files
ipdaeclaude
andcommitted
Fix white screen on node connection failure with retry and error UI
When all RPC nodes are unreachable, the launcher previously showed a permanent white screen with no feedback. This adds retry logic with exponential backoff, a 5s per-node timeout, and a ConnectionErrorView with a Retry button so users can recover without restarting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 46e986d commit 897b00a

4 files changed

Lines changed: 284 additions & 22 deletions

File tree

__tests__/node-retry.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
3+
/**
4+
* initializeNodeWithRetry 로직을 테스트하기 위해
5+
* Electron 의존성 없이 동일한 로직을 재현합니다.
6+
*/
7+
8+
type InitFn = () => Promise<string>;
9+
10+
async function initializeNodeWithRetry(
11+
initFn: InitFn,
12+
maxRetries: number = 2,
13+
baseDelayMs: number = 1000,
14+
): Promise<string> {
15+
let lastError: Error | undefined;
16+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
17+
try {
18+
return await initFn();
19+
} catch (e) {
20+
lastError = e instanceof Error ? e : new Error(String(e));
21+
if (attempt < maxRetries) {
22+
const delay = baseDelayMs * Math.pow(2, attempt);
23+
await new Promise((resolve) => setTimeout(resolve, delay));
24+
}
25+
}
26+
}
27+
throw lastError!;
28+
}
29+
30+
describe("initializeNodeWithRetry", () => {
31+
beforeEach(() => {
32+
vi.useFakeTimers();
33+
});
34+
35+
afterEach(() => {
36+
vi.useRealTimers();
37+
});
38+
39+
it("첫 시도 성공 시 재시도 없이 반환", async () => {
40+
const initFn = vi.fn().mockResolvedValue("node-1");
41+
42+
const promise = initializeNodeWithRetry(initFn);
43+
const result = await promise;
44+
45+
expect(result).toBe("node-1");
46+
expect(initFn).toHaveBeenCalledTimes(1);
47+
});
48+
49+
it("2번째 시도에서 성공", async () => {
50+
const initFn = vi
51+
.fn()
52+
.mockRejectedValueOnce(new Error("fail"))
53+
.mockResolvedValueOnce("node-2");
54+
55+
const promise = initializeNodeWithRetry(initFn);
56+
57+
// 첫 번째 실패 후 1초 대기
58+
await vi.advanceTimersByTimeAsync(1000);
59+
60+
const result = await promise;
61+
expect(result).toBe("node-2");
62+
expect(initFn).toHaveBeenCalledTimes(2);
63+
});
64+
65+
it("3번째 시도에서 성공", async () => {
66+
const initFn = vi
67+
.fn()
68+
.mockRejectedValueOnce(new Error("fail-1"))
69+
.mockRejectedValueOnce(new Error("fail-2"))
70+
.mockResolvedValueOnce("node-3");
71+
72+
const promise = initializeNodeWithRetry(initFn);
73+
74+
// 1초 (1st backoff) + 2초 (2nd backoff)
75+
await vi.advanceTimersByTimeAsync(1000);
76+
await vi.advanceTimersByTimeAsync(2000);
77+
78+
const result = await promise;
79+
expect(result).toBe("node-3");
80+
expect(initFn).toHaveBeenCalledTimes(3);
81+
});
82+
83+
it("모든 시도 실패 시 마지막 에러를 throw", async () => {
84+
const initFn = vi
85+
.fn()
86+
.mockRejectedValueOnce(new Error("fail-1"))
87+
.mockRejectedValueOnce(new Error("fail-2"))
88+
.mockRejectedValueOnce(new Error("fail-3"));
89+
90+
const promise = initializeNodeWithRetry(initFn);
91+
// catch를 미리 걸어서 unhandled rejection 방지
92+
const caught = promise.catch((e) => e);
93+
94+
await vi.advanceTimersByTimeAsync(1000);
95+
await vi.advanceTimersByTimeAsync(2000);
96+
97+
const error = await caught;
98+
expect(error).toBeInstanceOf(Error);
99+
expect(error.message).toBe("fail-3");
100+
expect(initFn).toHaveBeenCalledTimes(3);
101+
});
102+
103+
it("backoff 간격이 지수적으로 증가 (1s, 2s)", async () => {
104+
const timestamps: number[] = [];
105+
const initFn = vi.fn().mockImplementation(() => {
106+
timestamps.push(Date.now());
107+
return Promise.reject(new Error("fail"));
108+
});
109+
110+
const promise = initializeNodeWithRetry(initFn).catch(() => {});
111+
112+
await vi.advanceTimersByTimeAsync(1000);
113+
await vi.advanceTimersByTimeAsync(2000);
114+
await promise;
115+
116+
expect(timestamps).toHaveLength(3);
117+
// 1번째 → 2번째: 1000ms
118+
expect(timestamps[1] - timestamps[0]).toBe(1000);
119+
// 2번째 → 3번째: 2000ms
120+
expect(timestamps[2] - timestamps[1]).toBe(2000);
121+
});
122+
});
123+
124+
describe("PreloadEnded timeout", () => {
125+
it("5초 초과 시 false 반환", async () => {
126+
const slowRequest = new Promise<boolean>((resolve) => {
127+
setTimeout(() => resolve(true), 10000);
128+
});
129+
130+
const result = await Promise.race([
131+
slowRequest,
132+
new Promise<boolean>((_, reject) =>
133+
setTimeout(() => reject(new Error("timeout")), 5000),
134+
),
135+
]).catch(() => false);
136+
137+
expect(result).toBe(false);
138+
});
139+
});

src/config.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ export class NodeInfo {
6666
public async PreloadEnded(): Promise<boolean> {
6767
const headlessGraphQLSDK = getSdk(this.GraphqlClient());
6868
try {
69-
const ended = await headlessGraphQLSDK.PreloadEnded();
69+
const ended = await Promise.race([
70+
headlessGraphQLSDK.PreloadEnded(),
71+
new Promise<never>((_, reject) =>
72+
setTimeout(() => reject(new Error("PreloadEnded timeout")), 5000),
73+
),
74+
]);
7075
if (ended.status === 200) {
7176
this.clientCount = ended.data!.rpcInformation.totalCount;
7277
this.tip = ended.data!.nodeStatus.tip.index;
@@ -330,3 +335,29 @@ export async function initializeNode(
330335
);
331336
return nodeInfo;
332337
}
338+
339+
export async function initializeNodeWithRetry(
340+
rpcEndpoints: RpcEndpoints,
341+
quick: boolean = false,
342+
maxRetries: number = 2,
343+
baseDelayMs: number = 1000,
344+
): Promise<NodeInfo> {
345+
let lastError: Error | undefined;
346+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
347+
try {
348+
return await initializeNode(rpcEndpoints, quick);
349+
} catch (e) {
350+
lastError = e instanceof Error ? e : new Error(String(e));
351+
console.warn(
352+
`initializeNode attempt ${attempt + 1}/${maxRetries + 1} failed: ${
353+
lastError.message
354+
}`,
355+
);
356+
if (attempt < maxRetries) {
357+
const delay = baseDelayMs * Math.pow(2, attempt);
358+
await new Promise((resolve) => setTimeout(resolve, delay));
359+
}
360+
}
361+
}
362+
throw lastError!;
363+
}

src/main/main.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
netenv,
77
baseUrl,
88
CONFIG_FILE_PATH,
9-
initializeNode,
9+
initializeNodeWithRetry,
1010
NodeInfo,
1111
} from "../config";
1212
import {
@@ -70,6 +70,8 @@ let registry: Planet[];
7070
let accessiblePlanets: Planet[];
7171
let remoteNode: NodeInfo;
7272
let geoBlock: { ip: string; country: string; isWhitelist?: boolean };
73+
// eslint-disable-next-line prefer-const
74+
let configInitError: string | null = null;
7375

7476
const useUpdate = getConfig("UseUpdate", process.env.NODE_ENV === "production");
7577

@@ -189,7 +191,7 @@ async function initializeConfig() {
189191
return accessiblePlanets[0];
190192
})();
191193

192-
remoteNode = await initializeNode(planet.rpcEndpoints, true);
194+
remoteNode = await initializeNodeWithRetry(planet.rpcEndpoints, true);
193195
console.log(registry);
194196

195197
const localConfigVersion = getConfig("ConfigVersion");
@@ -212,6 +214,7 @@ async function initializeConfig() {
212214
console.error(
213215
`An unexpected error occurred during fetching remote config. ${error}`,
214216
);
217+
configInitError = error instanceof Error ? error.message : String(error);
215218
}
216219

217220
log.transports.file.maxSize = getConfig("LogSizeBytes");
@@ -382,12 +385,33 @@ function initializeIpc() {
382385
});
383386

384387
ipcMain.handle("get-planetary-info", async () => {
385-
// Synchronously wait until registry / remote node initialized
386-
// This should return, otherwise entry point of renderer will stuck in white screen.
388+
const MAX_WAIT_MS = 30_000;
389+
const POLL_INTERVAL_MS = 100;
390+
let waited = 0;
391+
387392
while (!registry || !remoteNode || !accessiblePlanets) {
388-
await utils.sleep(100);
393+
if (configInitError) {
394+
return { error: configInitError };
395+
}
396+
if (waited >= MAX_WAIT_MS) {
397+
return { error: "Timed out waiting for node initialization." };
398+
}
399+
await utils.sleep(POLL_INTERVAL_MS);
400+
waited += POLL_INTERVAL_MS;
401+
}
402+
return { data: [registry, remoteNode, accessiblePlanets] };
403+
});
404+
405+
ipcMain.handle("retry-planetary-init", async () => {
406+
configInitError = null;
407+
remoteNode = undefined as unknown as NodeInfo;
408+
await initializeConfig();
409+
if (remoteNode && registry && accessiblePlanets) {
410+
return { data: [registry, remoteNode, accessiblePlanets] };
389411
}
390-
return [registry, remoteNode, accessiblePlanets];
412+
return {
413+
error: configInitError ?? "Failed to initialize after retry.",
414+
};
391415
});
392416

393417
ipcMain.handle("check-geoblock", async () => {

src/renderer/App.tsx

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,99 @@ import { observer } from "mobx-react";
1313
import { Planet } from "src/interfaces/registry";
1414
import { NodeInfo } from "src/config";
1515

16+
function ConnectionErrorView({
17+
error,
18+
onRetry,
19+
}: {
20+
error: string;
21+
onRetry: () => void;
22+
}) {
23+
const [retrying, setRetrying] = useState(false);
24+
return (
25+
<div
26+
style={{
27+
width: "100%",
28+
height: "100%",
29+
display: "flex",
30+
flexDirection: "column",
31+
alignItems: "center",
32+
justifyContent: "center",
33+
backgroundColor: "#1d1e1f",
34+
color: "white",
35+
fontFamily: "sans-serif",
36+
}}
37+
>
38+
<h1 style={{ color: "#74f4bc", marginBottom: 16 }}>Connection Failed</h1>
39+
<p style={{ maxWidth: 400, textAlign: "center", marginBottom: 24 }}>
40+
Unable to connect to the Nine Chronicles network. Please check your
41+
internet connection.
42+
</p>
43+
<p style={{ fontSize: 12, color: "#888", marginBottom: 24 }}>{error}</p>
44+
<button
45+
onClick={() => {
46+
setRetrying(true);
47+
onRetry();
48+
}}
49+
disabled={retrying}
50+
style={{
51+
backgroundColor: retrying ? "#555" : "#3e2a8d",
52+
color: "white",
53+
border: "none",
54+
padding: "12px 36px",
55+
fontSize: 16,
56+
fontWeight: "bold",
57+
cursor: retrying ? "default" : "pointer",
58+
borderRadius: 4,
59+
}}
60+
>
61+
{retrying ? "Retrying..." : "Retry"}
62+
</button>
63+
</div>
64+
);
65+
}
66+
1667
function App() {
1768
const { planetary, account, game } = useStore();
1869
const client = useApolloClient();
70+
const [initError, setInitError] = useState<string | null>(null);
71+
const [retryCount, setRetryCount] = useState(0);
72+
73+
const handlePlanetaryResult = (result: {
74+
data?: [Planet[], NodeInfo, Planet[]];
75+
error?: string;
76+
}) => {
77+
if (result.error) {
78+
setInitError(result.error);
79+
setRetryCount((c) => c + 1);
80+
return;
81+
}
82+
if (result.data) {
83+
setInitError(null);
84+
planetary.init(result.data[0], result.data[1], result.data[2]);
85+
}
86+
};
1987

20-
/** Asynchronous Invoke in useEffect
21-
* As ipcRenderer.invoke() is async we're not guaranteed to receive IPC result on time
22-
*
23-
* Also even if we use .then() to force synchronous flow useEffect() won't wait.
24-
* But we need these to render login page.
25-
* hence we render null until all three initialized;
26-
* Planetary, GQL client, AccountStore
27-
*
28-
* It could be better if we can have react suspense here.
29-
*/
3088
useEffect(() => {
31-
ipcRenderer
32-
.invoke("get-planetary-info")
33-
.then((info: [Planet[], NodeInfo, Planet[]]) => {
34-
planetary.init(info[0], info[1], info[2]);
35-
});
89+
ipcRenderer.invoke("get-planetary-info").then(handlePlanetaryResult);
3690
ipcRenderer
3791
.invoke("check-geoblock")
3892
.then((v) => game.setGeoBlock(v.country, v.isWhitelist ?? false));
3993
}, []);
4094

95+
if (initError) {
96+
return (
97+
<ConnectionErrorView
98+
error={initError}
99+
onRetry={() => {
100+
ipcRenderer
101+
.invoke("retry-planetary-init")
102+
.then(handlePlanetaryResult);
103+
}}
104+
key={retryCount}
105+
/>
106+
);
107+
}
108+
41109
if (planetary.node === null) return null;
42110
if (!account.isInitialized) return null;
43111
if (client === null) return null;

0 commit comments

Comments
 (0)