perf(aegis): stop freezing Theseus — a 7.4 MB store parsed on every read
Reported as "opening Aegis makes Theseus get stuck / not responding", and it was Aegis's fault. The host's add-on store is ONE JSON file per add-on, and storage.get() does a readFileSync plus a JSON.parse of the whole thing on every call — synchronously, on the Electron main thread, the thread that drives the entire browser. storage.set() additionally stringifies and writes all of it. That store had grown to 7.4 MB, 99.9% of it one wallet's txCache: getTx() kept every transaction it ever fetched, with full vin/vout arrays, and a busy chipnet test wallet had thousands. Measured on the real file: parse 59 ms, stringify 39 ms. So one storage.get blocked the UI for ~60 ms, one set for ~99 ms, and fullState() — which reads the store seven times over selectedWalletId, walletRoles, walletEntries, snapshotForSelected and the server list — cost ~420 ms. emitState() runs on every adapter change, across nine wallets, so the main thread was never given back. Three changes: - txCache is capped at 400 entries, pruned newest-first by block time (unconfirmed entries sort as newest — they are the current ones). Worst case ~0.3 MB per wallet instead of unbounded. - TX_CACHE_VERSION 3, so existing oversized caches are discarded on first load rather than needing a manual clear. - loadHistory() only persists when something actually changed; an idle wallet was rewriting the whole file on every poll for nothing. - api.storage gains a write-through read cache, so repeated gets cost one parse per process instead of one per call. Writes still go to the host unchanged. Safe because this process is the only writer — Aegis's panel talks over addon messages and never touches addon storage; if that changes, the cache has to go.
This commit is contained in:
parent
64b75c5bf4
commit
40683c84cf
3 changed files with 67 additions and 3 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "aegis",
|
"id": "aegis",
|
||||||
"name": "Aegis Wallet",
|
"name": "Aegis Wallet",
|
||||||
"version": "0.27.0",
|
"version": "0.27.1",
|
||||||
"category": "plugin",
|
"category": "plugin",
|
||||||
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.",
|
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,35 @@ function coinsForPanel() {
|
||||||
|
|
||||||
// ---- wallet list ------------------------------------------------------------
|
// ---- wallet list ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Replace api.storage with a write-through read cache. Writes still go to the
|
||||||
|
// host so nothing changes about durability or who owns the file; only the
|
||||||
|
// repeated parsing of it disappears. Falls back to the host's storage
|
||||||
|
// untouched if the api object will not accept the substitution.
|
||||||
|
function installStorageCache(api) {
|
||||||
|
const raw = api.storage;
|
||||||
|
if (!raw || typeof raw.all !== "function") return false;
|
||||||
|
let mem = null;
|
||||||
|
const load = () => {
|
||||||
|
if (!mem) { try { mem = raw.all() || {}; } catch { mem = {}; } }
|
||||||
|
return mem;
|
||||||
|
};
|
||||||
|
const cached = {
|
||||||
|
get: (key, fallback = null) => {
|
||||||
|
const s = load();
|
||||||
|
return Object.prototype.hasOwnProperty.call(s, key) ? s[key] : fallback;
|
||||||
|
},
|
||||||
|
set: (key, value) => {
|
||||||
|
load()[key] = value;
|
||||||
|
raw.set(key, value);
|
||||||
|
},
|
||||||
|
all: () => ({ ...load() }),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
api.storage = cached;
|
||||||
|
return api.storage === cached;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
function readWallets(api) {
|
function readWallets(api) {
|
||||||
const raw = api.storage.get("wallets", null);
|
const raw = api.storage.get("wallets", null);
|
||||||
return Array.isArray(raw) ? raw : null;
|
return Array.isArray(raw) ? raw : null;
|
||||||
|
|
@ -3223,6 +3252,17 @@ module.exports = {
|
||||||
// so the toolbar dock button renders the aegis.x brand mark instead
|
// so the toolbar dock button renders the aegis.x brand mark instead
|
||||||
// of a fallback emoji.
|
// of a fallback emoji.
|
||||||
api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" });
|
api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" });
|
||||||
|
// Serve reads from memory. The host's storage.get() does a readFileSync
|
||||||
|
// plus a JSON.parse of the ENTIRE add-on store on every single call, on
|
||||||
|
// the Electron main thread. fullState() alone reads it seven-odd times
|
||||||
|
// (selectedWalletId, walletRoles, walletEntries, snapshotForSelected,
|
||||||
|
// the server list...) and emitState() runs on every adapter change, so a
|
||||||
|
// large store made opening Aegis hang the whole browser.
|
||||||
|
//
|
||||||
|
// Safe because this process is the only writer: Aegis's panel talks to
|
||||||
|
// the host over addon messages and never touches addon storage directly.
|
||||||
|
// If that ever changes, this cache has to go or be invalidated.
|
||||||
|
installStorageCache(api);
|
||||||
const c = ctx = {
|
const c = ctx = {
|
||||||
api,
|
api,
|
||||||
d: null,
|
d: null,
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,36 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
|
||||||
// indistinguishable from "no token", so they are dropped once rather than
|
// indistinguishable from "no token", so they are dropped once rather than
|
||||||
// trusted. Only the pre-1.5 fallback path reads this for classification,
|
// trusted. Only the pre-1.5 fallback path reads this for classification,
|
||||||
// but a warm v1 cache there would silently report a token wallet as empty.
|
// but a warm v1 cache there would silently report a token wallet as empty.
|
||||||
const TX_CACHE_VERSION = 2;
|
// v3 because v2 was unbounded. getTx() kept every transaction it ever
|
||||||
|
// fetched, with full vin/vout arrays, and a busy chipnet test wallet grew
|
||||||
|
// this to 7.7 MB. That matters enormously, because the host's addon store
|
||||||
|
// is ONE JSON file per add-on that is read, parsed, stringified and written
|
||||||
|
// whole and SYNCHRONOUSLY on the Electron main thread — the thread that
|
||||||
|
// drives the entire browser. A 7.7 MB cache turned every storage access
|
||||||
|
// into a multi-hundred-millisecond freeze of all of Theseus. Bumping the
|
||||||
|
// version also discards existing oversized caches on first load.
|
||||||
|
const TX_CACHE_VERSION = 3;
|
||||||
|
const TX_CACHE_MAX = 400;
|
||||||
let txCache = storage.get("txCache", {}) || {};
|
let txCache = storage.get("txCache", {}) || {};
|
||||||
if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) {
|
if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) {
|
||||||
txCache = {};
|
txCache = {};
|
||||||
storage.set("txCache", txCache);
|
storage.set("txCache", txCache);
|
||||||
storage.set("txCacheVersion", TX_CACHE_VERSION);
|
storage.set("txCacheVersion", TX_CACHE_VERSION);
|
||||||
}
|
}
|
||||||
|
// Only write when something actually changed. loadHistory() used to persist
|
||||||
|
// the cache on every refresh, so an idle wallet rewrote the whole file on
|
||||||
|
// every poll for nothing.
|
||||||
|
let txDirty = false;
|
||||||
|
function pruneTxCache() {
|
||||||
|
const ids = Object.keys(txCache);
|
||||||
|
if (ids.length <= TX_CACHE_MAX) return;
|
||||||
|
// Newest first by block time. Unconfirmed entries have time 0 but are by
|
||||||
|
// definition current, so they sort as newest rather than being evicted
|
||||||
|
// first. Anything dropped is re-fetchable on demand.
|
||||||
|
ids.sort((a, b) => ((txCache[b].time || Infinity) - (txCache[a].time || Infinity)));
|
||||||
|
for (const id of ids.slice(TX_CACHE_MAX)) delete txCache[id];
|
||||||
|
txDirty = true;
|
||||||
|
}
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let subscribedHeaders = false;
|
let subscribedHeaders = false;
|
||||||
|
|
||||||
|
|
@ -230,6 +253,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
|
||||||
size: raw.size || 0,
|
size: raw.size || 0,
|
||||||
};
|
};
|
||||||
txCache[txid] = slim;
|
txCache[txid] = slim;
|
||||||
|
txDirty = true;
|
||||||
return slim;
|
return slim;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,7 +295,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
state.history = out;
|
state.history = out;
|
||||||
storage.set("txCache", txCache);
|
if (txDirty) { pruneTxCache(); storage.set("txCache", txCache); txDirty = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function scriptToAddress(scriptHex) {
|
function scriptToAddress(scriptHex) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue