From edb4f6b1f3d86e05d547beb59baf39dced1879b6 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 2 Aug 2026 13:15:41 +0800 Subject: [PATCH] save monero wallet scan progress --- apps/desktop/src/bun/index.ts | 19 +++++ apps/desktop/src/bun/lib/monero/wallet.ts | 87 +++++++++++++++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 5642121..d4830b4 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -11,6 +11,7 @@ import { existsSync } from 'fs' import { getENV } from './lib/get-env' import { createMoneroHandlers } from './rpc/monero' import { createXrpHandlers } from './rpc/xrp' +import { stop as walletStop } from './lib/monero' import type { MoneroWalletState } from './lib/monero' import { canPromptTouchID, promptTouchID } from './lib/biometric' import { @@ -84,6 +85,24 @@ const moneroState = { downloading: false, } +async function stopWalletServers(): Promise { + if (moneroState.manager) { + const manager = moneroState.manager + moneroState.manager = null + await walletStop(manager) + } +} + +// Save the monero wallet and shut down wallet-rpc gracefully on app quit +// (otherwise an orphaned process keeps the scan progress only in memory). +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, () => { + log(`${signal} received, stopping wallet servers...`) + void stopWalletServers().finally(() => process.exit(0)) + setTimeout(() => process.exit(0), 5000).unref?.() + }) +} + const rpc = BrowserView.defineRPC({ maxRequestTime: 120000, handlers: { diff --git a/apps/desktop/src/bun/lib/monero/wallet.ts b/apps/desktop/src/bun/lib/monero/wallet.ts index fa4b455..0bed1ba 100644 --- a/apps/desktop/src/bun/lib/monero/wallet.ts +++ b/apps/desktop/src/bun/lib/monero/wallet.ts @@ -12,6 +12,7 @@ const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081' export type MoneroWalletState = { wallet: MoneroWalletRpc | null process: Bun.Subprocess | null + saveTimer: ReturnType | null rpcPort: number rpcUser: string rpcPassword: string @@ -22,6 +23,7 @@ export function createMoneroWalletState(): MoneroWalletState { return { wallet: null, process: null, + saveTimer: null, rpcPort: 28084, rpcUser: 'koins', rpcPassword: 'koins-local-rpc', @@ -97,17 +99,44 @@ export async function start( ) state.daemonAddress = daemonAddress - // Kill any existing monero-wallet-rpc on the port (stale process from dev reloads) + // Kill any existing monero-wallet-rpc on the port (stale process from + // dev reloads or an unclean quit). Prefer SIGTERM so a live wallet gets + // a chance to save its scan progress before exiting. try { const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], { stdout: 'pipe', }) const pids = proc.stdout.toString().trim().split('\n').filter(Boolean) - for (const pid of pids) { - try { - process.kill(Number(pid), 'SIGKILL') - console.log(`[monero] killed stale process ${pid} on port ${state.rpcPort}`) - } catch {} + if (pids.length) { + console.log( + `[monero] stale process(es) on port ${state.rpcPort}: ${pids.join(', ')}`, + ) + for (const pid of pids) { + try { + process.kill(Number(pid), 'SIGTERM') + } catch {} + } + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + const check = Bun.spawnSync( + ['lsof', '-ti', `tcp:${state.rpcPort}`], + { stdout: 'pipe' }, + ) + if (!check.stdout.toString().trim()) break + await new Promise((r) => setTimeout(r, 250)) + } + const leftover = Bun.spawnSync( + ['lsof', '-ti', `tcp:${state.rpcPort}`], + { stdout: 'pipe' }, + ) + for (const pid of leftover.stdout.toString().trim().split('\n').filter(Boolean)) { + try { + process.kill(Number(pid), 'SIGKILL') + console.log( + `[monero] killed unresponsive process ${pid} on port ${state.rpcPort}`, + ) + } catch {} + } } } catch {} @@ -140,10 +169,30 @@ export async function start( `[monero] wallet-rpc process exited with code ${exitCode}`, error?.message ?? '', ) + if (state.saveTimer) { + clearInterval(state.saveTimer) + state.saveTimer = null + } + state.wallet = null + state.process = null }, }) console.log(`[monero] wallet-rpc pid: ${state.process.pid}`) + // Auto-save the wallet every 60s so scan progress survives an + // unexpected kill (without `store`, the wallet file keeps its old + // refresh height and every restart resyncs from block 0). + state.saveTimer = setInterval(async () => { + try { + if (state.wallet) { + await rawRpc(state, 'store') + console.log(`[monero] wallet auto-saved`) + } + } catch (e) { + console.log(`[monero] wallet auto-save failed:`, e) + } + }, 60000) + const reader = (state.process!.stdout as ReadableStream).getReader() ;(async () => { while (true) { @@ -183,17 +232,41 @@ export async function start( export async function stop(state: MoneroWalletState) { console.log(`[monero] stopping wallet manager...`) + if (state.saveTimer) { + clearInterval(state.saveTimer) + state.saveTimer = null + } + // Persist scan progress before shutting down. + try { + if (state.wallet) { + await rawRpc(state, 'store') + console.log(`[monero] wallet saved before stop`) + } + } catch (e) { + console.log(`[monero] wallet save before stop failed:`, e) + } if (state.process) { console.log( - `[monero] killing wallet-rpc process (pid ${state.process.pid})`, + `[monero] sending SIGTERM to wallet-rpc (pid ${state.process.pid})`, ) state.process.kill() + await Promise.race([state.process.exited, sleep(3000)]) + if (state.process.exitCode === null) { + console.log( + `[monero] wallet-rpc did not exit gracefully, sending SIGKILL`, + ) + state.process.kill('SIGKILL') + } state.process = null } state.wallet = null console.log(`[monero] wallet manager stopped`) } +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + export async function createWallet( state: MoneroWalletState, name: string,