fix: drive background refresh and phishing update from alarms (closes #158)
All checks were successful
check / check (push) Successful in 29s
All checks were successful
check / check (push) Successful in 29s
The Chrome MV3 service worker is terminated after roughly 30 seconds idle, which destroyed both recurring jobs: the 60-second balance refresh and the 24-hour phishing blocklist refresh were setInterval schedules, so in practice each ran only while the worker happened to be alive. The phishing delta was persisted to localStorage, which does not exist in a service worker, so on Chrome it was never persisted at all. Both jobs now run off the extension alarms API in the new src/shared/alarms.js: the browser holds the schedule and wakes the worker to deliver it. The balance refresh is one minute and the phishing refresh is 1440 minutes, both whole minutes at or above the one-minute minimum, so neither is silently clamped. Alarms are created only when missing or when the existing one carries a different period, because creating one restarts its period and the startup path runs on every wake — while an alarm left at an older release's period would otherwise never be reconciled. Each job's freshness guard is decoupled from its alarm period, or the period would not be the cadence. A guard is measured from when the last run finished, which is one run-duration after the alarm that started it, so a guard timed to the period vetoes the very next tick and the real rate halves. The two are handled differently because the guards differ in purpose: the phishing cache TTL exists to keep the worker off the network on the wakes between refreshes, so the scheduled tick bypasses it and fetches unconditionally; the balance guard exists to skip work an open popup has already done, so it must keep applying on the tick and is instead shortened to half the alarm period — above the popup's 10-second refresh, below the 60-second period. The phishing delta and the timestamps of the fetch that produced it now live in extension storage, and updatePhishingList() reloads that record before deciding whether a fetch is due. A revived worker therefore neither re-fetches on every wake nor sleeps through an overdue update. A timestamp read back from storage is discarded if it lies in the future: clock skew or a restored profile backup would otherwise suppress updates until that time arrived, permanently, now that the value outlives the worker. Two timestamps are kept, not one. The 256 KiB cap still drops an oversized delta together with its freshness claim, but the record of having contacted the network at all is written regardless — as it is after a failed fetch — and floors unscheduled retries at one hour. Without it, a list that is persistently oversized or a fetch that persistently fails means a full blocklist download on every worker wake, indefinitely. The startup path (ensureRecurringAlarms plus the phishing list init) is registered on onInstalled and onStartup as well as running at the top level of the worker, and is idempotent. The concurrent callers on a fresh install share one in-flight run rather than racing to create the same alarm, and a failure is logged instead of becoming an unhandled rejection. Firefox MV2 has a persistent background page where timers would have survived, but both browsers are built from one bundle and both take the alarm path, so there is a single code path; "alarms" is declared in both manifests. src/shared/ens.js keeps its localStorage cache and gains a comment recording that it is popup-only, so it does not get pulled into the worker later.
This commit is contained in:
@@ -12,13 +12,20 @@ const {
|
||||
currentNetwork,
|
||||
} = require("../shared/state");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch } = require("../shared/log");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
|
||||
const {
|
||||
isPhishingDomain,
|
||||
updatePhishingList,
|
||||
startPeriodicRefresh,
|
||||
refreshPhishingListOnSchedule,
|
||||
initPhishingList,
|
||||
} = require("../shared/phishingDomains");
|
||||
const {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
ensureRecurringAlarms,
|
||||
registerAlarmHandlers,
|
||||
} = require("../shared/alarms");
|
||||
|
||||
const storageApi =
|
||||
typeof browser !== "undefined"
|
||||
@@ -591,12 +598,22 @@ async function broadcastAccountsChanged() {
|
||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
||||
// fresh, so this naturally skips.
|
||||
const BACKGROUND_REFRESH_INTERVAL = 60000;
|
||||
//
|
||||
// The alarm period alone sets the cadence; this guard only suppresses a
|
||||
// refresh something else has just done, so it must stay strictly shorter than
|
||||
// the period. Timed to the period it would veto every tick it gates —
|
||||
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
|
||||
// after the last one always lands inside a guard of equal length and the real
|
||||
// cadence becomes two periods. Half the period keeps it comfortably above the
|
||||
// popup's 10-second refresh, so an open popup still suppresses the background
|
||||
// job, and comfortably below the alarm period, so the schedule always wins.
|
||||
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
||||
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
|
||||
|
||||
async function backgroundRefresh() {
|
||||
await loadState();
|
||||
const now = Date.now();
|
||||
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL)
|
||||
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
|
||||
return;
|
||||
if (state.wallets.length === 0) return;
|
||||
await refreshBalances(
|
||||
@@ -609,12 +626,58 @@ async function backgroundRefresh() {
|
||||
await saveState();
|
||||
}
|
||||
|
||||
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
|
||||
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
|
||||
// a service worker that the browser terminates after about 30 seconds idle,
|
||||
// so a setInterval would only ever survive until the first idle period and
|
||||
// module-level state does not outlive it. Alarms are held by the browser and
|
||||
// wake the worker to deliver them.
|
||||
registerAlarmHandlers({
|
||||
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||
// The scheduled refresh, which restores persisted state on a freshly
|
||||
// revived worker and then fetches unconditionally. The freshness guards
|
||||
// belong to the startup path; applying them here would make the tick skip
|
||||
// itself.
|
||||
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
|
||||
});
|
||||
|
||||
// Fetch the phishing domain blocklist delta on startup and refresh every 24h.
|
||||
// The vendored blocklist is bundled at build time; this fetches only new entries.
|
||||
updatePhishingList();
|
||||
startPeriodicRefresh();
|
||||
// Everything the background context needs re-established on start. This runs
|
||||
// on a fresh install, on browser startup, and on every revival of a
|
||||
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
|
||||
// creates alarms that are missing or carrying a stale period, and
|
||||
// initPhishingList() fetches only when the persisted timestamps say the list
|
||||
// is stale.
|
||||
//
|
||||
// On a fresh install the top-level call and the onInstalled listener both run,
|
||||
// close enough together that both could see an alarm missing and create it.
|
||||
// Sharing one in-flight run makes the "create only when missing" check
|
||||
// race-free; the memo is dropped once it settles so a later onStartup runs
|
||||
// again.
|
||||
let backgroundJobsRun = null;
|
||||
|
||||
function startBackgroundJobs() {
|
||||
if (backgroundJobsRun) return backgroundJobsRun;
|
||||
backgroundJobsRun = Promise.all([
|
||||
ensureRecurringAlarms(),
|
||||
initPhishingList(),
|
||||
])
|
||||
.catch((err) => {
|
||||
// An alarm that failed to schedule means a recurring job silently
|
||||
// never runs again; it must not be an unhandled rejection.
|
||||
log.errorf("background job startup failed:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
backgroundJobsRun = null;
|
||||
});
|
||||
return backgroundJobsRun;
|
||||
}
|
||||
|
||||
if (runtime.onInstalled) {
|
||||
runtime.onInstalled.addListener(startBackgroundJobs);
|
||||
}
|
||||
if (runtime.onStartup) {
|
||||
runtime.onStartup.addListener(startBackgroundJobs);
|
||||
}
|
||||
startBackgroundJobs();
|
||||
|
||||
// When approval window is closed without a response, treat as rejection
|
||||
if (windowsApi && windowsApi.onRemoved) {
|
||||
|
||||
Reference in New Issue
Block a user