From 11e0bcdaf6c8f1818a9f4bd26873eaf94ce3462d Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 2 Aug 2026 14:54:39 +0800 Subject: [PATCH] add timeouts so syncing wallet never hangs the UI --- apps/desktop/src/bun/lib/monero/wallet.ts | 96 +++++++++++--- apps/desktop/src/bun/rpc/monero/index.ts | 9 +- .../src/lib/states/monero-wallet.svelte.ts | 119 ++++++++++++++---- apps/desktop/src/routes/index.svelte | 24 ++-- .../src/routes/monero/wallet/[name].svelte | 10 +- 5 files changed, 203 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/bun/lib/monero/wallet.ts b/apps/desktop/src/bun/lib/monero/wallet.ts index 58bf09c..5af424b 100644 --- a/apps/desktop/src/bun/lib/monero/wallet.ts +++ b/apps/desktop/src/bun/lib/monero/wallet.ts @@ -41,13 +41,40 @@ function rpcConnection( }) } +function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ) + promise.then( + (v) => { + clearTimeout(timer) + resolve(v) + }, + (e) => { + clearTimeout(timer) + reject(e) + }, + ) + }) +} + async function rawRpc( state: MoneroWalletState, method: string, params: Record = {}, ): Promise { const conn = rpcConnection(state) - const res = await conn.sendJsonRequest(method, params) + const res = await withTimeout( + conn.sendJsonRequest(method, params), + 30000, + `wallet-rpc ${method}`, + ) if (res?.error) throw new Error(res.error.message || JSON.stringify(res.error)) return res?.result @@ -62,7 +89,11 @@ async function waitForRpc( while (Date.now() - start < timeoutMs) { try { const conn = rpcConnection(state) - const res = await conn.sendJsonRequest('get_version', {}) + const res = await withTimeout( + conn.sendJsonRequest('get_version', {}), + 5000, + 'wallet-rpc probe', + ) if (res?.result?.version) { console.log(`[monero] RPC ready after ${Date.now() - start}ms`) return @@ -290,7 +321,11 @@ export async function createWallet( language: 'English', }) if (!state.wallet) throw new Error('Wallet RPC not started') - const address = await state.wallet.getAddress(0, 0) + const address = (await withTimeout( + state.wallet.getAddress(0, 0), + 30000, + 'wallet get_address', + )) as string console.log(`[monero] wallet created: ${name} -> ${address}`) return { mnemonic: result.mnemonic, address } } @@ -329,7 +364,7 @@ export async function openWallet( state.wallet = await connectClient(state) } try { - await state.wallet.openWallet(name, password) + await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`) log(`[monero] wallet opened: ${name}`) } catch (e) { logError(`[monero] wallet open failed (${name})`, e) @@ -348,8 +383,16 @@ export async function getBalance( state: MoneroWalletState, ): Promise<{ balance: bigint; unlocked: bigint }> { if (!state.wallet) throw new Error('Wallet RPC not started') - const balance = (await state.wallet.getBalance()) as bigint - const unlocked = (await state.wallet.getUnlockedBalance()) as bigint + const balance = (await withTimeout( + state.wallet.getBalance(), + 30000, + 'wallet get_balance', + )) as bigint + const unlocked = (await withTimeout( + state.wallet.getUnlockedBalance(), + 30000, + 'wallet get_unlocked_balance', + )) as bigint console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`) return { balance, unlocked } } @@ -360,9 +403,10 @@ export async function getAddress( subIdx = 0, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') - const address = (await state.wallet.getAddress( - accountIdx, - subIdx, + const address = (await withTimeout( + state.wallet.getAddress(accountIdx, subIdx), + 30000, + 'wallet get_address', )) as string console.log(`[monero] address: ${address}`) return address @@ -398,7 +442,11 @@ export async function getAccounts( state: MoneroWalletState, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') - const accounts = (await state.wallet.getAccounts(true)) as any[] + const accounts = (await withTimeout( + state.wallet.getAccounts(true), + 30000, + 'wallet get_accounts', + )) as any[] console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`) if (accounts?.length) { for (const acct of accounts) { @@ -543,7 +591,11 @@ export async function getHeight( state: MoneroWalletState, ): Promise { if (!state.wallet) throw new Error('Wallet RPC not started') - const height = (await state.wallet.getHeight()) as number + const height = (await withTimeout( + state.wallet.getHeight(), + 30000, + 'wallet get_height', + )) as number console.log(`[monero] wallet height: ${height}`) return height } @@ -587,7 +639,11 @@ export async function getDaemonHeight( const daemon = await connectToDaemonRpc( `http://${state.daemonAddress}`, ) - const height = (await daemon.getHeight()) as number + const height = (await withTimeout( + daemon.getHeight(), + 15000, + 'daemon get_height', + )) as number console.log(`[monero] daemon height: ${height}`) return height } catch (e) { @@ -665,9 +721,13 @@ export async function isWalletOpen( ): Promise { if (!state.wallet) return false try { - await state.wallet.getHeight() + await withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height') return true - } catch { + } catch (e) { + // A timeout usually means the wallet-rpc is busy syncing, not that + // the wallet is closed. Assume still-open so the UI doesn't flip to + // "closed" and try to re-open an already-open wallet. + if (e instanceof Error && e.message.includes('timed out')) return true return false } } @@ -677,10 +737,12 @@ export async function isConnected( ): Promise { if (!state.wallet) return false try { - const daemon = await connectToDaemonRpc( - `http://${state.daemonAddress}`, + const daemon = await withTimeout( + connectToDaemonRpc(`http://${state.daemonAddress}`), + 8000, + 'daemon connect', ) - await daemon.getHeight() + await withTimeout(daemon.getHeight(), 8000, 'daemon get_height') return true } catch (e) { console.log(`[monero] connection check failed:`, e) diff --git a/apps/desktop/src/bun/rpc/monero/index.ts b/apps/desktop/src/bun/rpc/monero/index.ts index f38532a..e6b0910 100644 --- a/apps/desktop/src/bun/rpc/monero/index.ts +++ b/apps/desktop/src/bun/rpc/monero/index.ts @@ -108,12 +108,19 @@ export function createMoneroHandlers(state: { console.log('[rpc] moneroRestoreWallet:', name) if (!state.manager) throw new Error('Monero wallet RPC not started') + // Default to ~2 days of history instead of a full genesis scan + // (which can take hours against a remote daemon). + let height = restoreHeight + if (!height || height === 0) { + const tip = await walletGetDaemonHeight(state.manager) + if (tip > 1440) height = tip - 1440 + } await walletRestore( state.manager, name, password, mnemonic, - restoreHeight, + height, ) const address = await walletGetAddress(state.manager) console.log( diff --git a/apps/desktop/src/lib/states/monero-wallet.svelte.ts b/apps/desktop/src/lib/states/monero-wallet.svelte.ts index 1e45025..29ddab3 100644 --- a/apps/desktop/src/lib/states/monero-wallet.svelte.ts +++ b/apps/desktop/src/lib/states/monero-wallet.svelte.ts @@ -51,6 +51,27 @@ export const MoneroWallet = () => { let passwordRequired = $state(false) let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null let loginInFlight: Promise | null = null + let refreshInFlight: Promise | null = null + let syncing = $state(false) + let syncRetryTimer: ReturnType | null = null + + const rpcTimeout = (promise: Promise, ms: number, label: string): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ) + promise.then( + (v) => { + clearTimeout(timer) + resolve(v) + }, + (e) => { + clearTimeout(timer) + reject(e) + }, + ) + }) const beginOpening = () => { openingDepth += 1 @@ -203,14 +224,22 @@ export const MoneroWallet = () => { const checkStatus = async () => { if (!electrobun.rpc) return const [binStatus] = await tryCatch( - electrobun.rpc.request.moneroBinaryStatus({}), + rpcTimeout( + electrobun.rpc.request.moneroBinaryStatus({}), + 15000, + 'moneroBinaryStatus', + ), ) if (binStatus) { installed = binStatus.installed downloading = binStatus.downloading } const [status] = await tryCatch( - electrobun.rpc.request.moneroWalletStatus({}), + rpcTimeout( + electrobun.rpc.request.moneroWalletStatus({}), + 30000, + 'moneroWalletStatus', + ), ) if (status) { running = status.running @@ -362,32 +391,67 @@ export const MoneroWallet = () => { if (remember) await moneroStorePassword(target, password) } + const scheduleSyncRetry = () => { + if (syncRetryTimer) clearTimeout(syncRetryTimer) + syncRetryTimer = setTimeout(async () => { + syncRetryTimer = null + if (walletOpen && !loading) await refresh() + }, 15000) + } + const refresh = async () => { - if (!electrobun.rpc) return - loading = true - try { - const [bal] = await tryCatch( - electrobun.rpc.request.moneroGetBalance({}), - ) - if (bal) { - balAtomic = bal.balance - unlockedAtomic = bal.unlocked - address = bal.address - height = bal.height - daemonHeight = bal.daemonHeight + const rpc = electrobun.rpc + if (!rpc) return + if (refreshInFlight) return refreshInFlight + refreshInFlight = (async () => { + loading = true + let failed = false + try { + const [bal, balErr] = await tryCatch( + rpcTimeout( + rpc.request.moneroGetBalance({}), + 20000, + 'moneroGetBalance', + ), + ) + if (balErr) failed = true + if (bal) { + balAtomic = bal.balance + unlockedAtomic = bal.unlocked + address = bal.address + height = bal.height + daemonHeight = bal.daemonHeight + } + const [result, txErr] = await tryCatch( + rpcTimeout( + rpc.request.moneroGetTransactions({ + accountIndex: selectedAccountIndex, + }), + 20000, + 'moneroGetTransactions', + ), + ) + if (txErr) failed = true + txs = result ?? [] + const ok = await fetchAccounts() + if (!ok) failed = true + if (failed) { + // The wallet-rpc is likely busy syncing; keep the stale + // balance and retry shortly instead of showing an error. + syncing = true + scheduleSyncRetry() + } else { + syncing = false + } + } catch (e) { + syncing = true + scheduleSyncRetry() + } finally { + loading = false + refreshInFlight = null } - const [result] = await tryCatch( - electrobun.rpc.request.moneroGetTransactions({ - accountIndex: selectedAccountIndex, - }), - ) - txs = result ?? [] - await fetchAccounts() - } catch (e) { - error = e instanceof Error ? e.message : 'Monero refresh failed' - } finally { - loading = false - } + })() + return refreshInFlight } const send = async ( @@ -506,6 +570,9 @@ export const MoneroWallet = () => { get passwordRequired() { return passwordRequired }, + get syncing() { + return syncing + }, get error() { return error }, diff --git a/apps/desktop/src/routes/index.svelte b/apps/desktop/src/routes/index.svelte index 2adbe54..090a052 100644 --- a/apps/desktop/src/routes/index.svelte +++ b/apps/desktop/src/routes/index.svelte @@ -250,16 +250,20 @@ - {#if moneroWallet.walletOpen} -

{fmtBal(xmrBalance, 4)}

- {#if xmrPrice} -

- ${fmtUsd(xmrTotalUsd)} -

-

- ${fmtUsd(parseFloat(xmrPrice) )} / XMR -

- {/if} + {#if moneroWallet.walletOpen} +

{fmtBal(xmrBalance, 4)}

+ {#if moneroWallet.syncing} +

Syncing…

+ {:else if xmrPrice} +

+ ${fmtUsd(xmrTotalUsd)} +

+ {/if} + {#if xmrPrice} +

+ ${fmtUsd(parseFloat(xmrPrice) )} / XMR +

+ {/if} {:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}

Wallet locked — unlock to view balance

{:else if moneroWallet.opening} diff --git a/apps/desktop/src/routes/monero/wallet/[name].svelte b/apps/desktop/src/routes/monero/wallet/[name].svelte index c31f185..e662156 100644 --- a/apps/desktop/src/routes/monero/wallet/[name].svelte +++ b/apps/desktop/src/routes/monero/wallet/[name].svelte @@ -203,7 +203,7 @@ {:else}
- {#if w.loading} + {#if w.loading && !w.syncing}
{:else}

Balance

@@ -223,6 +223,14 @@ Unlocked: {w.unlocked} XMR

{/if} + {#if w.syncing} +
+ +

+ Syncing wallet — balance updates when the scan completes +

+
+ {/if}