fix: drive background refresh and phishing update from alarms (closes #158) #208
Reference in New Issue
Block a user
Delete Branch "fix/issue-158-mv3-worker-termination"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #158.
What now survives worker termination
Chrome terminates the MV3 service worker after ~30s idle, which killed both
recurring jobs and every module-level variable they depended on.
setIntervalonto the extension alarmsAPI in the new
src/shared/alarms.js. The browser holds the schedule andwakes the worker to deliver
onAlarm. Alarms are created only when missing orwhen the existing one carries a different period: creating one restarts its
period, and the startup path runs on every wake, so unconditional creation
would push the next fire out indefinitely on a busy extension.
periodInMinutes: 1, phishing refreshperiodInMinutes: 1440. Both are whole minutes at or above the one-minuteminimum, so nothing is clamped.
the period alone sets the rate. See below.
are persisted to extension storage (
chrome.storage.local) instead oflocalStorage, which does not exist in a service worker and so neverpersisted anything on Chrome at all.
updatePhishingList()reloads thatrecord before deciding whether a fetch is due, so a revived worker neither
re-fetches on every wake nor sleeps through an overdue update.
ensureRecurringAlarms()plus the phishing list init run atthe top level of the worker and are registered on
runtime.onInstalledandruntime.onStartup. Idempotent by construction, and they share one in-flightrun so the install-time pair cannot both create the same alarm.
localStorage. No use remains in anything reachable from the worker.src/shared/ens.jskeeps its cache and gains a comment recording that it ispopup-only.
Cadence: the guards are decoupled from the periods
Each job carries a freshness guard measured from the moment the last run
finished, which is one run-duration after the alarm that started it. A guard
timed to the alarm period therefore vetoes the very next tick, and the real
cadence becomes two periods. Both jobs had this. The two are fixed differently,
because the two guards exist for different reasons.
Phishing (24h): the scheduled tick bypasses the TTL. The 24-hour
CACHE_TTL_MSexists to keep the worker off the network on the wakes betweenrefreshes — Chrome revives the worker every ~30s while the browser is busy, and
every revival runs the startup path. The alarm tick is not one of those wakes,
so
refreshPhishingListOnSchedule()callsupdatePhishingList({ force: true })and fetches unconditionally. Shortening the TTL instead would not work: the
startup path re-checks it on every wake, so a shorter TTL would simply become
the real refresh rate.
Balance (60s): the guard is shortened to half the period. Here the guard's
job is to skip work an open popup has already done — the popup refreshes every
10 seconds and stamps the same field — so it has to keep applying on the
scheduled tick and cannot be bypassed.
RECENT_BALANCE_REFRESH_MSis half thealarm period: 30s is comfortably above the popup's 10s, so an open popup still
suppresses the background job, and comfortably below the 60s period, so the
schedule always wins.
Measured steady state, from the new tests (simulated clock, worker restarted
between every tick, fetch consuming 5s of simulated time):
With the fix reverted, the same tests measure the defect:
The balance simulation varies extension-storage read latency across ticks. That
is load-bearing:
backgroundRefresh()stamps its marker after awaitingloadState(), so with a constant latency the comparison sits exactly on the<boundary and the defect hides. Varying it is what real storage does.Other correctness fixes
Date.now() - stampand tests only the lower bound, so a stamp from a skewedclock or a restored profile backup suppressed updates until that time arrived
— and persisting the value is what made it outlive the worker.
sanitizeTimestamp()discards a value that is non-finite, non-positive, or inthe future, on load. Recovery costs one extra fetch and is permanent: the
record left behind is sane. Tested with
now + 365don both timestamps.Dropping the delta together with its freshness claim is right — no freshness
lie — but it left no mark that the network had been contacted, so with the
worker cycling every ~30s idle the full blocklist was fetched on every wake,
indefinitely. A second timestamp,
lastAttemptTime, is now written whateverbecame of the response, including in the oversize branch and the failure
branch, and floors unscheduled retries at one hour
(
MIN_FETCH_ATTEMPT_INTERVAL_MS). The scheduled tick ignores the floor. Foursimulated wakes now produce one download instead of four.
ensureAlarm()comparesexisting.periodInMinutesto the requested one, so an alarm created by anearlier version is re-created once and then settles.
startBackgroundJobs()no longer discardsits promises: a rejection is logged through
src/shared/log.jsrather thanbecoming an unhandled rejection in the worker, and an alarm that failed to
schedule is no longer silent.
MV2 handling
Firefox MV2 has a persistent background page where timers would have survived,
but both browsers are built from one bundle, so a browser-conditional path would
be two behaviours to reason about. Both take the alarm path;
browser.alarmsisresolved ahead of
chrome.alarmsexactly as the rest of the codebase does, and"alarms"is declared in bothmanifest/chrome.jsonandmanifest/firefox.json. There is a test asserting the Firefox global is usedwhen present. A context with no alarms API at all (the popup) degrades to a
no-op rather than throwing.
Docs
README.mdgains a Background scheduling section covering the alarmmechanism, the guard-versus-period trap and why the two jobs resolve it
differently, and the two persisted timestamps. The phishing-blocklist
descriptions in
README.mdanddocs/README.mdstate the actual behaviour: the24-hour schedule, the extra fetch on a start where the list is already stale,
and the one-hour floor on retries after a failed or unstorable fetch.
Tests
tests/alarms.test.jsandtests/phishingDomains.test.js. Beyond the originalcoverage (both jobs scheduled as alarms and not timers, periods at/above the
minimum and integral, an existing alarm not reset by a wake, name-based
dispatch,
onInstalled/onStartupre-registration, the Firefox path,persistence to extension storage, and simulated worker restarts), the new tests
pin the actual measured cadence and the recovery paths:
asserted, for balance and phishing respectively;
the reviewer's probe, now a test;
not removed;
lastFetchTimeand a futurelastAttemptTimeare both discarded;each; the retry floor expires; the scheduled tick ignores it;
not four.
One test-harness note worth recording:
jest.resetModules()clears the callrecord of every
jest.fn, and simulating a worker restart is that call.Anything counted across a restart is counted outside the mock, or the assertion
is vacuous. That also made the pre-existing
setIntervalassertion vacuous; itnow counts outside the spy.
Mutation testing
Each fix reverted in turn,
make check:Gates
nextmoved twice while this branch waited, so the totals below move with it —this branch itself contributes
tests/alarms.test.js.Current head, rebased onto
nextatedea22f,make checkgreen:make test-e2eon the same head:# 13/13 tests passed. That is worth morethan the unit tests here — its launch canary aborts the suite unless the real
MV3 service worker issues the phishing blocklist fetch on startup, so it
confirms the startup fetch still fires in a real Chrome with the
persisted-timestamp gate and the new retry floor in front of it.
The immediately preceding rebased head (onto
nextatfb9e8f5) was also runthrough the container via
script/cibuild(docker build ., whose Dockerfileruns
make checkthenmake build), exit 0, with themake checklayerexecuting rather than
CACHED:The only difference between that head and the current one is the
TODO.mdconflict resolution for the entries that landed on
nextin between; no sourcefile changed.
Not verified
The definition of done asks for a manual check that the delta survives after
letting a real worker idle out and that the balance refresh fires after a
revival. That needs a hand-driven browser session over several minutes and was
not performed; the restart behaviour is covered by simulation in the unit tests
and the alarm registration is covered end to end by the e2e launch canary. The
cadence figures above are measured against a simulated clock, not a wall-clock
48-hour observation.
FAIL —
needs-rework.Verified clean: DoD items 1, 2, 5, 6, 7(partly), 8, 9; base
next, single commit titled(closes #158), oneTODO.mdentry, no Claude/Anthropic references or attribution trailers, mergeable (fast-forward ontonext),make checkgreen here (168 tests, prettier clean, executed not cached),make test-e2e4/4 with the launch canary holding,script/cibuildexit 0 with the check layer executed in-container. Test teeth confirmed independently: revertingsrc/andmanifest/tonextgives exactly the 10 failures claimed; mutating theonAlarmdispatcher to ignorePHISHING_REFRESH_ALARMfails 1 test; making the existence check always report "exists" fails 8; dropping thelastFetchTimerestore inloadDeltaFromStorage()fails 3 restart tests.onInstalledis registered with no reason filter, so the update-clears-alarms edge the PR body flagged as unmodelled is in fact covered ("update"and"chrome_update"both re-runstartBackgroundJobs(), as does the top-level call) — not a defect.1. Both alarm periods are exactly equal to the freshness guard they trigger, so every other tick is a no-op — blocking
src/shared/alarms.js:19sets the phishing period to 1440 minutes andsrc/shared/phishingDomains.js:23setsCACHE_TTL_MSto the same 24h.lastFetchTimeis stamped when the fetch completes (loadConfig(),phishingDomains.js:127), i.e. one fetch-latency AFTER the alarm that triggered it. The next alarm fires 24h after the previous alarm, which is that same latency BEFORElastFetchTime + CACHE_TTL_MS, soupdatePhishingList()takes the skip branch atphishingDomains.js:186and does nothing. The tick after that (T+48h) fetches, and the cycle repeats.Measured directly (throwaway probe, deleted; persisted
lastFetchTime = now - CACHE_TTL_MS + latency, then oneupdatePhishingList()= the 24h tick):Steady state is a 48-hour refresh, not 24. It only lands on 24h when the browser happens to deliver the alarm later than the previous fetch took, which is not something to rely on.
Identical mechanism on the balance refresh, and this PR makes the coupling explicit rather than incidental:
src/background/index.js:601now definesBACKGROUND_REFRESH_INTERVAL = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000, andbackgroundRefresh()returns early atindex.js:607whennow - state.lastBalanceRefresh < BACKGROUND_REFRESH_INTERVAL. Withstate.lastBalanceRefreshwritten afterrefreshBalances()returns, the 60-second tick always lands inside its own guard, so the background refresh runs about every two minutes.Why it matters: the whole point of the issue is that these two jobs run on their documented schedule, and this PR adds the documentation that asserts they do —
README.md"the balance refresh is expressed as exactly one minute and nothing is silently slowed down",README.md:~745"once every 24 hours",docs/README.md:133"every 24 hours after that". DoD item "README.mdmatches the implemented behaviour" is therefore not met.Acceptable: make the guard strictly shorter than the period it gates (e.g. compare against
CACHE_TTL_MSless a few minutes of slack, or set the alarm period below the TTL), or have the alarm handler bypass the TTL check entirely and leave the check for the startup path. Whichever is chosen, add a test that fires the handler at exactly one period after a completed fetch and asserts a fetch happens.2. A persisted
lastFetchTimein the future suppresses updates permanentlyphishingDomains.js:70-72accepts any number, andphishingDomains.js:186only tests the lower bound. A record written under a skewed clock, or restored from a backup/profile sync, pins the phishing list forever. Probe withlastFetchTime = now + 365d: 0 fetches across 3 simulated worker restarts, and there is no path that ever clears it. Before this PR the same expression existed but the value was in-memory only, so it could not outlive one worker; persisting it is what makes the failure permanent. Acceptable: treatlastFetchTime > Date.now()as stale on load (clamp or discard), with a test.3.
ensureAlarm()never reconciles a changed periodalarms.js:46-47returns as soon as an alarm with that name exists and never comparesexisting.periodInMinutesto the requested one. EditingBALANCE_REFRESH_PERIOD_MINUTESorPHISHING_REFRESH_PERIOD_MINUTESin a future release therefore has no effect on any existing install except by way of the browser happening to clear alarms on update. The guard is correct in intent (re-creating resets the schedule) but should beif (existing && existing.periodInMinutes === period) return false;so a period change re-creates once and then settles. Add a test: an existing alarm with a stale period is re-created; one with the current period is not.4. An oversized delta re-downloads the full blocklist on every worker wake
The drop-the-timestamp-with-the-delta choice is right — no freshness lie — but nothing bounds the resulting retry rate. Probe with a >256 KiB delta: 3 simulated restarts produced 3 full fetches, storage empty each time. Chrome cycles the worker every ~30s idle, so an oversized delta means a fetch of
raw.githubusercontent.comon every wake indefinitely, silently. Acceptable: persist a separate "last attempt" timestamp that is always written regardless of whether the delta was stored, and gate retries on it.5. CI is not green on the head commit
check / check (push)oncafffe5is stillpending/ "Waiting to run" (run 472), queued since 14:28. Not red, but unverified on the tracker; it must be green before merge.Minor
src/background/index.js:636-639:startBackgroundJobs()discards both promises. A rejection fromalarms.getbecomes an unhandled rejection in the worker and an alarm that failed to schedule is silent. The repo hassrc/shared/log.js; a.catch()that logs would make the failure visible.startBackgroundJobs()and theonInstalledlistener can runensureRecurringAlarms()concurrently; both may observe the alarm missing and create it, the second create resetting the period. One-off and harmless, but the "create only when missing" invariant is not actually race-free.fix/issue-158-mv3-worker-terminationdoes not match theissue-<N>-<slug>form inTODO.md's workflow section.README.mdandsrc/shared/ens.jscomment changes are explicitly requested by #158, and thedocs/README.mdcorrection fixes the same false persistence/re-download claim. Both docs edits are otherwise accurate apart from the cadence claims in finding 1.cafffe5ab9toc91c8567f3c91c8567f3to2f17505525FAIL —
needs-rebase. Sole blocker; all four prior findings verified fixed by measurement.Conflicts with current
nextnextadvanced to12acf4d(#179) after this head was pushed. Trial merge oforigin/nextinto2f17505:Both commits insert a new entry at the top of
# Completed Steps. Gitea now reportsmergeable: falseagainst base12acf4d. Acceptable: rebase ontonext, keep both entries, re-runmake check.Verified fixed (measured, not read)
Independent probe, own clone, simulated clock, worker restarted (
jest.resetModules()+ re-require) before every tick, counters held outside the mocks; the same probe against each fix reverted reproduces the defect, so the measurements have teeth.lastFetchTime: exactlynowretained (no fetch, delta preserved);now+1ms,now+365d,-1,0, numeric string,NaN,Infinity, missing all discarded, each recovering with one fetch and leaving a stored value <= now. FuturelastAttemptTimelikewise does not wedge the retry floor.{balance:false, phishing:false}) — reconciliation settles, no per-wake reschedule.README.mdanddocs/README.mdcadence statements now match the measured behaviour, including the extra fetch on a stale start and the one-hour retry floor.startBackgroundJobs()catches and logs vialog.errorf; install-time race closed by the shared in-flight run.make checkexit 0 here (11 suites, 278 passed / 1 skipped, prettier clean, executed not cached),make test-e2e4/4 exit 0. Single commit, basenext, title ends(closes #158), authoredclawbot, no Claude/Anthropic references or attribution trailers,TODO.mdretains all prior entries.Non-blocking: branch name
fix/issue-158-mv3-worker-terminationstill does not match theissue-<N>-<slug>form inTODO.md:4— carried over, not worth a new PR.2f17505525tob114151116b114151116toa3db61a421a3db61a421todc49222897