Skip to content

refactor: background work reads app state in its own loop - #29660

Open
chrisnojima wants to merge 3 commits into
masterfrom
nojima/HOTPOT-as-bgwork
Open

chrisnojima wants to merge 3 commits into
masterfrom
nojima/HOTPOT-as-bgwork

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Split out of #29637. Based on master, independent of the app-state stack.

Why

Six subsystems run background loops that are supposed to pause when the app is not in the foreground. Each implemented that independently, and each got some part of it wrong in the same way: they assume the app starts in FOREGROUND, and they read the app state at a moment that can miss a transition. Gathering them into one change makes the shared shape reviewable once instead of six times.

The shape, applied per subsystem: seed from the current app state instead of assuming FOREGROUND, arm NextUpdate before doing work so a transition landing mid-flight is not lost, and re-read the state on wake rather than assuming what it changed to.

Master bugs this fixes

  • BgTicker leaks its goroutine. Stop() only stops the ticker. tick() still sits in for c := range t.ticker.C — which never ends, because Stop() does not close the channel — and blocks forever on t.c <- c once the 1-slot buffer fills and no one is reading. The goroutine-leak assertions in the suites below depend on this being fixed, which is why it is here.
  • The avatars flusher can lose a wake-up. NextUpdate is armed inside the goroutine, so a change between start() and the goroutine's first scheduling is missed entirely.
  • Every loop assumes FOREGROUND at startupconvloader.go:178 and leveldb_cleaner.go:134 both hardcode state := MobileAppState_FOREGROUND as the seed.
  • INACTIVE is treated as backgrounded by the conv loader, so a transient UI state pauses loading.
  • KBFS paused loops leak on shutdown. The QR, disk-cache-clean and search-index loops wait for FOREGROUND without watching shutdown, so they leak goroutines and hang Indexer.Shutdown.
  • The KBFS prefetcher has two independent pause waits — one for app state, one for cell network — and each unpauses ignoring the other, so it resumes while the other condition still says it should not.
  • Archive jobs can launch after a plain Stop.
  • Archive history leaks across an account switch. Stop keeps the in-memory job history and inited, so the next user's Start resumes the previous user's paused jobs under the new uid and writes that history to the new user's db key. A job launched before the switch can also register or report progress into the new user's history.

What this changes

Per subsystem: BgTicker.Stop() closes a done channel; the avatars flusher is armed before its goroutine runs; the ephemeral keygen loop is seeded from the current state and made testable; the conv loader reads app state inside its loop, Start waits for the previous run, and suspendInAppState becomes state == BACKGROUND so INACTIVE keeps loading; the archive registry runs from one loop and pauses by app-state level; the chat search indexer starts no sync outside FOREGROUND; and the KBFS loops watch shutdown with the prefetcher's two waits collapsed into one.

Follow-ups within those:

  • Search indexer: a sync skipped outside FOREGROUND, including the one-shot start sync, runs on the next change into FOREGROUND instead of waiting for the next 15-minute tick.
  • Conv loader: an app-state change that doesn't suspend (e.g. FOREGROUND → INACTIVE) keeps waiting out the load delay, so a retry keeps its full error backoff.
  • Archive registry: Set returns an error when it pauses a job registering outside the foreground, so ArchiveChat stops at once instead of carrying on with a canceled context. Stop drops the run's history once flushed, so the next Start reads its own user's. Set takes the job's uid and refuses (and stops) a job for any user but the current one.
  • KBFS QR: forced reclamations are not serviced while QR is paused; noted in a comment, behavior unchanged.

Ordering

Independent of the app-state stack at compile level — all of it uses only MobileAppState.State()/NextUpdate(), unchanged on master. No file overlap with the stack at all.

But it should land before or with #29651, which makes Android boot in BACKGROUND. Until these loops seed from the live state, they wake at boot, read BACKGROUND and suspend — on Android the conv loader would come up suspended.

Verification

go build, go vet, gofmt clean, golangci-lint --new-from-rev master 0 issues. All new tests pass under -race, and each follow-up's regression test fails with its fix reverted.

Not verified here: tests calling kbtest signup need kbweb on localhost:3000, unavailable in this environment — 5 in convloader_test.go, TestUploadAvatar, TestKeygenIfNeeded. They are pre-existing and untouched.

TestArchiveScenarioReplay and TestSyncLoopScenarioReplay are deliberately omitted — they need lifecycle/lifecycletest, which lands in #29651. Add them once both are in.

Each background subsystem now watches MobileAppState in a loop it owns
instead of piggybacking on a shared notification path, so pauses and
resumes are ordered against that subsystem's own start/stop.

- bgticker: Stop() closes a done channel, so the tick goroutine can no
  longer block forever sending to a reader that has gone away. Other
  suites' goroutine-leak assertions depend on this.
- avatars: the background flusher arms its first wake-up before its
  goroutine runs, so the seed state can't be missed.
- ephemeral: the keygen loop seeds from the current state and reacts to
  transitions itself.
- chat convloader: suspension is tracked per run; app-state suspends
  carry across runs and stale wake-ups are dropped.
- chat archive registry: resumes launch a job once, ended runs touch
  nothing, and pauses flush.
- chat search indexer: the sync loop only syncs in FOREGROUND and
  cancels an active sync on background.
- kbfs: paused loops (folder_block_manager, search indexer) exit on
  shutdown, and the prefetcher does a single pause-wait so its pause
  reasons no longer undo each other.
@zoom-ua

zoom-ua commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Every loop assumes FOREGROUND at startup — convloader.go:178 and leveldb_cleaner.go:134 both hardcode state := MobileAppState_FOREGROUND as the seed.

FOREGROUND is the default we get back anyway, except on android. So this might subtly change that behavior.

I'd prefer if there were independent PRs for the bugfixes and the arch change after that

- search indexer: a sync skipped outside FOREGROUND (including the one-shot
  start sync) runs on the next change into FOREGROUND instead of being lost.
- convloader: an app-state change that doesn't suspend keeps waiting out the
  load delay, so a retry keeps its full backoff.
- archive registry: Set returns an error when it pauses a job registering
  outside the foreground, so ArchiveChat stops at once. Stop drops the run's
  in-memory history so the next Start, possibly for another user, reads its
  own.
- kbfs: note that forced quota reclamations block while QR is paused.
A job launched before a logout could register or report progress after the
next user's Start and land in that user's history. Set now takes the uid the
job runs as, and refuses (and stops) a job for any user but the current one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants