theseus/lib/addon-store.cjs
Local Dev 65ef59f5c8 Theseus: add-on stores live in memory — no more 16 s "Not Responding" at launch
An add-on's storage.get read and parsed its whole store file on every call,
and storage.set read, parsed and rewrote it — synchronously, on the main
thread. Traced on a real profile (installed 0.3.70): with a 7.5 MB Aegis
store, 30 of the first 35 s of main-thread time went to storage.get, the
window sat in "Not Responding" from 3 s to 19 s, and the first page showed at
19 s. One get cost ~73 ms; Aegis does dozens per state update.

lib/addon-store.cjs keeps one in-memory copy per store, shared by the
add-on's api.storage (addons-host.js) and its pages (addon-storage-* IPC in
main.js). After a one-time load a get costs microseconds; values are copied
in and out (structuredClone), so callers keep the old semantics. Writes are
coalesced (100 ms) and land as temp-file + rename, and are flushed on quit;
a store that doesn't parse is moved aside instead of being replaced by {}.

Measured on copies of the same profile, dev build:
  first page 16.9-17.6 s -> 1.5-1.7 s; main thread blocked 24.7-25.8 s of
  30 -> 1.0-1.1 s; longest freeze 13.7-15.0 s -> 0.6 s.

The Aegis side (capping its unbounded txCache) ships separately through
Aegis's own update channel. The boot tracer gains total/longest block columns.
2026-10-03 16:08:22 +02:00

96 lines
3.9 KiB
JavaScript

// Per-add-on key/value stores (<userData>/extensions-data/<id>.json), shared
// by the add-on's own API (addons-host.js) and its pages (main.js IPC).
//
// The store used to be read and parsed from disk on EVERY get, and read +
// parsed + rewritten on every set — synchronously, on the Electron main
// thread. With a 7.5 MB Aegis store that was ~60 ms per get, and Aegis does
// many per state update: measured 2026-10-03 on a real profile, 30 s of the
// first 35 s of the main thread went to storage.get, and the window sat in
// "Not Responding" for 16 s after launch.
//
// Now each store is read once and kept in memory. A get costs only the value
// it returns; values are copied in and out (structuredClone), so a caller
// that mutates what it got — or what it passed to set — never changes the
// store behind its back, exactly as with the old parse-per-call. Writes are
// coalesced (WRITE_DELAY_MS) and land as an atomic temp-file + rename, so a
// crash mid-write can't leave a truncated file; flushAll() runs on quit.
// A store file that exists but doesn't parse is moved aside before the
// first write instead of being silently replaced by {} (which lost every key).
"use strict";
const fs = require("fs");
const path = require("path");
const WRITE_DELAY_MS = 100;
const stores = new Map(); // file -> Store
class Store {
constructor(file, log) {
this.file = file;
this.log = log || (() => {});
this.data = null; // loaded lazily
this.timer = null;
this.dirty = false;
}
_load() {
if (this.data) return this.data;
let text = null;
try { text = fs.readFileSync(this.file, "utf8"); } catch (e) { if (e.code !== "ENOENT") this.log(`store read failed (${path.basename(this.file)}):`, e.message); }
if (text == null) { this.data = {}; return this.data; }
try {
const parsed = JSON.parse(text);
this.data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch (e) {
// Keep the unreadable file for recovery rather than overwriting it.
const aside = `${this.file}.corrupt-${Date.now()}`;
try { fs.renameSync(this.file, aside); } catch {}
this.log(`store ${path.basename(this.file)} did not parse (${e.message}); kept as ${path.basename(aside)}`);
this.data = {};
}
return this.data;
}
get(key, fallback = null) {
const d = this._load();
return key in d ? structuredClone(d[key]) : fallback;
}
set(key, value) {
const d = this._load();
// JSON drops undefined: the old write-then-reparse made such a key vanish,
// so a later get returned the fallback. Keep that.
if (value === undefined) delete d[key]; else d[key] = structuredClone(value);
this._schedule();
}
all() { return structuredClone(this._load()); }
_schedule() {
this.dirty = true;
if (this.timer) return;
this.timer = setTimeout(() => { this.timer = null; this.flush(); }, WRITE_DELAY_MS);
}
flush() {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (!this.dirty || !this.data) return true;
const tmp = `${this.file}.tmp`;
try {
fs.mkdirSync(path.dirname(this.file), { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(this.data));
fs.renameSync(tmp, this.file);
this.dirty = false;
return true;
} catch (e) {
this.log(`store write failed (${path.basename(this.file)}):`, e.message);
try { fs.rmSync(tmp, { force: true }); } catch {}
return false;
}
}
}
// The one Store for a file — every reader and writer of an add-on's store
// must go through this, or the in-memory copy and the file drift apart.
function storeFor(file, log) {
const key = path.resolve(file).toLowerCase();
let s = stores.get(key);
if (!s) { s = new Store(path.resolve(file), log); stores.set(key, s); }
return s;
}
function flushAll() { for (const s of stores.values()) s.flush(); }
module.exports = { storeFor, flushAll, WRITE_DELAY_MS };