From bfef5dde14fe2975afac603e98e54c8f2a52204b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:03:31 -0400 Subject: [PATCH 01/16] feat(lifecycle): add a controller that derives the mobile app state Native reports only what the UI is doing -- active, inactive, off screen -- and the controller derives MobileAppState from those reports plus the holds open on background work: FOREGROUND and INACTIVE follow the UI, and a backgrounded UI is BACKGROUNDACTIVE while any hold is open and BACKGROUND otherwise. One writer, one place the state is decided, instead of each native callback picking a state for itself. Holds cover the background task that drains the outbox, the background sync window, the push window and live location, and each ends on its own terms: leaving the background ends the first two, expiration ends every background task, and termination ends them all. The package must not import libkb, since libkb holds a Controller and libkb's own tests drive it. lifecycletest replays native event sequences against a controller with a fake clock and records what an app-state consumer observes, so consumer packages can assert against the same scenarios. Additive: nothing wires the controller up yet. --- go/libkb/lifecycle/controller_test.go | 399 +++++++++++++ go/libkb/lifecycle/export_test.go | 10 + go/libkb/lifecycle/lifecycle.go | 503 ++++++++++++++++ go/libkb/lifecycle/lifecycletest/clock.go | 72 +++ go/libkb/lifecycle/lifecycletest/harness.go | 435 ++++++++++++++ go/libkb/lifecycle/lifecycletest/recorder.go | 137 +++++ .../lifecycle/lifecycletest/recorder_test.go | 86 +++ go/libkb/lifecycle/lifecycletest/scenarios.go | 537 ++++++++++++++++++ go/libkb/lifecycle/scenario_test.go | 103 ++++ 9 files changed, 2282 insertions(+) create mode 100644 go/libkb/lifecycle/controller_test.go create mode 100644 go/libkb/lifecycle/export_test.go create mode 100644 go/libkb/lifecycle/lifecycle.go create mode 100644 go/libkb/lifecycle/lifecycletest/clock.go create mode 100644 go/libkb/lifecycle/lifecycletest/harness.go create mode 100644 go/libkb/lifecycle/lifecycletest/recorder.go create mode 100644 go/libkb/lifecycle/lifecycletest/recorder_test.go create mode 100644 go/libkb/lifecycle/lifecycletest/scenarios.go create mode 100644 go/libkb/lifecycle/scenario_test.go diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go new file mode 100644 index 000000000000..a4d6516f9034 --- /dev/null +++ b/go/libkb/lifecycle/controller_test.go @@ -0,0 +1,399 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycle_test + +import ( + "context" + "errors" + "math/rand" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +const ( + foreground = keybase1.MobileAppState_FOREGROUND + background = keybase1.MobileAppState_BACKGROUND + backgroundActive = keybase1.MobileAppState_BACKGROUNDACTIVE + inactive = keybase1.MobileAppState_INACTIVE +) + +func noop() {} + +// noDeliveries starts a task that finds nothing to deliver; stay says whether +// it keeps polling first. +func noDeliveries(stay bool) lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + Stay: func() bool { return stay }, + ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { return nil, nil }, + NextFailure: func() (chan []chat1.OutboxRecord, func()) { + return make(chan []chat1.OutboxRecord), func() {} + }, + NotifyFailure: func([]chat1.OutboxRecord) {}, + } +} + +// An id is never reused, so releasing an old hold again can't end a newer one. +func TestHoldReleaseIsIdempotent(t *testing.T) { + appState, _ := newAppState(t) + flushes := 0 + c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) + token := c.UIBackground(noDeliveries(false)) + require.Positive(t, token) + c.WaitBackgroundTask(token) + require.Equal(t, background, appState.State()) + // Into BACKGROUNDACTIVE, then out of it. + require.Equal(t, 2, flushes) + first := c.AcquireBackgroundWork() + require.Equal(t, backgroundActive, appState.State()) + require.True(t, first.Release()) + require.Zero(t, lifecycle.Holds(c)) + require.Equal(t, background, appState.State()) + require.Equal(t, 3, flushes) + second := c.AcquireBackgroundWork() + require.False(t, first.Release()) + // The stale Release left the newer hold alone. + require.Equal(t, 1, lifecycle.Holds(c)) + require.Equal(t, backgroundActive, appState.State()) + require.True(t, second.Release()) + require.Equal(t, background, appState.State()) + require.Equal(t, 4, flushes) +} + +// Close waits for the running background tasks, so no later call may start +// one: a task that joined the wait afterwards would be a WaitGroup misuse. +func TestNoTaskStartsAfterClose(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{}) + c.Close() + + c.UIInactive() + require.Zero(t, c.UIBackground(noDeliveries(true)), "UIBackground started a task after Close") + require.Zero(t, lifecycle.Holds(c)) + require.Equal(t, background, appState.State()) + + // PushWindowEnd's hand-over to a task is gated the same way; the push + // window's own hold is not. + push := c.PushWindowBegin() + require.Positive(t, push) + require.Equal(t, backgroundActive, appState.State()) + require.Zero(t, c.PushWindowEnd(push, noDeliveries(true)), "PushWindowEnd started a task after Close") + require.Zero(t, lifecycle.Holds(c)) + require.Equal(t, background, appState.State()) +} + +func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{}) + defer c.Close() + c.UIInactive() + require.Positive(t, c.UIBackground(noDeliveries(true))) + push := c.PushWindowBegin() + live := c.AcquireBackgroundWork() + notified := 0 + c.BackgroundTaskExpired(func() { notified++ }) + require.Equal(t, 1, notified) + require.Equal(t, 2, lifecycle.Holds(c)) + require.Equal(t, backgroundActive, appState.State()) + c.BackgroundTaskExpired(func() { notified++ }) + require.Equal(t, 1, notified, "nothing was left to expire") + c.WaitBackgroundTask(c.PushWindowEnd(push, noDeliveries(false))) + require.True(t, live.Release()) + require.Equal(t, background, appState.State()) +} + +// A start while a background task runs, from a duplicate didEnterBackground +// or a push window's end, joins that task: one task keeps the app up, and a +// failed message is warned about once. +func TestBackgroundTaskStartsJoinTheRunningTask(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{}) + defer c.Close() + var mu sync.Mutex + var subscribers []chan []chat1.OutboxRecord + subscribed := make(chan struct{}, 10) + var notified atomic.Int32 + deps := noDeliveries(true) + deps.NextFailure = func() (chan []chat1.OutboxRecord, func()) { + ch := make(chan []chat1.OutboxRecord, 1) + mu.Lock() + subscribers = append(subscribers, ch) + mu.Unlock() + subscribed <- struct{}{} + return ch, func() {} + } + deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) } + + c.UIInactive() + first := c.UIBackground(deps) + require.Positive(t, first) + select { + case <-subscribed: + case <-time.After(5 * time.Second): + require.Fail(t, "the background task never watched for failures") + } + require.Equal(t, first, c.UIBackground(deps), "a duplicate didEnterBackground") + push := c.PushWindowBegin() + require.Equal(t, first, c.PushWindowEnd(push, deps), "a push window's end") + require.Equal(t, 1, lifecycle.Holds(c)) + + // The outbox tells every watcher about a failure. + mu.Lock() + for _, ch := range subscribers { + ch <- make([]chat1.OutboxRecord, 1) + } + mu.Unlock() + c.WaitBackgroundTask(first) + require.Equal(t, background, appState.State()) + require.EqualValues(t, 1, notified.Load()) +} + +// startPolledTask starts a background task on a fake clock. Its done closes +// once the task has ended. +func startPolledTask(t *testing.T, maxDuration time.Duration, deps lifecycle.BackgroundTaskDeps) ( + appState *libkb.MobileAppState, clock *lifecycletest.FakeClock, done chan struct{}, +) { + appState, _ = newAppState(t) + appState.Update(background) + clock = lifecycletest.NewFakeClock() + c := lifecycle.New(appState, lifecycle.Config{ + Clock: clock, + BackgroundTaskPollInterval: pollInterval, + BackgroundTaskMaxDuration: maxDuration, + }) + t.Cleanup(c.Close) + c.UIInactive() + token := c.UIBackground(deps) + require.Positive(t, token) + done = make(chan struct{}) + go func() { + defer close(done) + c.WaitBackgroundTask(token) + }() + return appState, clock, done +} + +const pollInterval = 5 * time.Second + +// advancePolls lets a background task poll until it ends, at most limit +// times, and returns how many polls it took. +func advancePolls(t *testing.T, clock *lifecycletest.FakeClock, done chan struct{}, limit int) int { + for n := range limit { + if !clock.WaitForAfter(t, pollInterval, done) { + return n + } + clock.Advance(pollInterval) + } + return limit +} + +// The maximum duration holds even while the outbox can't be read. +func TestBackgroundTaskTimesOutWhileDeliveriesFail(t *testing.T) { + var notified atomic.Int32 + deps := noDeliveries(true) + deps.ActiveDeliveries = func(context.Context) ([]chat1.OutboxRecord, error) { + return nil, errors.New("outbox unavailable") + } + deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) } + appState, clock, done := startPolledTask(t, 3*pollInterval, deps) + require.Equal(t, 3, advancePolls(t, clock, done, 10), "the task outlived its maximum duration") + require.EqualValues(t, 1, notified.Load()) + require.Equal(t, background, appState.State()) +} + +// Deliveries that reappear start the count of empty polls over. +func TestBackgroundTaskNeedsEmptyPollsInARow(t *testing.T) { + outbox := [][]chat1.OutboxRecord{nil, nil, make([]chat1.OutboxRecord, 1), nil, nil, nil} + var polls atomic.Int32 + var notified atomic.Int32 + deps := noDeliveries(true) + deps.ActiveDeliveries = func(context.Context) ([]chat1.OutboxRecord, error) { + if i := int(polls.Add(1)) - 1; i < len(outbox) { + return outbox[i], nil + } + return nil, nil + } + deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) } + appState, clock, done := startPolledTask(t, lifecycle.DefaultBackgroundTaskMaxDuration, deps) + require.Equal(t, len(outbox), advancePolls(t, clock, done, 10), "the task ended with a message still sending") + require.Zero(t, notified.Load()) + require.Equal(t, background, appState.State()) +} + +// Native gives these last events only a short wait, so the state change and +// the flush must happen before the slow pending-message warning. +func TestExitEventsApplyBeforeNotifying(t *testing.T) { + events := map[string]struct { + prepare func(c *lifecycle.Controller) + do func(c *lifecycle.Controller, notifyPending func()) + }{ + "willTerminate": { + prepare: func(c *lifecycle.Controller) { c.UIActive() }, + do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, + }, + "backgroundTaskExpired": { + prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, + do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) }, + }, + } + for name, event := range events { + t.Run(name, func(t *testing.T) { + appState, _ := newAppState(t) + var flushes int + c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) + defer c.Close() + event.prepare(c) + flushesBefore := flushes + notified := false + event.do(c, func() { + notified = true + require.Equal(t, background, appState.State()) + require.Equal(t, flushesBefore+1, flushes) + }) + require.True(t, notified) + }) + } +} + +// Hold owners run concurrently with UI reports, then each phase ends on known +// last reports and checks nothing is left holding the app up: FOREGROUND +// stays FOREGROUND and a background UI with no work is BACKGROUND. Owner +// goroutines and the background tasks the controller runs must all exit. +func TestHoldsStress(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{ + BackgroundSyncWindow: 200 * time.Microsecond, + BackgroundTaskPollInterval: time.Millisecond, + BackgroundTaskMaxDuration: time.Minute, + }) + defer c.Close() + baseline := runtime.NumGoroutine() + + chaos := func(t *testing.T, iterations int) { + var owners sync.WaitGroup + lifecycleDone := make(chan struct{}) + runOwner := func(f func(r *rand.Rand)) { + owners.Add(1) + go func(seed int64) { + defer owners.Done() + r := rand.New(rand.NewSource(seed)) + for { + select { + case <-lifecycleDone: + return + default: + } + f(r) + } + }(rand.Int63()) + } + for range 3 { + runOwner(func(r *rand.Rand) { + token := c.PushWindowBegin() + if r.Intn(2) == 0 { + time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) + } + c.PushWindowEnd(token, noDeliveries(r.Intn(3) == 0)) + }) + runOwner(func(*rand.Rand) { c.BackgroundSync() }) + runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) + runOwner(func(r *rand.Rand) { + h := c.AcquireBackgroundWork() + time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) + h.Release() + }) + } + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for range iterations { + switch r.Intn(5) { + case 0: + c.UIActive() + case 1: + c.UIInactive() + case 2: + c.UIBackground(noDeliveries(r.Intn(2) == 0)) + case 3: + c.UIBackground(noDeliveries(false)) + case 4: + if r.Intn(10) == 0 { + c.WillTerminate(noop) + } + } + time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) + } + c.UIActive() + close(lifecycleDone) + waitGroupWithin(t, &owners, "owners deadlocked") + require.Equal(t, foreground, appState.State()) + require.Equal(t, 0, lifecycle.Holds(c)) + } + + t.Run("ends in foreground", func(t *testing.T) { + chaos(t, 300) + }) + + t.Run("ends in background", func(t *testing.T) { + chaos(t, 300) + c.WaitBackgroundTask(c.UIBackground(noDeliveries(false))) + require.Equal(t, background, appState.State()) + require.Equal(t, 0, lifecycle.Holds(c)) + }) + + t.Run("concurrent holds end in background", func(t *testing.T) { + for range 50 { + chaos(t, 20) + c.WaitBackgroundTask(c.UIBackground(noDeliveries(false))) + var holders sync.WaitGroup + for range 4 { + holders.Add(1) + go func() { + defer holders.Done() + c.AcquireBackgroundWork().Release() + }() + } + waitGroupWithin(t, &holders, "holders deadlocked") + require.Equal(t, background, appState.State()) + require.Equal(t, 0, lifecycle.Holds(c)) + } + }) + + // require.Eventually runs its condition on extra goroutines, so poll by hand. + settled := runtime.NumGoroutine() + for deadline := time.Now().Add(5 * time.Second); settled > baseline && time.Now().Before(deadline); { + time.Sleep(10 * time.Millisecond) + settled = runtime.NumGoroutine() + } + require.LessOrEqual(t, settled, baseline, "leaked goroutines") + t.Logf("goroutines: baseline %d, settled %d", baseline, settled) +} + +func waitGroupWithin(t *testing.T, wg *sync.WaitGroup, msg string) { + t.Helper() + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(30 * time.Second): + require.Fail(t, msg) + } +} + +var _ lifecycle.AppState = (*libkb.MobileAppState)(nil) diff --git a/go/libkb/lifecycle/export_test.go b/go/libkb/lifecycle/export_test.go new file mode 100644 index 000000000000..cd2cd9673b58 --- /dev/null +++ b/go/libkb/lifecycle/export_test.go @@ -0,0 +1,10 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycle + +func Holds(c *Controller) int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.holds) +} diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go new file mode 100644 index 000000000000..4cf7c1d66055 --- /dev/null +++ b/go/libkb/lifecycle/lifecycle.go @@ -0,0 +1,503 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +// Package lifecycle derives the mobile app's MobileAppState from the UI state +// native code reports and the background work that must keep running: +// FOREGROUND and INACTIVE follow the UI, and a background UI is +// BACKGROUNDACTIVE while any hold is open and BACKGROUND otherwise. +// +// It must not import libkb: libkb holds a Controller, and libkb's own tests +// drive it. +package lifecycle + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/clockwork" + "golang.org/x/sync/errgroup" +) + +type UIState string + +const ( + UIBackground UIState = "background" + UIInactive UIState = "inactive" + UIActive UIState = "active" +) + +// Reason says what a hold keeps running, and so which events end it. +type Reason string + +const ( + ReasonBackgroundTask Reason = "backgroundTask" + ReasonBackgroundSync Reason = "backgroundSync" + ReasonPushWindow Reason = "pushWindow" + ReasonLiveLocation Reason = "liveLocation" +) + +// AppState is the part of libkb.MobileAppState the controller drives. +type AppState interface { + State() keybase1.MobileAppState + Update(state keybase1.MobileAppState) (changed bool) + NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} +} + +const ( + DefaultBackgroundSyncWindow = 10 * time.Second + DefaultBackgroundTaskPollInterval = 5 * time.Second + DefaultBackgroundTaskMaxDuration = 10 * time.Minute +) + +// Config holds the controller's dependencies. Zero fields get defaults: the +// real clock, the default durations, and no-op hooks. +type Config struct { + Clock clockwork.Clock + BackgroundSyncWindow time.Duration + BackgroundTaskPollInterval time.Duration + BackgroundTaskMaxDuration time.Duration + // Flush runs when the state moves into BACKGROUNDACTIVE or BACKGROUND from + // anything but BACKGROUND, where the OS may suspend or kill the process + // next. It runs at most once per controller call, under the controller's + // lock, so it must not block. + Flush func() + Debug func(format string, args ...interface{}) +} + +type BackgroundTaskDeps struct { + // Stay reports whether any work must keep a backgrounded app running. A + // task asks it first, off the controller's lock: answering reads the + // outbox. + Stay func() bool + ActiveDeliveries func(context.Context) ([]chat1.OutboxRecord, error) + NextFailure func() (chan []chat1.OutboxRecord, func()) + NotifyFailure func([]chat1.OutboxRecord) +} + +// Hold keeps a backgrounded app BACKGROUNDACTIVE until it is released or the +// controller ends it. +type Hold struct { + c *Controller + id int64 + reason Reason + // done is closed once the hold has ended, by Release or by the controller. + done chan struct{} +} + +// Released reports whether the hold has ended, by Release or by the +// controller. Release cannot stand in for it: on a hold that is still open, +// asking that way would end it. +func (h *Hold) Released() bool { + select { + case <-h.done: + return true + default: + return false + } +} + +// Release ends the hold. It reports whether this call ended it; ending a hold +// again, or one the controller already ended, does nothing. +func (h *Hold) Release() bool { return h.c.release(h) } + +type Controller struct { + appState AppState + cfg Config + // ctx ends the background tasks the controller runs; see Close. + ctx context.Context + cancel context.CancelFunc + + // wg counts the background task goroutines Close waits for. + wg sync.WaitGroup + + // mu serializes every UI report and hold change with the state it writes. + mu sync.Mutex + ui UIState + nextID int64 + holds map[int64]*Hold + // closed stops new tasks once Close is waiting for the running ones, so + // nothing joins wg while Close waits on it. + closed bool +} + +func New(appState AppState, cfg Config) *Controller { + if cfg.Clock == nil { + cfg.Clock = clockwork.NewRealClock() + } + if cfg.BackgroundSyncWindow == 0 { + cfg.BackgroundSyncWindow = DefaultBackgroundSyncWindow + } + if cfg.BackgroundTaskPollInterval == 0 { + cfg.BackgroundTaskPollInterval = DefaultBackgroundTaskPollInterval + } + if cfg.BackgroundTaskMaxDuration == 0 { + cfg.BackgroundTaskMaxDuration = DefaultBackgroundTaskMaxDuration + } + if cfg.Flush == nil { + cfg.Flush = func() {} + } + if cfg.Debug == nil { + cfg.Debug = func(string, ...interface{}) {} + } + c := &Controller{appState: appState, cfg: cfg, holds: make(map[int64]*Hold)} + c.ctx, c.cancel = context.WithCancel(context.Background()) + switch appState.State() { + case keybase1.MobileAppState_FOREGROUND: + c.ui = UIActive + case keybase1.MobileAppState_INACTIVE: + c.ui = UIInactive + default: + c.ui = UIBackground + } + return c +} + +// Close ends the background tasks the controller runs and waits for them to +// return. No task starts after it. +func (c *Controller) Close() { + c.cancel() + c.mu.Lock() + c.closed = true + c.mu.Unlock() + c.wg.Wait() +} + +func derive(ui UIState, holds int) keybase1.MobileAppState { + switch { + case ui == UIActive: + return keybase1.MobileAppState_FOREGROUND + case ui == UIInactive: + return keybase1.MobileAppState_INACTIVE + case holds > 0: + return keybase1.MobileAppState_BACKGROUNDACTIVE + default: + return keybase1.MobileAppState_BACKGROUND + } +} + +func (c *Controller) debugLocked(event string, format string, args ...interface{}) { + c.cfg.Debug("lifecycle: %s: %s (ui: %v, holds: %d, state: %v)", event, fmt.Sprintf(format, args...), + c.ui, len(c.holds), c.appState.State()) +} + +// applyLocked writes the derived state. The OS may suspend or kill the +// process once the UI is in the background or nothing holds it up, so it +// flushes when the state moves into the background from anything but +// BACKGROUND. +func (c *Controller) applyLocked() { + prev := c.appState.State() + state := derive(c.ui, len(c.holds)) + if c.appState.Update(state) && prev != keybase1.MobileAppState_BACKGROUND && + (state == keybase1.MobileAppState_BACKGROUND || state == keybase1.MobileAppState_BACKGROUNDACTIVE) { + c.cfg.Flush() + } +} + +func (c *Controller) acquireLocked(reason Reason) *Hold { + c.nextID++ + h := &Hold{c: c, id: c.nextID, reason: reason, done: make(chan struct{})} + c.holds[h.id] = h + return h +} + +// dropLocked ends every hold match selects and returns how many it ended. +func (c *Controller) dropLocked(match func(*Hold) bool) (dropped int) { + for id, h := range c.holds { + if match(h) { + delete(c.holds, id) + close(h.done) + dropped++ + } + } + return dropped +} + +// setUILocked records a UI report. Leaving the background ends the holds that +// only keep a backgrounded app alive. +func (c *Controller) setUILocked(ui UIState) { + if c.ui == UIBackground && ui != UIBackground { + c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask || h.reason == ReasonBackgroundSync }) + } + c.ui = ui +} + +// runningTaskLocked returns the open background task hold's id, or 0. +func (c *Controller) runningTaskLocked() int64 { + for id, h := range c.holds { + if h.reason == ReasonBackgroundTask { + return id + } + } + return 0 +} + +// startTaskLocked opens a background task hold and runs the task that keeps +// it until the work is done. A background task hold that is already open is +// reused instead, so one task at a time keeps the app up and warns about +// failures, and a later start doesn't extend its maximum duration. +func (c *Controller) startTaskLocked(deps BackgroundTaskDeps) int64 { + if c.closed { + return 0 + } + if id := c.runningTaskLocked(); id != 0 { + return id + } + h := c.acquireLocked(ReasonBackgroundTask) + c.wg.Add(1) + go func() { + defer c.wg.Done() + c.runBackgroundTask(h, deps) + }() + return h.id +} + +// AcquireBackgroundWork opens a live location hold, which keeps a backgrounded +// app BACKGROUNDACTIVE until it is released. Of the controller's events only +// WillTerminate ends it, which is why it is the one hold callers may open for +// themselves -- and why a caller holding one past a WillTerminate must check +// Released before it counts on it. +func (c *Controller) AcquireBackgroundWork() *Hold { + c.mu.Lock() + defer c.mu.Unlock() + h := c.acquireLocked(ReasonLiveLocation) + c.applyLocked() + c.debugLocked("acquire", "%v hold %d", h.reason, h.id) + return h +} + +func (c *Controller) release(h *Hold) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.dropLocked(func(o *Hold) bool { return o == h }) == 0 { + return false + } + c.applyLocked() + c.debugLocked("release", "%v hold %d", h.reason, h.id) + return true +} + +func (c *Controller) UIActive() { + c.mu.Lock() + defer c.mu.Unlock() + c.setUILocked(UIActive) + c.applyLocked() + c.debugLocked("uiActive", "applied") +} + +// UIInactive covers the app on screen without receiving events (Control +// Center, alerts, the app switcher, iPad focus loss) and a scene or process +// coming to the foreground before it is active. +func (c *Controller) UIInactive() { + c.mu.Lock() + defer c.mu.Unlock() + c.setUILocked(UIInactive) + c.applyLocked() + c.debugLocked("uiInactive", "applied") +} + +// UIBackground records the UI leaving the screen and starts a background task, +// which keeps the app BACKGROUNDACTIVE while work must keep going and ends at +// once when none does. It returns the task hold's token for +// WaitBackgroundTask, or 0 once the controller is closed. +// +// A report while the UI is already in the background starts nothing -- a new +// task would take the app through BACKGROUNDACTIVE and back for no reason. It +// returns the running task's token, or 0. Android reports this after a +// finishing activity's willExit, once the process stops. +func (c *Controller) UIBackground(deps BackgroundTaskDeps) int64 { + c.mu.Lock() + defer c.mu.Unlock() + if c.ui == UIBackground { + token := c.runningTaskLocked() + c.debugLocked("uiBackground", "already in the background, background task hold %d", token) + return token + } + c.setUILocked(UIBackground) + token := c.startTaskLocked(deps) + c.applyLocked() + c.debugLocked("uiBackground", "background task hold %d", token) + return token +} + +// WaitBackgroundTask returns once the hold at token has ended, which is what +// native is asking about: whether Go still needs background time. Ids are +// never reused, so no entry means the hold has already ended. +func (c *Controller) WaitBackgroundTask(token int64) { + c.mu.Lock() + h := c.holds[token] + c.mu.Unlock() + if h == nil { + return + } + <-h.done + // A hold's done closes under the lock, before the state its end derives is + // written; taking the lock again waits for that write, so a caller that + // gives up its background time never leaves a stale state behind. + c.mu.Lock() + defer c.mu.Unlock() + c.debugLocked("waitBackgroundTask", "hold %d ended", token) +} + +// WillTerminate ends every hold: the process is about to die. notifyPending +// warns about messages that won't send; it runs last because it can take +// seconds and native waits only briefly. +func (c *Controller) WillTerminate(notifyPending func()) { + c.mu.Lock() + c.setUILocked(UIBackground) + c.dropLocked(func(*Hold) bool { return true }) + c.applyLocked() + c.debugLocked("willTerminate", "ended every hold") + c.mu.Unlock() + notifyPending() +} + +// BackgroundTaskExpired ends every background task hold: iOS is ending the +// app's background time, which is per app, so every UIKit task still open +// expires with it. Live location, push window and sync holds keep their own +// lifetimes. +func (c *Controller) BackgroundTaskExpired(notifyPending func()) { + c.mu.Lock() + ended := c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask }) + c.applyLocked() + c.debugLocked("backgroundTaskExpired", "ended %d background task holds", ended) + c.mu.Unlock() + if ended > 0 { + notifyPending() + } +} + +// PushWindowBegin holds the app up while a push is handled. It returns the +// hold's token, or 0 when the app is active and nothing needs holding. +func (c *Controller) PushWindowBegin() int64 { + c.mu.Lock() + defer c.mu.Unlock() + if c.ui == UIActive { + c.debugLocked("pushWindowBegin", "skipped in the foreground") + return 0 + } + h := c.acquireLocked(ReasonPushWindow) + c.applyLocked() + c.debugLocked("pushWindowBegin", "hold %d", h.id) + return h.id +} + +// PushWindowEnd ends the push window's hold. If the UI is still in the +// background, it first hands over to a background task, which keeps the app +// up while work must keep going. The token it returns is for the test harness; +// native ignores it. +func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 { + c.mu.Lock() + defer c.mu.Unlock() + var task int64 + if h, ok := c.holds[token]; ok && h.reason == ReasonPushWindow { + if c.ui == UIBackground { + task = c.startTaskLocked(deps) + } + c.dropLocked(func(o *Hold) bool { return o == h }) + } + c.applyLocked() + c.debugLocked("pushWindowEnd", "hold %d ended, background task hold %d", token, task) + return task +} + +// BackgroundSync holds the app up for the sync window while the UI is in the +// background. It returns a status for native logs. +func (c *Controller) BackgroundSync() string { + c.mu.Lock() + if c.ui != UIBackground { + msg := "skipping, app not in background state: " + c.appState.State().String() + c.debugLocked("backgroundSyncBegin", "%s", msg) + c.mu.Unlock() + return msg + } + h := c.acquireLocked(ReasonBackgroundSync) + c.applyLocked() + c.debugLocked("backgroundSyncBegin", "hold %d", h.id) + c.mu.Unlock() + var msg string + select { + case <-h.done: + msg = "bailing out early, hold ended: " + c.appState.State().String() + case <-c.cfg.Clock.After(c.cfg.BackgroundSyncWindow): + msg = "completed window" + } + h.Release() + c.cfg.Debug("lifecycle: backgroundSyncEnd: hold %d: %s", h.id, msg) + return msg +} + +// runBackgroundTask keeps the background task hold h while work must keep +// going: until outgoing messages are delivered, one fails, time runs out, the +// hold is ended (the UI left the background, expiration, termination) or the +// controller is closed. +func (c *Controller) runBackgroundTask(h *Hold, deps BackgroundTaskDeps) { + if !deps.Stay() { + released := h.Release() + c.cfg.Debug("lifecycle: backgroundTaskEnd: hold %d done because: nothing to keep running, released: %v", + h.id, released) + return + } + clock := c.cfg.Clock + // Round(0) drops the monotonic reading, so time the device spends asleep + // counts toward the maximum. + beginTime := clock.Now().Round(0) + g, ctx := errgroup.WithContext(c.ctx) + g.Go(func() error { + select { + case <-h.done: + return errors.New("hold ended") + case <-ctx.Done(): + return ctx.Err() + } + }) + g.Go(func() error { + ch, cancel := deps.NextFailure() + defer cancel() + select { + case obrs := <-ch: + deps.NotifyFailure(obrs) + return fmt.Errorf("failure received: %d marked", len(obrs)) + case <-ctx.Done(): + return ctx.Err() + } + }) + g.Go(func() error { + // An empty outbox can race a failure, so it takes three empty polls in + // a row to count as delivered. + emptyPolls := 0 + var pending []chat1.OutboxRecord + for { + select { + case <-clock.After(c.cfg.BackgroundTaskPollInterval): + case <-ctx.Done(): + return ctx.Err() + } + obrs, err := deps.ActiveDeliveries(ctx) + switch { + case err != nil: + c.cfg.Debug("lifecycle: failed to query active deliveries: %s", err) + case len(obrs) == 0: + pending = nil + emptyPolls++ + if emptyPolls > 2 { + return errors.New("delivered everything") + } + default: + pending = obrs + emptyPolls = 0 + } + if clock.Now().Round(0).Sub(beginTime) >= c.cfg.BackgroundTaskMaxDuration { + deps.NotifyFailure(pending) + return errors.New("time expired") + } + } + }) + err := g.Wait() + released := h.Release() + c.cfg.Debug("lifecycle: backgroundTaskEnd: hold %d done because: %v, released: %v", h.id, err, released) +} diff --git a/go/libkb/lifecycle/lifecycletest/clock.go b/go/libkb/lifecycle/lifecycletest/clock.go new file mode 100644 index 000000000000..283ef3ef441a --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/clock.go @@ -0,0 +1,72 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycletest + +import ( + "sync" + "testing" + "time" + + "github.com/keybase/clockwork" +) + +// FakeClock is a clockwork fake clock that also reports each After call, so a +// test can advance time only once the code under test is waiting on it. +type FakeClock struct { + clockwork.FakeClock + mu sync.Mutex + pending map[time.Duration]int + changed chan struct{} +} + +func NewFakeClock() *FakeClock { + return &FakeClock{ + FakeClock: clockwork.NewFakeClock(), + pending: make(map[time.Duration]int), + changed: make(chan struct{}), + } +} + +func (c *FakeClock) After(d time.Duration) <-chan time.Time { + ch := c.FakeClock.After(d) + c.mu.Lock() + defer c.mu.Unlock() + c.pending[d]++ + close(c.changed) + c.changed = make(chan struct{}) + return ch +} + +// ForgetAfters drops unconsumed After calls, such as those of a goroutine +// that has exited. +func (c *FakeClock) ForgetAfters() { + c.mu.Lock() + defer c.mu.Unlock() + c.pending = make(map[time.Duration]int) +} + +// WaitForAfter consumes one After(d) call, waiting for it if needed. It +// returns false if done closes first. +func (c *FakeClock) WaitForAfter(t testing.TB, d time.Duration, done <-chan struct{}) bool { + t.Helper() + timeout := time.After(5 * time.Second) + for { + c.mu.Lock() + if c.pending[d] > 0 { + c.pending[d]-- + c.mu.Unlock() + return true + } + changed := c.changed + c.mu.Unlock() + select { + case <-changed: + case <-done: + return false + case <-timeout: + t.Fatalf("nothing waited on After(%v)", d) + return false + } + } +} diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go new file mode 100644 index 000000000000..53d5bc8fa29b --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -0,0 +1,435 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycletest + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/libkb/lifecycle" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +type Platform int + +const ( + IOS Platform = iota + Android +) + +func (p Platform) String() string { + if p == Android { + return "android" + } + return "ios" +} + +// InitialState is the state the service starts in on both platforms. +const InitialState = keybase1.MobileAppState_BACKGROUND + +type Action int + +const ( + // Nothing reports no event, as when an Android dialog, permission prompt + // or picker pauses the activity. + Nothing Action = iota + 1 + + // Native lifecycle events, as native reports them: willEnterForeground and + // willResignActive are UIInactive, didBecomeActive is UIActive, + // didEnterBackground is UIBackground. PushWindowBegin and PushWindowEnd + // bracket a push or notification action, as the bind layer handles one: + // on iOS they don't reach the controller and return false. + // DidEnterBackground, and PushWindowEnd when it hands over, start a + // background task: they wait until it is polling and return true, or until + // it has ended at once, with nothing to keep running, and return false. A + // DidEnterBackground while the UI is already in the background starts + // nothing: it returns true if it joined a running task, false otherwise. + WillEnterForeground + DidBecomeActive + WillResignActive + DidEnterBackground + WillTerminate + BackgroundTaskExpired + PushWindowBegin + PushWindowEnd + LiveLocationAcquire + LiveLocationRelease + + // BackgroundSyncStart starts the blocking BackgroundSync call and waits + // until it is waiting out its window (returns true) or has skipped + // (returns false). + BackgroundSyncStart + // BackgroundSyncTimerFires advances the clock past the sync window and + // waits for BackgroundSync to return. + BackgroundSyncTimerFires + // BackgroundSyncWait waits for a BackgroundSync that bails out on its own. + BackgroundSyncWait + + // BackgroundTaskDelivered finishes pending deliveries and polls until + // the task returns. + BackgroundTaskDelivered + BackgroundTaskFails + // BackgroundTaskTimesUp advances the clock past the task's maximum + // duration with a delivery still pending. + BackgroundTaskTimesUp + // BackgroundTaskWait waits for a task that exits on its own, after a + // state change. + BackgroundTaskWait + + // WorkStarts makes a delivery pending, so the app must keep running in + // the background. + WorkStarts + // WorkStops clears pending work. + WorkStops +) + +var actionNames = map[Action]string{ + Nothing: "Nothing", + WillEnterForeground: "WillEnterForeground", + DidBecomeActive: "DidBecomeActive", + WillResignActive: "WillResignActive", + DidEnterBackground: "DidEnterBackground", + WillTerminate: "WillTerminate", + BackgroundTaskExpired: "BackgroundTaskExpired", + PushWindowBegin: "PushWindowBegin", + PushWindowEnd: "PushWindowEnd", + LiveLocationAcquire: "LiveLocationAcquire", + LiveLocationRelease: "LiveLocationRelease", + BackgroundSyncStart: "BackgroundSyncStart", + BackgroundSyncTimerFires: "BackgroundSyncTimerFires", + BackgroundSyncWait: "BackgroundSyncWait", + BackgroundTaskDelivered: "BackgroundTaskDelivered", + BackgroundTaskFails: "BackgroundTaskFails", + BackgroundTaskTimesUp: "BackgroundTaskTimesUp", + BackgroundTaskWait: "BackgroundTaskWait", + WorkStarts: "WorkStarts", + WorkStops: "WorkStops", +} + +func (a Action) String() string { + if name, ok := actionNames[a]; ok { + return name + } + return fmt.Sprintf("Action(%d)", int(a)) +} + +type Return int + +const ( + // ReturnNone: the action returns nothing to check. + ReturnNone Return = iota + ReturnTrue + ReturnFalse +) + +// Step is one action and what must hold right after it. +type Step struct { + Do Action + // Slot names the push window for PushWindowBegin/End. + Slot int + Want keybase1.MobileAppState + // Flushes: how many times local DBs were flushed. + Flushes int + // Warn: the user was warned about messages that won't send. + Warn bool + Returns Return +} + +type Scenario struct { + Name string + Platform Platform + Steps []Step + // Observed is every state a consumer sees, starting with the initial + // state. + Observed []keybase1.MobileAppState +} + +// Harness drives a Controller with a fake clock and fake chat deliveries, and +// records what consumers of the app state observe. +type Harness struct { + T testing.TB + Platform Platform + AppState lifecycle.AppState + Clock *FakeClock + Controller *lifecycle.Controller + Recorder *Recorder + + flushes atomic.Int32 + warnings atomic.Int32 + stay atomic.Bool + pending atomic.Int32 + failures chan []chat1.OutboxRecord + tokens map[int]int64 + liveLocation *lifecycle.Hold + + // A new background task asks Stay only once stayGate lets it, so the + // recorder sees the BACKGROUNDACTIVE the task may leave at once. + stayGate chan struct{} + closing chan struct{} + task int64 + syncDone chan struct{} + taskDone chan struct{} + running sync.WaitGroup +} + +const ( + syncWindow = 10 * time.Second + pollInterval = 5 * time.Second + maxDuration = 10 * time.Minute +) + +// NewHarness moves appState to the initial state and starts +// recording. Close it when done. +func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *Harness { + appState.Update(InitialState) + h := &Harness{ + T: t, + Platform: platform, + AppState: appState, + Clock: NewFakeClock(), + failures: make(chan []chat1.OutboxRecord, 1), + tokens: make(map[int]int64), + stayGate: make(chan struct{}), + closing: make(chan struct{}), + syncDone: closedChan(), + taskDone: closedChan(), + } + h.Controller = lifecycle.New(appState, lifecycle.Config{ + Clock: h.Clock, + BackgroundSyncWindow: syncWindow, + BackgroundTaskPollInterval: pollInterval, + BackgroundTaskMaxDuration: maxDuration, + Flush: func() { h.flushes.Add(1) }, + Debug: func(format string, args ...interface{}) { t.Logf(format, args...) }, + }) + h.Recorder = NewRecorder(appState) + return h +} + +func closedChan() chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch +} + +// Close ends any background task or sync still running, and the recorder. +func (h *Harness) Close() { + close(h.closing) + h.Controller.Close() + h.Clock.Advance(maxDuration) + h.running.Wait() + h.Recorder.Stop() +} + +func (h *Harness) Flushes() int { return int(h.flushes.Load()) } +func (h *Harness) Warnings() int { return int(h.warnings.Load()) } + +func (h *Harness) warn() { h.warnings.Add(1) } + +func (h *Harness) deps() lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + Stay: func() bool { + select { + case <-h.stayGate: + case <-h.closing: + } + return h.stay.Load() + }, + ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { + return make([]chat1.OutboxRecord, h.pending.Load()), nil + }, + NextFailure: func() (chan []chat1.OutboxRecord, func()) { return h.failures, func() {} }, + NotifyFailure: func([]chat1.OutboxRecord) { h.warn() }, + } +} + +func (h *Harness) goRun(f func()) chan struct{} { + done := make(chan struct{}) + h.running.Add(1) + go func() { + defer h.running.Done() + defer close(done) + f() + }() + return done +} + +func (h *Harness) wait(done chan struct{}, what string) { + h.T.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + h.T.Fatalf("%s did not return", what) + } +} + +// Do performs step and checks what must hold after it. +func (h *Harness) Do(step Step) { + t := h.T + t.Helper() + flushes, warnings := h.Flushes(), h.Warnings() + ret := h.perform(step) + h.Recorder.Sync(t) + state := h.AppState.State() + if state != step.Want { + t.Fatalf("%v: state %v, want %v", step.Do, state, step.Want) + } + if got := h.Flushes() - flushes; got != step.Flushes { + t.Fatalf("%v: %d flushes, want %d", step.Do, got, step.Flushes) + } + if got := h.Warnings() - warnings; got != boolInt(step.Warn) { + t.Fatalf("%v: %d pending-message warnings, want %d", step.Do, got, boolInt(step.Warn)) + } + if step.Returns != ReturnNone && ret != (step.Returns == ReturnTrue) { + t.Fatalf("%v: returned %v, want %v", step.Do, ret, step.Returns == ReturnTrue) + } +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func (h *Harness) perform(step Step) bool { + h.T.Helper() + c := h.Controller + switch step.Do { + case Nothing: + case WillEnterForeground, WillResignActive: + c.UIInactive() + case DidBecomeActive: + c.UIActive() + case DidEnterBackground: + return h.startsTask(func() int64 { return c.UIBackground(h.deps()) }) + case WillTerminate: + c.WillTerminate(h.warn) + case BackgroundTaskExpired: + c.BackgroundTaskExpired(h.warn) + case PushWindowBegin: + if h.Platform != Android { + return false + } + h.tokens[step.Slot] = c.PushWindowBegin() + return h.tokens[step.Slot] > 0 + case PushWindowEnd: + if h.Platform != Android { + return false + } + return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], h.deps()) }) + case LiveLocationAcquire: + h.liveLocation = c.AcquireBackgroundWork() + case LiveLocationRelease: + require.NotNil(h.T, h.liveLocation, "LiveLocationRelease without LiveLocationAcquire") + h.liveLocation.Release() + case BackgroundSyncStart: + h.Clock.ForgetAfters() + h.syncDone = h.goRun(func() { c.BackgroundSync() }) + return h.Clock.WaitForAfter(h.T, syncWindow, h.syncDone) + case BackgroundSyncTimerFires: + h.Clock.Advance(syncWindow) + h.wait(h.syncDone, "BackgroundSync") + case BackgroundSyncWait: + h.wait(h.syncDone, "BackgroundSync") + case BackgroundTaskDelivered: + h.pending.Store(0) + for { + h.Clock.Advance(pollInterval) + if !h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone) { + break + } + } + h.wait(h.taskDone, "background task") + case BackgroundTaskFails: + h.failures <- make([]chat1.OutboxRecord, 1) + h.wait(h.taskDone, "background task") + case BackgroundTaskTimesUp: + h.Clock.Advance(maxDuration) + h.wait(h.taskDone, "background task") + case BackgroundTaskWait: + h.wait(h.taskDone, "background task") + case WorkStarts: + h.stay.Store(true) + h.pending.Store(1) + case WorkStops: + h.stay.Store(false) + h.pending.Store(0) + default: + h.T.Fatalf("unknown action %v", step.Do) + } + return false +} + +// startsTask runs a call that may start a background task and, if it did, +// waits until the task is polling or has ended. It reports whether the task +// is running. +func (h *Harness) startsTask(call func() int64) bool { + h.T.Helper() + h.Clock.ForgetAfters() + token := call() + if token == 0 { + return false + } + if token == h.task { + // The call reused the running task's hold; that task is past Stay. + select { + case <-h.taskDone: + return false + default: + return true + } + } + h.task = token + h.taskDone = h.goRun(func() { h.Controller.WaitBackgroundTask(token) }) + h.Recorder.Sync(h.T) + select { + case h.stayGate <- struct{}{}: + case <-time.After(5 * time.Second): + h.T.Fatalf("background task %d never asked whether to stay", token) + } + return h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone) +} + +// Play runs every step of sc on a fresh harness and checks the observed +// states. afterStep, if set, runs after each step's checks, for a consumer +// test to check its own reaction. +func Play(t *testing.T, appState lifecycle.AppState, sc Scenario, afterStep func(h *Harness, i int, step Step)) { + t.Helper() + h := NewHarness(t, appState, sc.Platform) + defer h.Close() + for i, step := range sc.Steps { + h.Do(step) + if afterStep != nil { + afterStep(h, i, step) + } + } + h.CheckObserved(sc.Observed) +} + +func (h *Harness) CheckObserved(want []keybase1.MobileAppState) { + h.T.Helper() + got := h.Recorder.States() + if fmt.Sprint(got) != fmt.Sprint(want) { + h.T.Fatalf("observed states %v, want %v", got, want) + } +} + +// NoWork is what a background task sees when nothing must keep a backgrounded +// app running: it ends at once. +func NoWork() lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{Stay: func() bool { return false }} +} + +// ToBackground reports the UI in the background with nothing to keep running, +// and returns once the background task that starts, if any, has ended. +func ToBackground(c *lifecycle.Controller) { + c.WaitBackgroundTask(c.UIBackground(NoWork())) +} diff --git a/go/libkb/lifecycle/lifecycletest/recorder.go b/go/libkb/lifecycle/lifecycletest/recorder.go new file mode 100644 index 000000000000..447e31d229f5 --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/recorder.go @@ -0,0 +1,137 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +// Package lifecycletest replays native lifecycle event sequences against a +// lifecycle.Controller and records what an app-state consumer observes. It +// doesn't import libkb, so libkb's own tests can use it too. +package lifecycletest + +import ( + "sync" + "testing" + "time" + + "github.com/keybase/client/go/protocol/keybase1" +) + +// Source is what an app-state consumer watches; *libkb.MobileAppState +// implements it. +type Source interface { + State() keybase1.MobileAppState + NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} +} + +// Recorder watches a Source the way consumers do: it seeds from State() and +// wakes on NextUpdate. Like any consumer it can miss a state that is replaced +// before it wakes (X to Y and back to X records nothing); Sync only guarantees +// the recorder has woken for every change so far. +type Recorder struct { + src Source + mu sync.Mutex + states []keybase1.MobileAppState + // waiting is the NextUpdate channel the recorder is blocked on. Every + // real change closes and replaces the source's channel. + waiting <-chan struct{} + stop chan struct{} + done chan struct{} +} + +func NewRecorder(src Source) *Recorder { + r := &Recorder{ + src: src, + stop: make(chan struct{}), + done: make(chan struct{}), + } + state := src.State() + r.states = []keybase1.MobileAppState{state} + go r.loop(state) + return r +} + +func (r *Recorder) loop(state keybase1.MobileAppState) { + defer close(r.done) + for { + ch := r.src.NextUpdate(state) + r.mu.Lock() + r.waiting = ch + r.mu.Unlock() + select { + case <-ch: + case <-r.stop: + return + } + state = r.src.State() + r.mu.Lock() + if r.states[len(r.states)-1] != state { + r.states = append(r.states, state) + } + r.mu.Unlock() + } +} + +// Stop ends the recording and waits for the watcher goroutine to exit. +func (r *Recorder) Stop() { + select { + case <-r.stop: + default: + close(r.stop) + } + <-r.done +} + +// States returns the observed states, starting with the seed. Consecutive +// entries always differ. +func (r *Recorder) States() []keybase1.MobileAppState { + r.mu.Lock() + defer r.mu.Unlock() + return append([]keybase1.MobileAppState(nil), r.states...) +} + +func (r *Recorder) Last() keybase1.MobileAppState { + r.mu.Lock() + defer r.mu.Unlock() + return r.states[len(r.states)-1] +} + +// Teardowns counts observed entries into BACKGROUND after the seed: the only +// state in which network and servers go down. +func (r *Recorder) Teardowns() int { + n := 0 + for _, s := range r.States()[1:] { + if s == keybase1.MobileAppState_BACKGROUND { + n++ + } + } + return n +} + +// Sync waits until the recorder has woken for every change to the source so +// far: it is blocked on the source's current, still open, NextUpdate channel. +// Comparing values alone would miss a change and its reversal within one step. +// Only meaningful while nothing else is updating the state. +func (r *Recorder) Sync(t testing.TB) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !r.synced() { + if time.Now().After(deadline) { + t.Fatalf("recorder stuck at %v, state is %v", r.Last(), r.src.State()) + } + time.Sleep(time.Millisecond) + } +} + +func (r *Recorder) synced() bool { + r.mu.Lock() + waiting := r.waiting + last := r.states[len(r.states)-1] + r.mu.Unlock() + if waiting == nil || waiting != r.src.NextUpdate(last) { + return false + } + select { + case <-waiting: + return false + default: + return true + } +} diff --git a/go/libkb/lifecycle/lifecycletest/recorder_test.go b/go/libkb/lifecycle/lifecycletest/recorder_test.go new file mode 100644 index 000000000000..2bf56765b9f3 --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/recorder_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycletest + +import ( + "sync" + "testing" + "time" + + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// slowWakeSource is a Source whose replaced NextUpdate channels close only on +// wake, standing in for a recorder goroutine that hasn't been scheduled yet. +type slowWakeSource struct { + mu sync.Mutex + state keybase1.MobileAppState + changed chan struct{} + unwoken []chan struct{} +} + +func (s *slowWakeSource) State() keybase1.MobileAppState { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +func (s *slowWakeSource) NextUpdate(last keybase1.MobileAppState) <-chan struct{} { + s.mu.Lock() + defer s.mu.Unlock() + if last != s.state { + ch := make(chan struct{}) + close(ch) + return ch + } + return s.changed +} + +func (s *slowWakeSource) update(state keybase1.MobileAppState) { + s.mu.Lock() + defer s.mu.Unlock() + if s.state != state { + s.state = state + s.unwoken = append(s.unwoken, s.changed) + s.changed = make(chan struct{}) + } +} + +func (s *slowWakeSource) wake() { + s.mu.Lock() + defer s.mu.Unlock() + for _, ch := range s.unwoken { + close(ch) + } + s.unwoken = nil +} + +// A change and its reversal leave the value where it was; Sync must still +// wait for the recorder to wake and re-arm. +func TestRecorderSyncWaitsForChangeAndReversal(t *testing.T) { + src := &slowWakeSource{state: keybase1.MobileAppState_FOREGROUND, changed: make(chan struct{})} + r := NewRecorder(src) + defer r.Stop() + r.Sync(t) + + src.update(keybase1.MobileAppState_BACKGROUND) + src.update(keybase1.MobileAppState_FOREGROUND) + synced := make(chan struct{}) + go func() { + r.Sync(t) + close(synced) + }() + select { + case <-synced: + require.Fail(t, "Sync returned before the recorder woke for the change") + case <-time.After(50 * time.Millisecond): + } + src.wake() + select { + case <-synced: + case <-time.After(5 * time.Second): + require.Fail(t, "Sync never returned") + } +} diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go new file mode 100644 index 000000000000..cc8f0aa9262c --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -0,0 +1,537 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycletest + +import "github.com/keybase/client/go/protocol/keybase1" + +const ( + fg = keybase1.MobileAppState_FOREGROUND + bg = keybase1.MobileAppState_BACKGROUND + bga = keybase1.MobileAppState_BACKGROUNDACTIVE + ina = keybase1.MobileAppState_INACTIVE +) + +func step(do Action, want keybase1.MobileAppState) Step { return Step{Do: do, Want: want} } + +func (s Step) flush() Step { return s.flushes(1) } + +func (s Step) flushes(n int) Step { + s.Flushes = n + return s +} + +func (s Step) warn() Step { + s.Warn = true + return s +} + +func (s Step) returns(b bool) Step { + if b { + s.Returns = ReturnTrue + } else { + s.Returns = ReturnFalse + } + return s +} + +func (s Step) slot(n int) Step { + s.Slot = n + return s +} + +func steps(parts ...[]Step) []Step { + var all []Step + for _, p := range parts { + all = append(all, p...) + } + return all +} + +func states(s ...keybase1.MobileAppState) []keybase1.MobileAppState { return s } + +// toForeground brings the app to the foreground: an iOS scene connects and +// becomes active, an Android process starts and resumes. +var toForeground = []Step{ + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), +} + +// iosToBackgroundTask backgrounds a foreground app with a message still +// sending, which starts the background task. +var iosToBackgroundTask = []Step{ + step(WorkStarts, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bga).flush().returns(true), +} + +// Scenarios replays whole native event sequences. Consumers of the app state +// can play them with their own checks (see Play). +var Scenarios = []Scenario{ + {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, + { + // iOS handles a push within the time it grants for it, so nothing is held. + Name: "ios background launch by silent push stays in BACKGROUND, then foreground", + Platform: IOS, + Steps: steps([]Step{ + step(PushWindowBegin, bg).returns(false), + step(PushWindowEnd, bg).returns(false), + }, toForeground), + Observed: states(bg, ina, fg), + }, + { + Name: "ios silent push while active holds nothing", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(PushWindowBegin, fg).returns(false), + step(PushWindowEnd, fg).returns(false), + }), + Observed: states(bg, ina, fg), + }, + { + // iOS suspends the app once the push's completion handler runs. + Name: "ios silent push with a message still sending starts no background task", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(WorkStarts, bg), + step(PushWindowBegin, bg).returns(false), + step(PushWindowEnd, bg).returns(false), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios background launch by BGAppRefresh, then foreground", + Platform: IOS, + Steps: steps([]Step{ + step(BackgroundSyncStart, bga).returns(true), + step(BackgroundSyncTimerFires, bg).flush(), + }, toForeground), + Observed: states(bg, bga, bg, ina, fg), + }, + { + Name: "ios home and return", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), + }, + { + Name: "ios quick background and foreground cycles with duplicate events", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).returns(false), + step(WillEnterForeground, ina), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(DidBecomeActive, fg), + // Backgrounding abandoned before didEnterBackground. + step(WillResignActive, ina), + step(DidBecomeActive, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, bga, bg, ina, fg, ina, fg, ina, bga, bg, ina, fg), + }, + { + Name: "ios control center or system alert keeps things up", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), step(DidBecomeActive, fg), + step(WillResignActive, ina), step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, fg, ina, fg), + }, + { + Name: "ipad focus loss keeps things up", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), step(WillResignActive, ina), step(DidBecomeActive, fg), + step(WillResignActive, ina), step(DidBecomeActive, fg), step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, fg, ina, fg), + }, + { + Name: "ios lock and unlock", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), + }, + { + // Leaving the background ends the sync's hold, so the sync returns at once. + Name: "ios BackgroundSync window racing willEnterForeground and didBecomeActive", + Platform: IOS, + Steps: []Step{ + step(BackgroundSyncStart, bga).returns(true), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundSyncWait, fg), + }, + Observed: states(bg, bga, ina, fg), + }, + { + Name: "ios slow didBecomeActive after the BackgroundSync window ends", + Platform: IOS, + Steps: []Step{ + step(BackgroundSyncStart, bga).returns(true), + step(WillEnterForeground, ina), + step(BackgroundSyncTimerFires, ina), + step(DidBecomeActive, fg), + }, + Observed: states(bg, bga, ina, fg), + }, + { + Name: "ios BackgroundSync skips outside the background", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(BackgroundSyncStart, fg).returns(false), + step(WillResignActive, ina), + step(BackgroundSyncStart, ina).returns(false), + }), + Observed: states(bg, ina, fg, ina), + }, + { + Name: "ios background task completes", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(BackgroundTaskDelivered, bg).flush(), + step(BackgroundTaskExpired, bg), + }, toForeground), + Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), + }, + { + Name: "ios background task fails", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios background task runs out of time", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios background task expires", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(BackgroundTaskExpired, bg).flush().warn(), + step(BackgroundTaskWait, bg), + step(BackgroundTaskExpired, bg), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios background task expires after return to foreground", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundTaskWait, fg), + step(BackgroundTaskExpired, fg), + }), + Observed: states(bg, ina, fg, ina, bga, ina, fg), + }, + { + Name: "ios background task expires between willEnterForeground and didBecomeActive", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(WillEnterForeground, ina), + step(BackgroundTaskExpired, ina), + step(BackgroundTaskDelivered, ina), + step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, bga, ina, fg), + }, + { + // Leaving the background ended the task's hold; finishing later changes nothing. + Name: "ios background task finishes after willEnterForeground", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(WillEnterForeground, ina), + step(BackgroundTaskDelivered, ina), + step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg, ina, bga, ina, fg), + }, + { + Name: "ios live location across background", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(BackgroundTaskDelivered, bg).flush(), + // A location update wakes the app while tracking. + step(LiveLocationAcquire, bga), + // Tracking ends. + step(LiveLocationRelease, bg).flush(), + step(LiveLocationAcquire, bga), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(LiveLocationRelease, fg), + // A hold taken in the foreground keeps the app running once it backgrounds. + step(LiveLocationAcquire, fg), + step(WorkStops, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bga).flush().returns(false), + step(LiveLocationRelease, bg).flush(), + }), + Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), + }, + { + // A repeat didEnterBackground is defensive: a single-scene iOS app + // doesn't report twice. It joins the running task instead of starting + // another that would warn again. + Name: "ios duplicate didEnterBackground keeps one background task", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(DidEnterBackground, bga).returns(true), + step(BackgroundTaskFails, bg).flush().warn(), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios background task expiration keeps live location running", + Platform: IOS, + Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{ + step(BackgroundTaskExpired, bga).warn(), + step(BackgroundTaskWait, bga), + step(LiveLocationRelease, bg).flush(), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios termination from the background", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(WillTerminate, bg).warn(), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios termination from the foreground", + Platform: IOS, + Steps: steps(toForeground, []Step{step(WillTerminate, bg).flush().warn()}), + Observed: states(bg, ina, fg, bg), + }, + { + Name: "ios termination during a background task", + Platform: IOS, + Steps: steps(toForeground, iosToBackgroundTask, []Step{ + step(WillTerminate, bg).flush().warn(), + step(BackgroundTaskWait, bg), + step(BackgroundTaskExpired, bg), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + { + Name: "ios termination ends live location's hold", + Platform: IOS, + Steps: steps(toForeground, []Step{ + step(LiveLocationAcquire, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bga).flush().returns(false), + step(WillTerminate, bg).flush().warn(), + step(LiveLocationRelease, bg), + }), + Observed: states(bg, ina, fg, ina, bga, bg), + }, + {Name: "android cold launch", Platform: Android, Steps: toForeground, Observed: states(bg, ina, fg)}, + { + Name: "android process stop and start", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(DidEnterBackground, bg).flushes(2).returns(false), + }, toForeground, []Step{ + step(WorkStarts, fg), + step(DidEnterBackground, bga).flush().returns(true), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundTaskWait, fg), + }), + Observed: states(bg, ina, fg, bga, bg, ina, fg, bga, ina, fg), + }, + { + // A finishing activity reports willExit while visible; the process stop + // that follows reports the background again, which starts nothing. + Name: "android willExit then process stop", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(WillTerminate, bg).flush().warn(), + step(WorkStarts, bg), + step(DidEnterBackground, bg).returns(false), + }), + Observed: states(bg, ina, fg, bg), + }, + { + Name: "android dialog, permission prompt or picker pause keeps the foreground", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(Nothing, fg), + step(PushWindowBegin, fg).returns(false), + step(PushWindowEnd, fg).returns(false), + // Back from the prompt: the process resumes without a start. + step(DidBecomeActive, fg), + }), + Observed: states(bg, ina, fg), + }, + { + Name: "android push window in the background", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(DidEnterBackground, bg).flushes(2).returns(false), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + }), + Observed: states(bg, ina, fg, bga, bg, bga, bg), + }, + { + // A process started without UI stays in BACKGROUND until the push window opens. + Name: "android push at cold start", + Platform: Android, + Steps: []Step{ + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + }, + Observed: states(bg, bga, bg), + }, + { + Name: "android quick reply at cold start hands the sending reply over to a background task", + Platform: Android, + Steps: []Step{ + step(PushWindowBegin, bga).returns(true), + step(WorkStarts, bga), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskDelivered, bg).flush(), + }, + Observed: states(bg, bga, bg), + }, + { + // The push window's hold lasts until its own end, whatever the process does meanwhile. + Name: "android push window racing process start", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(DidEnterBackground, bg).flushes(2).returns(false), + step(PushWindowBegin, bga).returns(true), + step(WillEnterForeground, ina), + // No background task outside the background, even with work pending. + step(WorkStarts, ina), + step(PushWindowEnd, ina).returns(false), + step(DidBecomeActive, fg), + step(WorkStops, fg), + step(PushWindowBegin, fg).returns(false), + step(PushWindowEnd, fg).returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(PushWindowBegin, bga).returns(true), + }, toForeground, []Step{ + step(DidEnterBackground, bga).flush().returns(false), + step(PushWindowEnd, bg).flush().returns(false), + }), + Observed: states(bg, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg), + }, + { + Name: "android push window hands over to a background task", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(DidEnterBackground, bg).flushes(2).returns(false), + step(PushWindowBegin, bga).returns(true), + step(WorkStarts, bga), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskDelivered, bg).flush(), + }), + Observed: states(bg, ina, fg, bga, bg, bga, bg), + }, + { + Name: "android push window ending during a background task joins it", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(WorkStarts, fg), + step(DidEnterBackground, bga).flush().returns(true), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskFails, bg).flush().warn(), + }), + Observed: states(bg, ina, fg, bga, bg), + }, + { + Name: "android overlapping push windows", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(DidEnterBackground, bg).flushes(2).returns(false), + step(PushWindowBegin, bga).slot(0).returns(true), + step(PushWindowBegin, bga).slot(1).returns(true), + step(PushWindowEnd, bga).slot(0).returns(false), + step(PushWindowEnd, bg).slot(1).flush().returns(false), + }), + Observed: states(bg, ina, fg, bga, bg, bga, bg), + }, + { + // BackgroundSyncWorker doesn't init Go, so it only syncs in a process where + // something else did, such as a push at cold start. + Name: "android WorkManager BackgroundSync after a push cold start", + Platform: Android, + Steps: []Step{ + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + step(BackgroundSyncStart, bga).returns(true), + step(BackgroundSyncTimerFires, bg).flush(), + }, + Observed: states(bg, bga, bg, bga, bg), + }, + { + Name: "android UI starts during a WorkManager sync after a push cold start", + Platform: Android, + Steps: []Step{ + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + step(BackgroundSyncStart, bga).returns(true), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundSyncWait, fg), + }, + Observed: states(bg, bga, bg, bga, ina, fg), + }, + { + // The sync keeps its hold after the push window ends. + Name: "android WorkManager BackgroundSync racing a push window", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(DidEnterBackground, bg).flushes(2).returns(false), + step(BackgroundSyncStart, bga).returns(true), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bga).returns(false), + step(BackgroundSyncTimerFires, bg).flush(), + }), + Observed: states(bg, ina, fg, bga, bg, bga, bg), + }, + { + // A finishing activity reports willExit while the process lives on, so a + // push can still open a window. The next exit ends its hold, and the + // window's end then starts no task. + Name: "android termination", + Platform: Android, + Steps: steps(toForeground, []Step{ + step(WillTerminate, bg).flush().warn(), + step(PushWindowBegin, bga).returns(true), + step(WillTerminate, bg).flush().warn(), + step(WorkStarts, bg), + step(PushWindowEnd, bg).returns(false), + }), + Observed: states(bg, ina, fg, bg, bga, bg), + }, +} diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go new file mode 100644 index 000000000000..e885949acbf1 --- /dev/null +++ b/go/libkb/lifecycle/scenario_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycle_test + +import ( + "context" + "slices" + "strings" + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +func newAppState(t *testing.T) (*libkb.MobileAppState, *libkb.GlobalContext) { + tc := libkb.SetupTest(t, strings.ReplaceAll(t.Name(), "/", "_"), 0) + t.Cleanup(tc.Cleanup) + return libkb.NewMobileAppState(tc.G), tc.G +} + +// Each scenario runs against a real MobileAppState. Besides the harness's +// per-step checks, live RPCs must be canceled exactly on a real change into +// BACKGROUND, the one state that tears down network and servers. +func TestScenarios(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + appState, g := newAppState(t) + h := lifecycletest.NewHarness(t, appState, sc.Platform) + defer h.Close() + for _, step := range sc.Steps { + seen := len(h.Recorder.States()) + ctx, key := g.RPCCanceler.RegisterContext(context.Background(), libkb.RPCCancelerReasonBackground) + h.Do(step) + canceled := ctx.Err() != nil + g.RPCCanceler.UnregisterContext(key) + wantCancel := slices.Contains(h.Recorder.States()[seen:], keybase1.MobileAppState_BACKGROUND) + require.Equal(t, wantCancel, canceled, "%v: RPC cancel", step.Do) + } + h.CheckObserved(sc.Observed) + teardowns := 0 + for _, s := range sc.Observed[1:] { + if s == keybase1.MobileAppState_BACKGROUND { + teardowns++ + } + } + require.Equal(t, teardowns, h.Recorder.Teardowns()) + }) + } +} + +// Play is what consumer tests use; make sure it runs the same checks. +func TestPlay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + appState, _ := newAppState(t) + steps := 0 + lifecycletest.Play(t, appState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + require.Equal(t, step.Want, h.Recorder.Last()) + steps++ + }) + require.Equal(t, len(sc.Steps), steps) + }) + } +} + +// A scenario that fails midway must not hang in Close on work still waiting +// on the fake clock or on deliveries. +func TestHarnessCloseEndsRunningWork(t *testing.T) { + const bga = keybase1.MobileAppState_BACKGROUNDACTIVE + cases := map[string][]lifecycletest.Step{ + "background sync": { + {Do: lifecycletest.BackgroundSyncStart, Want: bga, Returns: lifecycletest.ReturnTrue}, + }, + "background task": { + {Do: lifecycletest.DidBecomeActive, Want: keybase1.MobileAppState_FOREGROUND}, + {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_FOREGROUND}, + {Do: lifecycletest.DidEnterBackground, Want: bga, Flushes: 1, Returns: lifecycletest.ReturnTrue}, + }, + } + for name, steps := range cases { + t.Run(name, func(t *testing.T) { + appState, _ := newAppState(t) + h := lifecycletest.NewHarness(t, appState, lifecycletest.IOS) + for _, step := range steps { + h.Do(step) + } + closed := make(chan struct{}) + go func() { + h.Close() + close(closed) + }() + select { + case <-closed: + case <-time.After(5 * time.Second): + require.Fail(t, "Close hung") + } + }) + } +} From 97b3d788291ef4a623ca63a98ec3d584ccc0a0d6 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:03:36 -0400 Subject: [PATCH 02/16] feat(protocol): add NotifyApp.mobileAppStateChanged The service derives the app's lifecycle state; this is how a client learns it. Deriving it a second time from the OS would mean two answers -- on iOS from two different notification streams -- with nothing ordering them against each other. Additive: nothing sends or handles it yet. --- go/protocol/keybase1/notify_app.go | 26 ++++++++++++++++++++++++++ protocol/avdl/keybase1/notify_app.avdl | 7 +++++++ protocol/bin/enabled-calls.json | 1 + protocol/json/keybase1/notify_app.json | 17 ++++++++++++++++- shared/constants/rpc/index.tsx | 1 + shared/constants/rpc/rpc-gen.tsx | 6 +++++- 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/go/protocol/keybase1/notify_app.go b/go/protocol/keybase1/notify_app.go index 19120a54d2d3..e3fae3e1d110 100644 --- a/go/protocol/keybase1/notify_app.go +++ b/go/protocol/keybase1/notify_app.go @@ -13,8 +13,13 @@ import ( type ExitArg struct { } +type MobileAppStateChangedArg struct { + State MobileAppState `codec:"state" json:"state"` +} + type NotifyAppInterface interface { Exit(context.Context) error + MobileAppStateChanged(context.Context, MobileAppState) error } func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { @@ -31,6 +36,21 @@ func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { return }, }, + "mobileAppStateChanged": { + MakeArg: func() any { + var ret [1]MobileAppStateChangedArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + typedArgs, ok := args.(*[1]MobileAppStateChangedArg) + if !ok { + err = rpc.NewTypeError((*[1]MobileAppStateChangedArg)(nil), args) + return + } + err = i.MobileAppStateChanged(ctx, typedArgs[0].State) + return + }, + }, }, } } @@ -43,3 +63,9 @@ func (c NotifyAppClient) Exit(ctx context.Context) (err error) { err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.exit", []any{ExitArg{}}, 0*time.Millisecond) return } + +func (c NotifyAppClient) MobileAppStateChanged(ctx context.Context, state MobileAppState) (err error) { + __arg := MobileAppStateChangedArg{State: state} + err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.mobileAppStateChanged", []any{__arg}, 0*time.Millisecond) + return +} diff --git a/protocol/avdl/keybase1/notify_app.avdl b/protocol/avdl/keybase1/notify_app.avdl index 31dfac72e695..ca9ea15b87b7 100644 --- a/protocol/avdl/keybase1/notify_app.avdl +++ b/protocol/avdl/keybase1/notify_app.avdl @@ -1,7 +1,14 @@ @namespace("keybase.1") protocol NotifyApp { + import idl "appstate.avdl"; void exit() oneway; + // The app's lifecycle state changed. The service derives it from the UI + // reports native makes, so this is the only place a client learns it -- + // deriving it a second time from the OS would mean two answers with no + // ordering between them. + void mobileAppStateChanged(MobileAppState state) oneway; + } diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index 35ebd092896e..658ceacc02e6 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -153,6 +153,7 @@ "chat.1.local.updateUnsentText": {"promise":true}, "chat.1.local.userEmojis": {"promise":true}, "keybase.1.NotifyApp.exit": {"custom":true}, + "keybase.1.NotifyApp.mobileAppStateChanged": {"incoming":true}, "keybase.1.NotifyAudit.boxAuditError": {"incoming":true}, "keybase.1.NotifyAudit.rootAuditError": {"incoming":true}, "keybase.1.NotifyBadges.badgeState": {"incoming":true}, diff --git a/protocol/json/keybase1/notify_app.json b/protocol/json/keybase1/notify_app.json index c1a6ab87bd48..bfe65971fc58 100644 --- a/protocol/json/keybase1/notify_app.json +++ b/protocol/json/keybase1/notify_app.json @@ -1,12 +1,27 @@ { "protocol": "NotifyApp", - "imports": [], + "imports": [ + { + "path": "appstate.avdl", + "type": "idl" + } + ], "types": [], "messages": { "exit": { "request": [], "response": null, "oneway": true + }, + "mobileAppStateChanged": { + "request": [ + { + "name": "state", + "type": "MobileAppState" + } + ], + "response": null, + "oneway": true } }, "namespace": "keybase.1" diff --git a/shared/constants/rpc/index.tsx b/shared/constants/rpc/index.tsx index 639df9e16838..14806b2581c8 100644 --- a/shared/constants/rpc/index.tsx +++ b/shared/constants/rpc/index.tsx @@ -71,6 +71,7 @@ type Chat1ResponseActionMap = { } type Keybase1IncomingAction = + 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index c1792d8d0bad..e49a4dc17ece 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -15,6 +15,10 @@ export type MessageTypes = { inParam: undefined, outParam: void, }, + 'keybase.1.NotifyApp.mobileAppStateChanged': { + inParam: {readonly state: MobileAppState}, + outParam: void, + }, 'keybase.1.NotifyAudit.boxAuditError': { inParam: {readonly message: string}, outParam: void, @@ -3125,7 +3129,7 @@ export type WalletAccountInfo = {readonly accountID: string,readonly numUnread: export type WebProof = {readonly hostname: string,readonly protocols?: ReadonlyArray | null,} export type WriteArgs = {readonly opID: OpID,readonly path: Path,readonly offset: number,} -type IncomingMethod = 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | 'keybase.1.NotifyDeviceHistory.deviceHistoryChanged' | 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | 'keybase.1.NotifyUsers.userChanged' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.pgpUi.finished' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.reachability.reachabilityChanged' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent' +type IncomingMethod = 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | 'keybase.1.NotifyDeviceHistory.deviceHistoryChanged' | 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | 'keybase.1.NotifyUsers.userChanged' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.pgpUi.finished' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.reachability.reachabilityChanged' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent' export type IncomingCallMapType = Partial<{[M in IncomingMethod]: (params: RpcIn) => void}> type CustomIncomingMethod = 'keybase.1.NotifyApp.exit' | 'keybase.1.NotifyEmailAddress.emailAddressVerified' | 'keybase.1.NotifyEmailAddress.emailsChanged' | 'keybase.1.NotifyFS.FSOverallSyncStatusChanged' | 'keybase.1.NotifyFS.FSSubscriptionNotify' | 'keybase.1.NotifyFS.FSSubscriptionNotifyPath' | 'keybase.1.NotifyFeaturedBots.featuredBotsUpdate' | 'keybase.1.NotifyPGP.pgpKeyInSecretStoreFile' | 'keybase.1.NotifyPhoneNumber.phoneNumbersChanged' | 'keybase.1.NotifyRuntimeStats.runtimeStatsUpdate' | 'keybase.1.NotifyService.HTTPSrvInfoUpdate' | 'keybase.1.NotifyService.handleKeybaseLink' | 'keybase.1.NotifyService.shutdown' | 'keybase.1.NotifySession.clientOutOfDate' | 'keybase.1.NotifySession.loggedIn' | 'keybase.1.NotifySimpleFS.simpleFSArchiveStatusChanged' | 'keybase.1.NotifyTeam.avatarUpdated' | 'keybase.1.NotifyTeam.teamChangedByID' | 'keybase.1.NotifyTeam.teamDeleted' | 'keybase.1.NotifyTeam.teamExit' | 'keybase.1.NotifyTeam.teamMetadataUpdate' | 'keybase.1.NotifyTeam.teamRoleMapChanged' | 'keybase.1.NotifyTeam.teamTreeMembershipsDone' | 'keybase.1.NotifyTeam.teamTreeMembershipsPartial' | 'keybase.1.NotifyTracking.notifyUserBlocked' | 'keybase.1.NotifyTracking.trackingInfo' | 'keybase.1.NotifyUsers.identifyUpdate' | 'keybase.1.NotifyUsers.passwordChanged' | 'keybase.1.gpgUi.selectKey' | 'keybase.1.gpgUi.wantToAddGPGKey' | 'keybase.1.gregorUI.pushState' | 'keybase.1.homeUI.homeUIRefresh' | 'keybase.1.identify3Ui.identify3Result' | 'keybase.1.identify3Ui.identify3ShowTracker' | 'keybase.1.identify3Ui.identify3Summary' | 'keybase.1.identify3Ui.identify3UpdateRow' | 'keybase.1.identify3Ui.identify3UpdateUserCard' | 'keybase.1.identify3Ui.identify3UserReset' | 'keybase.1.logUi.log' | 'keybase.1.loginUi.chooseDeviceToRecoverWith' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.loginUi.getEmailOrUsername' | 'keybase.1.loginUi.promptPassphraseRecovery' | 'keybase.1.loginUi.promptResetAccount' | 'keybase.1.loginUi.promptRevokePaperKeys' | 'keybase.1.logsend.prepareLogsend' | 'keybase.1.pgpUi.finished' | 'keybase.1.pgpUi.keyGenerated' | 'keybase.1.pgpUi.shouldPushPrivate' | 'keybase.1.proveUi.checking' | 'keybase.1.proveUi.continueChecking' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.okToCheck' | 'keybase.1.proveUi.outputInstructions' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.proveUi.preProofWarning' | 'keybase.1.proveUi.promptOverwrite' | 'keybase.1.proveUi.promptUsername' | 'keybase.1.provisionUi.DisplayAndPromptSecret' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.PromptNewDeviceName' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.provisionUi.chooseDevice' | 'keybase.1.provisionUi.chooseDeviceType' | 'keybase.1.provisionUi.chooseGPGMethod' | 'keybase.1.provisionUi.switchToGPGSignOK' | 'keybase.1.rekeyUI.delegateRekeyUI' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent' | 'keybase.1.secretUi.getPassphrase' | 'keybase.1.teamsUi.confirmInviteLinkAccept' | 'keybase.1.teamsUi.confirmRootTeamDelete' | 'keybase.1.teamsUi.confirmSubteamDelete' From 945124af0f1644d37e87db1d9d10eedce976d953 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:03:44 -0400 Subject: [PATCH 03/16] fix(chat): live location stops writing the app state A location fix arriving while the app was backgrounded used to push MobileAppState to BACKGROUNDACTIVE itself, so the update could get out. The app state is about to become something the service derives -- from the UI reports native makes and the holds background work opens -- and a writer that reaches around that derivation would just be overwritten by the next thing the controller applies. KNOWN TEMPORARY REGRESSION, accepted deliberately: tracking does not open a hold of its own yet, so a background location fix no longer keeps the app out of BACKGROUND. A long background track can go quiet once the background task that started when the UI left the screen has ended -- roughly fifteen seconds with an empty outbox. The hold that restores this is part of the live-location rework later in this stack; nothing else in this change series puts it back, so it must not be dropped. --- go/chat/maps/livelocation.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 54dd586b89bf..6b8f6fe24a38 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -373,15 +373,13 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co defer l.Trace(ctx, nil, "LocationUpdate")() l.Lock() defer l.Unlock() - if l.G().IsMobileAppType() { - // if the app is woken up as the result of a location update, and we think we are currently - // backgrounded, then go ahead and mark us as background active so that we can get - // location updates out - l.G().MobileAppState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - func(curState keybase1.MobileAppState) bool { - return curState == keybase1.MobileAppState_BACKGROUND - }) - } + // A fix that arrives while the app is backgrounded no longer keeps the app + // out of BACKGROUND: the service derives its state from the UI reports + // native makes and the holds background work opens, and nothing may write + // the state directly any more. Tracking does not yet open a hold of its + // own, so a long background track can go quiet once the background task + // that started when the UI left the screen has ended. Deliberate and + // temporary -- the hold arrives with the live-location rework. if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") return From ab522b2c976fadf3de81310795e55bb8e85f46dc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:03:58 -0400 Subject: [PATCH 04/16] feat(lifecycle): derive the app state from a lifecycle controller GlobalContext grows a lifecycle.Controller over MobileAppState, and the bind layer reports UI state to it instead of picking an app state per callback: SetAppStateForeground/Inactive -> AppUIActive / AppUIInactive AppDidEnterBackground + AppBeginBackgroundTask -> AppUIBackground / AppWaitBackgroundTask (new) -> AppBackgroundTaskExpired SetAppStateBackgroundActive -> gone; the controller decides AppUIBackground returns a token and returns at once; native waits on it with AppWaitBackgroundTask and ends its UIKit background task when that returns. BackgroundSync and AppWillExit now go through the controller too, so the window and the terminate path no longer juggle the state by hand. The delivery-polling loop that AppBeginBackgroundTask ran moves into the controller, where it is testable against a fake clock. MobileAppState.Update returns whether the value changed and is the only writer; UpdateWithCheck is gone, since the check it existed for is now the controller's single critical section. A change tells connected clients from that one place, so no writer can move the state without announcing it. That announce is not yet ordered per connection -- the router still sends on a goroutine per notification -- which the ordered client-state stream later in this stack fixes. Both mobile platforms now start in BACKGROUND. The OS starts the process without UI for pushes, notification actions and background refresh, and the first UI report moves it out; Android's old BACKGROUNDACTIVE default claimed work was running when none was. flushLocalDbs moves to (*GlobalContext) and becomes the controller's Flush hook, so it runs on every entry into the background rather than at the two call sites that remembered to ask. Also drops two things that were already dead: the appState.updateAppState handler, which no longer appears in the protocol, and chat's MobileAppState interface, which nothing implemented. AppPushWindowBegin/AppPushWindowEnd are transitional. They exist only because Android still opens the push window from Kotlin; they go away once the bind layer wraps push handling in the window itself. --- go/bind/keybase.go | 271 +++++++++++------------------------- go/chat/types/interfaces.go | 5 - go/libkb/appstate.go | 129 +++++++++++------ go/libkb/appstate_test.go | 78 +++++++++++ go/libkb/globals.go | 12 ++ go/libkb/notify_router.go | 27 ++++ go/service/appstate.go | 9 -- 7 files changed, 288 insertions(+), 243 deletions(-) create mode 100644 go/libkb/appstate_test.go diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 4aa2ea2827de..729cf6e0077d 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -23,7 +23,6 @@ import ( "github.com/keybase/client/go/chat/globals" "github.com/keybase/client/go/chat/types" "github.com/keybase/client/go/status" - "golang.org/x/sync/errgroup" "github.com/keybase/client/go/externals" "github.com/keybase/client/go/kbfs/env" @@ -32,6 +31,7 @@ import ( "github.com/keybase/client/go/kbfs/libkbfs" "github.com/keybase/client/go/kbfs/simplefs" "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle" "github.com/keybase/client/go/logger" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/keybase1" @@ -875,65 +875,23 @@ func FlushLogs() { logger.FlushLogFile() } -func SetAppStateForeground() { +// AppUIActive reports the app on screen and receiving events: iOS didBecomeActive, Android process resume. +func AppUIActive() { if !isInited() { return } - defer kbCtx.Trace("SetAppStateForeground", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + defer kbCtx.Trace("AppUIActive", nil)() + kbCtx.MobileLifecycle.UIActive() } -func SetAppStateBackground() { +// AppUIInactive reports the app on screen but not active: iOS willEnterForeground and +// willResignActive, Android process start. +func AppUIInactive() { if !isInited() { return } - defer kbCtx.Trace("SetAppStateBackground", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - flushLocalDbs() -} - -// flushLocalDbs flushes the leveldb memtables in the background. An unclean -// kill while suspended (routine on iOS) with a non-empty journal forces a -// journal replay — or a whole-DB recovery — during the next launch, which is -// the main cold-start cost. Called when the app heads to the background so -// the journals are empty if the OS kills the process. -func flushLocalDbs() { - if kbCtx == nil { - return - } - flush := func(name string, db *libkb.JSONLocalDb) { - if db == nil { - return - } - ldb, ok := db.GetEngine().(*libkb.LevelDb) - if !ok { - return - } - begin := time.Now() - if err := ldb.Flush(); err != nil { - log("Go: flushLocalDbs: %s flush error: %v", name, err) - return - } - log("Go: flushLocalDbs: %s flushed in %s", name, time.Since(begin)) - } - go flush("LocalDb", kbCtx.LocalDb) - go flush("LocalChatDb", kbCtx.LocalChatDb) -} - -func SetAppStateInactive() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateInactive", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) -} - -func SetAppStateBackgroundActive() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateBackgroundActive", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + defer kbCtx.Trace("AppUIInactive", nil)() + kbCtx.MobileLifecycle.UIInactive() } func waitForInit(maxDur time.Duration) error { @@ -962,39 +920,7 @@ func BackgroundSync() string { return fmt.Sprintf("waitForInit timeout: %v", err) } defer kbCtx.Trace("BackgroundSync", nil)() - - // Skip the sync if we aren't in the background - if state := kbCtx.MobileAppState.State(); state != keybase1.MobileAppState_BACKGROUND { - msg := fmt.Sprintf("skipping, app not in background state: %v", state) - kbCtx.Log.Debug("BackgroundSync: %s", msg) - return msg - } - - // Flip to BACKGROUNDACTIVE only if still BACKGROUND, so a foreground - // transition that lands after the check above isn't overwritten. If the - // check fails, NextUpdate below fires immediately and we bail out. - nextState := keybase1.MobileAppState_BACKGROUNDACTIVE - kbCtx.MobileAppState.UpdateWithCheck(nextState, func(s keybase1.MobileAppState) bool { - return s == keybase1.MobileAppState_BACKGROUND - }) - select { - case <-kbCtx.MobileAppState.NextUpdate(nextState): - // if literally anything happens, let's get out of here - state := kbCtx.MobileAppState.State() - msg := fmt.Sprintf("bailing out early, appstate change: %v", state) - kbCtx.Log.Debug("BackgroundSync: %s", msg) - return msg - case <-time.After(10 * time.Second): - // Drop back to BACKGROUND only if we still hold BACKGROUNDACTIVE; - // the app may have foregrounded between the timer firing and this - // update, and clobbering FOREGROUND would cancel live RPCs and - // strand the service in BACKGROUND while the user is in the app. - kbCtx.MobileAppState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUND, - func(s keybase1.MobileAppState) bool { - return s == keybase1.MobileAppState_BACKGROUNDACTIVE - }) - return "completed 10s window" - } + return kbCtx.MobileLifecycle.BackgroundSync() } // pushPendingMessageFailure sends at most one notification that a message @@ -1020,136 +946,107 @@ func AppWillExit(pusher PushNotifier) { return } defer kbCtx.Trace("AppWillExit", nil)() - ctx := context.Background() - obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx) + kbCtx.MobileLifecycle.WillTerminate(func() { notifyPendingMessageFailure(pusher) }) +} + +// AppBackgroundTaskExpired is called when the OS is about to suspend the app +// before the background task started by AppUIBackground finished. It +// ends every background task hold, and warns about messages still waiting to +// send if one was open. +func AppBackgroundTaskExpired(pusher PushNotifier) { + if !isInited() { + return + } + defer kbCtx.Trace("AppBackgroundTaskExpired", nil)() + kbCtx.MobileLifecycle.BackgroundTaskExpired(func() { notifyPendingMessageFailure(pusher) }) +} + +// notifyPendingMessageFailure warns the user that messages still waiting to +// send will get stuck, since we are about to be killed or suspended. +func notifyPendingMessageFailure(pusher PushNotifier) { + obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(context.Background()) if err == nil { - // We are about to get killed with messages still to send, let the user - // know they will get stuck pushPendingMessageFailure(obrs, pusher) } - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - flushLocalDbs() } -// AppDidEnterBackground notifies the service that the app is in the background -// [iOS] returning true will request about ~3mins from iOS to continue execution -func AppDidEnterBackground() bool { - if !isInited() { - return false - } - defer kbCtx.Trace("AppDidEnterBackground", nil)() +func shouldStayRunningInBackground() bool { ctx := context.Background() convs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx) if err != nil { - kbCtx.Log.Debug("AppDidEnterBackground: failed to get active deliveries: %s", err) + kbCtx.Log.Debug("shouldStayRunningInBackground: failed to get active deliveries: %s", err) convs = nil } - stayRunning := false switch { case len(convs) > 0: - kbCtx.Log.Debug("AppDidEnterBackground: active deliveries in progress") - stayRunning = true + kbCtx.Log.Debug("shouldStayRunningInBackground: active deliveries in progress") + return true case kbChatCtx.LiveLocationTracker.ActivelyTracking(ctx): - kbCtx.Log.Debug("AppDidEnterBackground: active live location in progress") - stayRunning = true + kbCtx.Log.Debug("shouldStayRunningInBackground: active live location in progress") + return true case kbChatCtx.CoinFlipManager.HasActiveGames(ctx): - kbCtx.Log.Debug("AppDidEnterBackground: active coin flip games in progress") - stayRunning = true - } - if stayRunning { - kbCtx.Log.Debug("AppDidEnterBackground: setting background active") - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - flushLocalDbs() + kbCtx.Log.Debug("shouldStayRunningInBackground: active coin flip games in progress") return true } - SetAppStateBackground() return false } -func AppBeginBackgroundTaskNonblock(pusher PushNotifier) { +// AppUIBackground reports the app off screen. It returns at once, with a +// token for AppWaitBackgroundTask, which returns once Go needs no more time +// in the background. It is 0 before Init, and when the UI was already in the +// background with no background task running. +func AppUIBackground(pusher PushNotifier) int64 { if !isInited() { - return + return 0 } - defer kbCtx.Trace("AppBeginBackgroundTaskNonblock", nil)() - go AppBeginBackgroundTask(pusher) + defer kbCtx.Trace("AppUIBackground", nil)() + return kbCtx.MobileLifecycle.UIBackground(backgroundTaskDeps(pusher)) } -// AppBeginBackgroundTask notifies us that an app background task has been started on our behalf. This -// function will return once we no longer need any time in the background. -func AppBeginBackgroundTask(pusher PushNotifier) { +// AppWaitBackgroundTask returns once the background task whose token +// AppUIBackground returned no longer needs any time in the background. +func AppWaitBackgroundTask(token int64) { if !isInited() { return } - defer kbCtx.Trace("AppBeginBackgroundTask", nil)() - ctx := context.Background() - // Poll active deliveries in case we can shutdown early - beginTime := libkb.ForceWallClock(time.Now()) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - appState := kbCtx.MobileAppState.State() - if appState != keybase1.MobileAppState_BACKGROUNDACTIVE { - kbCtx.Log.Debug("AppBeginBackgroundTask: not in background mode, early out") + defer kbCtx.Trace("AppWaitBackgroundTask", nil)() + kbCtx.MobileLifecycle.WaitBackgroundTask(token) +} + +// AppPushWindowBegin holds a backgrounded app up while native handles a push or +// a notification action. It returns the window's token, or 0 when the app is +// active and nothing needs holding. +// +// Transitional: it exists only because Android still opens the push window from +// Kotlin. It goes away once the bind layer wraps push handling in the window +// itself, which is where the decision belongs -- iOS needs no window at all, +// since it suspends the app at the push's completion handler. +func AppPushWindowBegin() int64 { + if !isInited() { + return 0 + } + defer kbCtx.Trace("AppPushWindowBegin", nil)() + return kbCtx.MobileLifecycle.PushWindowBegin() +} + +// AppPushWindowEnd ends the window AppPushWindowBegin opened. If the UI is +// still in the background it first hands over to a background task, which keeps +// the app up while work must keep going; pusher warns about messages that +// won't send. Transitional, for the same reason as AppPushWindowBegin. +func AppPushWindowEnd(token int64, pusher PushNotifier) { + if !isInited() { return } - var g *errgroup.Group - g, ctx = errgroup.WithContext(ctx) - g.Go(func() error { - select { - case <-kbCtx.MobileAppState.NextUpdate(appState): - appState = kbCtx.MobileAppState.State() - kbCtx.Log.Debug( - "AppBeginBackgroundTask: app state change, aborting with no task shutdown: %v", appState) - return errors.New("app state change") - case <-ctx.Done(): - return ctx.Err() - } - }) - g.Go(func() error { - ch, cancel := kbChatCtx.MessageDeliverer.NextFailure() - defer cancel() - select { - case obrs := <-ch: - kbCtx.Log.Debug( - "AppBeginBackgroundTask: failure received, alerting the user: %d marked", len(obrs)) - pushPendingMessageFailure(obrs, pusher) - return errors.New("failure received") - case <-ctx.Done(): - return ctx.Err() - } - }) - g.Go(func() error { - successCount := 0 - for { - select { - case <-ticker.C: - obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx) - if err != nil { - kbCtx.Log.Debug("AppBeginBackgroundTask: failed to query active deliveries: %s", err) - continue - } - if len(obrs) == 0 { - kbCtx.Log.Debug("AppBeginBackgroundTask: delivered everything: successCount: %d", - successCount) - // We can race the failure case here, so lets go a couple passes of no pending - // convs before we abort due to ths condition. - if successCount > 1 { - return errors.New("delivered everything") - } - successCount++ - } - curTime := libkb.ForceWallClock(time.Now()) - if curTime.Sub(beginTime) >= 10*time.Minute { - kbCtx.Log.Debug("AppBeginBackgroundTask: failed to deliver and time is up, aborting") - pushPendingMessageFailure(obrs, pusher) - return errors.New("time expired") - } - case <-ctx.Done(): - return ctx.Err() - } - } - }) - if err := g.Wait(); err != nil { - kbCtx.Log.Debug("AppBeginBackgroundTask: dropped out of wait because: %s", err) + defer kbCtx.Trace("AppPushWindowEnd", nil)() + kbCtx.MobileLifecycle.PushWindowEnd(token, backgroundTaskDeps(pusher)) +} + +func backgroundTaskDeps(pusher PushNotifier) lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + Stay: shouldStayRunningInBackground, + ActiveDeliveries: kbChatCtx.MessageDeliverer.ActiveDeliveries, + NextFailure: kbChatCtx.MessageDeliverer.NextFailure, + NotifyFailure: func(obrs []chat1.OutboxRecord) { pushPendingMessageFailure(obrs, pusher) }, } } diff --git a/go/chat/types/interfaces.go b/go/chat/types/interfaces.go index fed2ad4751e4..3fc7a7da8d6e 100644 --- a/go/chat/types/interfaces.go +++ b/go/chat/types/interfaces.go @@ -293,11 +293,6 @@ type PushHandler interface { OobmHandler } -type MobileAppState interface { - State() keybase1.MobileAppState - NextUpdate() chan keybase1.MobileAppState -} - type TeamChannelSource interface { GetLastActiveForTLF(context.Context, gregor1.UID, chat1.TLFID, chat1.TopicType) (gregor1.Time, error) GetLastActiveForTeams(context.Context, gregor1.UID, chat1.TopicType) (chat1.LastActiveTimeAll, error) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index c278bce2fd79..2d1a58831959 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -1,6 +1,7 @@ package libkb import ( + "context" "fmt" "runtime" "sync" @@ -38,18 +39,25 @@ type MobileAppState struct { } func NewMobileAppState(g *GlobalContext) *MobileAppState { - state := keybase1.MobileAppState_FOREGROUND - if runtime.GOOS == "android" { - // we need this so cold notifications work on android - state = keybase1.MobileAppState_BACKGROUNDACTIVE - } return &MobileAppState{ Contextified: NewContextified(g), - state: state, + state: initialMobileAppState(runtime.GOOS), changed: make(chan struct{}), } } +func initialMobileAppState(goos string) keybase1.MobileAppState { + switch goos { + case "android", "ios": + // The OS starts the process without UI for pushes, notification + // actions and background refresh; the first UI report, or a push + // window, moves it out of BACKGROUND. + return keybase1.MobileAppState_BACKGROUND + default: + return keybase1.MobileAppState_FOREGROUND + } +} + // NextUpdate returns a channel that will be closed the next time the app // state changes. If lastState does not match the current state, an // already-closed channel is returned so the caller wakes immediately and can @@ -68,50 +76,62 @@ func (a *MobileAppState) NextUpdate(lastState keybase1.MobileAppState) <-chan st return a.changed } -func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) { - if a.state != state { - a.G().Log.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v", - state, a.state) - a.G().PerfLog.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v", - state, a.state) - a.state = state - t := time.Now() - a.mtime = &t // only update mtime if we're changing state - close(a.changed) - a.changed = make(chan struct{}) - - // cancel RPCs if we go into the background - switch a.state { - case keybase1.MobileAppState_BACKGROUND: - a.G().RPCCanceler.CancelLiveContexts(RPCCancelerReasonBackground) - default: - // Nothing to do for other states. - } - } else { - a.G().Log.Debug("MobileAppState.Update: ignoring update: %v, we are currently in state: %v", - state, a.state) +func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bool) { + if a.state == state { + a.G().Log.Debug("MobileAppState.Update: same-value update: %v", state) + return false } -} + a.G().Log.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v", + state, a.state) + a.G().PerfLog.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v", + state, a.state) + a.state = state + t := time.Now() + a.mtime = &t // only update mtime if we're changing state + close(a.changed) + a.changed = make(chan struct{}) -func (a *MobileAppState) UpdateWithCheck(state keybase1.MobileAppState, - check func(keybase1.MobileAppState) bool, -) { - defer a.G().Trace(fmt.Sprintf("MobileAppState.UpdateWithCheck(%v)", state), nil)() - a.Lock() - defer a.Unlock() - if check(a.state) { - a.updateLocked(state) - } else { - a.G().Log.Debug("MobileAppState.UpdateWithCheck: skipping update, failed check") + // cancel RPCs if we go into the background + switch a.state { + case keybase1.MobileAppState_BACKGROUND: + a.G().RPCCanceler.CancelLiveContexts(RPCCancelerReasonBackground) + default: + // Nothing to do for other states. } + + // Tell connected clients from here, the one place the value changes, so no + // writer can add a path that moves the state without announcing it. Held + // under the lock on purpose: the announce is queued in the same critical + // section that wrote the state, and nothing it touches reads app state, so + // it cannot re-enter this lock. + // + // What this does not yet give a client is order. The router still hands + // each notification to its own goroutine, so two changes in quick + // succession can reach a client in either order, and the last one it + // applies may not be the latest. Update has a single writer -- + // lifecycle.Controller.applyLocked, under Controller.mu -- so the queueing + // order is already the writing order; what is missing is one ordered + // stream per connection to carry it. + a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) + return true } -// Update updates the current app state, and notifies any waiting calls from NextUpdate -func (a *MobileAppState) Update(state keybase1.MobileAppState) { +// Update sets the current app state and returns whether the value changed; +// only a change wakes NextUpdate callers and has side effects. +// +// Connected clients are told from here, the one place the value changes, which +// is also before lifecycle's Flush hook runs. On iOS that is as early as a +// client can be told, but it is not a guarantee of delivery before suspension: +// native keeps the app alive only until Go's background task has ended and its +// state is written (AppDelegate.swift ends its UIKit background task once +// AppWaitBackgroundTask returns), not until clients have received it. A client +// acting on the notification is racing the OS, and what it can lose is bounded +// by whatever it last wrote of its own accord. +func (a *MobileAppState) Update(state keybase1.MobileAppState) (changed bool) { defer a.G().Trace(fmt.Sprintf("MobileAppState.Update(%v)", state), nil)() a.Lock() defer a.Unlock() - a.updateLocked(state) + return a.updateLocked(state) } // State returns the current app state @@ -356,3 +376,28 @@ func (a *DesktopAppState) resetLocked() { a.suspendChanged = make(chan struct{}) } } + +// flushLocalDbs flushes the leveldb memtables in the background. An unclean +// kill while suspended (routine on iOS) with a non-empty journal forces a +// journal replay — or a whole-DB recovery — during the next launch, which is +// the main cold-start cost. Called when the app heads to the background so +// the journals are empty if the OS kills the process. +func (g *GlobalContext) flushLocalDbs() { + flush := func(name string, db *JSONLocalDb) { + if db == nil { + return + } + ldb, ok := db.GetEngine().(*LevelDb) + if !ok { + return + } + begin := time.Now() + if err := ldb.Flush(); err != nil { + g.Log.Info("flushLocalDbs: %s flush error: %v", name, err) + return + } + g.Log.Info("flushLocalDbs: %s flushed in %s", name, time.Since(begin)) + } + go flush("LocalDb", g.LocalDb) + go flush("LocalChatDb", g.LocalChatDb) +} diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go new file mode 100644 index 000000000000..a7b1251ae629 --- /dev/null +++ b/go/libkb/appstate_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package libkb + +import ( + "context" + "testing" + + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +func requireClosed(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + default: + require.Fail(t, "expected channel to be closed") + } +} + +func requireOpen(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + require.Fail(t, "expected channel to be open") + default: + } +} + +func TestMobileAppStateInitialState(t *testing.T) { + require.Equal(t, keybase1.MobileAppState_BACKGROUND, initialMobileAppState("ios")) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, initialMobileAppState("android")) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("darwin")) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("linux")) +} + +func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) { + tc := SetupTest(t, "MobileAppStateSideEffects", 0) + defer tc.Cleanup() + a := NewMobileAppState(tc.G) + + require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + _, mtime := a.StateAndMtime() + require.NotNil(t, mtime) + + next := a.NextUpdate(keybase1.MobileAppState_BACKGROUND) + require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + requireOpen(t, next) + _, mtime2 := a.StateAndMtime() + require.Same(t, mtime, mtime2) + + // A stale lastState wakes immediately. + requireClosed(t, a.NextUpdate(keybase1.MobileAppState_FOREGROUND)) + + require.True(t, a.Update(keybase1.MobileAppState_FOREGROUND)) + requireClosed(t, next) +} + +func TestMobileAppStateBackgroundCancelsRPCsOnlyOnChange(t *testing.T) { + tc := SetupTest(t, "MobileAppStateCancel", 0) + defer tc.Cleanup() + a := NewMobileAppState(tc.G) + + register := func() context.Context { + ctx, _ := tc.G.RPCCanceler.RegisterContext(context.Background(), RPCCancelerReasonBackground) + return ctx + } + + first := register() + require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + requireClosed(t, first.Done()) + + second := register() + require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + requireOpen(t, second.Done()) +} diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 68e8f6c9233a..48b622bbd4cc 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -27,6 +27,7 @@ import ( "sync" "time" + "github.com/keybase/client/go/libkb/lifecycle" logger "github.com/keybase/client/go/logger" keybase1 "github.com/keybase/client/go/protocol/keybase1" clockwork "github.com/keybase/clockwork" @@ -69,6 +70,7 @@ type GlobalContext struct { DNSNSFetcher DNSNameServerFetcher // The mobile apps potentially pass an implementor of this interface which is used to grab currently configured DNS name servers MobileNetState *MobileNetState // The kind of network connection for the currently running instance of the app MobileAppState *MobileAppState // The state of focus for the currently running instance of the app + MobileLifecycle *lifecycle.Controller // Derives MobileAppState from native UI reports and background-work holds DesktopAppState *DesktopAppState // The state of focus for the currently running instance of the app ChatHelper ChatHelper // conveniently send chat messages RPCCanceler *RPCCanceler // register live RPCs so they can be cancelleed en masse @@ -305,6 +307,10 @@ func (g *GlobalContext) Init() *GlobalContext { g.localSigchainGuard = NewLocalSigchainGuard(g) g.MobileNetState = NewMobileNetState(g) g.MobileAppState = NewMobileAppState(g) + g.MobileLifecycle = lifecycle.New(g.MobileAppState, lifecycle.Config{ + Flush: g.flushLocalDbs, + Debug: func(format string, args ...interface{}) { g.Log.Debug(format, args...) }, + }) g.DesktopAppState = NewDesktopAppState(g) g.RPCCanceler = NewRPCCanceler() g.IdentifyDispatch = NewIdentifyDispatch() @@ -836,6 +842,12 @@ func (g *GlobalContext) Shutdown(mctx MetaContext) error { g.hiddenTeamChainManager.Shutdown(mctx) } + // Ends the background tasks the controller runs before the chat + // services they poll go away. + if g.MobileLifecycle != nil { + g.MobileLifecycle.Close() + } + if g.NotifyRouter != nil { g.NotifyRouter.Shutdown() } diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index fcf789e563b2..8847a4f04457 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -2838,6 +2838,33 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase }) } +// HandleMobileAppState announces the app lifecycle state the service derived +// from native's UI reports. It is the client's only source for it: deriving it +// a second time from the OS would mean two answers -- on iOS from two different +// notification streams -- with nothing ordering them against each other. +// +// No runListeners, unlike the announces above it: there is no in-process +// listener for this. The in-process consumers (kbhttp/manager, kbfs) watch +// MobileAppState.NextUpdate directly, which is the earlier and cheaper signal. +// +// Called with MobileAppState's lock held, so nothing below may read app state. +func (n *NotifyRouter) HandleMobileAppState(ctx context.Context, state keybase1.MobileAppState) { + if n == nil { + return + } + ctx = CopyTagsToBackground(ctx) + n.cm.ApplyAll(func(id ConnectionID, xp rpc.Transporter) bool { + if n.getNotificationChannels(id).App { + go func() { + _ = (keybase1.NotifyAppClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).MobileAppStateChanged(ctx, state) + }() + } + return true + }) +} + func (n *NotifyRouter) HandleHandleKeybaseLink(ctx context.Context, link string, deferred bool) { if n == nil { return diff --git a/go/service/appstate.go b/go/service/appstate.go index c6e0e44b2b94..97d05a86a66c 100644 --- a/go/service/appstate.go +++ b/go/service/appstate.go @@ -5,7 +5,6 @@ package service import ( "context" - "fmt" "strings" "github.com/keybase/client/go/libkb" @@ -25,14 +24,6 @@ func newAppStateHandler(xp rpc.Transporter, g *libkb.GlobalContext) *appStateHan } } -func (a *appStateHandler) UpdateAppState(ctx context.Context, state keybase1.MobileAppState) (err error) { - a.G().Trace(fmt.Sprintf("UpdateAppState(%v)", state), &err)() - - // Update app state - a.G().MobileAppState.Update(state) - return nil -} - func (a *appStateHandler) UpdateMobileNetState(ctx context.Context, stateStr string) (err error) { a.G().Log.CDebugf(ctx, "UpdateMobileNetState(%v)", stateStr) From 7efb026d5a2709ec6f2b105dec23c26ade626b69 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:04:12 -0400 Subject: [PATCH 05/16] refactor(ios): report UI lifecycle events to Go instead of app states AppLifecycleForwarder hands each UIKit callback to Go as what it is -- the UI became active, went inactive, went off screen, is terminating -- and Go derives the app state. notifyAppState is gone with it: it read UIApplication.applicationState, which lags inside the scene-forwarded callbacks, so every caller had already grown a comment about why it could not be trusted there. The forwarder also owns the UIKit background task. Each background entry starts its own task and ends it once Go's background task has ended, and the expiration handler tells Go its background time is up rather than pretending the app is terminating. Background time is per app, so every task still open expires together, which is why Go ends all of its background tasks at once. The launch-time app-state report is gone: Go starts in BACKGROUND, and a foreground launch gets willEnterForeground and didBecomeActive from the scene. sceneDidDisconnect drops the window and the cover view, which didStartReactNative rebuilds if a new scene connects. --- shared/ios/Keybase/AppDelegate.swift | 142 ++++++++++++++----------- shared/ios/Keybase/SceneDelegate.swift | 10 +- 2 files changed, 87 insertions(+), 65 deletions(-) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 04648fc15bdd..2dd6f45c3b72 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -21,7 +21,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi var resignImageView: UIImageView? var fsPaths: [String: String] = [:] - var shutdownTask: UIBackgroundTaskIdentifier = .invalid + private let lifecycle = AppLifecycleForwarder() var iph: ItemProviderHelper? private var startupLogFileHandle: FileHandle? private let logQueue = DispatchQueue(label: "kb.startup.log", qos: .utility) @@ -37,12 +37,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi self.didLaunchSetupBefore() - // Tell Go the real app state right after init. Go defaults to foreground, - // so a background launch (silent push, background fetch) would otherwise - // look foregrounded until didLaunchSetupAfter runs — long enough to join - // a coin flip it can't finish. - self.notifyAppState(application) - if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { let notificationDict = Dictionary(uniqueKeysWithValues: remoteNotification.map { (String(describing: $0.key), $0.value) }) KbSetInitialNotification(notificationDict) @@ -74,7 +68,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // Start FPS monitoring if launched with -PERF_FPS_MONITOR PerfFPSMonitor.startIfEnabled() - self.didLaunchSetupAfter(application: application) + self.didLaunchSetupAfter() return true } @@ -202,17 +196,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi self.writeStartupTimingLog("After Go init") } - func notifyAppState(_ application: UIApplication) { - let state = application.applicationState - log.info("notifyAppState: notifying service with new appState: \(state.rawValue)") - switch state { - case .active: Keybasego.KeybaseSetAppStateForeground() - case .background: Keybasego.KeybaseSetAppStateBackground() - case .inactive: Keybasego.KeybaseSetAppStateInactive() - default: Keybasego.KeybaseSetAppStateForeground() - } - } - func didLaunchSetupBefore() { setupGo() try? AVAudioSession.sharedInstance().setCategory(.ambient) @@ -221,9 +204,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // BGTaskScheduler.register must run before didFinishLaunching returns, so this // can't wait for the scene to connect. - func didLaunchSetupAfter(application: UIApplication) { - notifyAppState(application) - + func didLaunchSetupAfter() { BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in self.handleAppRefresh(task: task as! BGAppRefreshTask) } @@ -244,6 +225,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi dim = screenBounds.height } let square = CGRect(origin: screenBounds.origin, size: CGSize(width: dim, height: dim)) + self.resignImageView?.removeFromSuperview() self.resignImageView = UIImageView(frame: square) self.resignImageView?.contentMode = .center self.resignImageView?.alpha = 0 @@ -252,6 +234,14 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi if let view = self.resignImageView { window.addSubview(view) } } + // Called by SceneDelegate when the scene goes away; didStartReactNative + // rebuilds both if a new scene connects. + func didDisconnectScene() { + self.window = nil + self.resignImageView?.removeFromSuperview() + self.resignImageView = nil + } + func addDrop(_ rootView: UIView) { let dropInteraction = UIDropInteraction(delegate: self) dropInteraction.allowsSimultaneousDropSessions = true @@ -384,7 +374,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi override func applicationWillTerminate(_ application: UIApplication) { self.window?.rootViewController?.view.isHidden = true - Keybasego.KeybaseAppWillExit(PushNotifier()) + lifecycle.willTerminate() } func hideCover() { @@ -403,7 +393,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } completion: { finished in log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") } - Keybasego.KeybaseSetAppStateInactive() + lifecycle.uiInactive() } override func applicationDidEnterBackground(_ application: UIApplication) { @@ -414,43 +404,13 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationDidEnterBackground: setting keyz screen alpha to 1.") self.resignImageView?.alpha = 1 - log.info("applicationDidEnterBackground: notifying go.") - let requestTime = Keybasego.KeybaseAppDidEnterBackground() - log.info("applicationDidEnterBackground: after notifying go.") - - if requestTime && (self.shutdownTask == UIBackgroundTaskIdentifier.invalid) { - self.shutdownTask = UIApplication.shared.beginBackgroundTask { - // Expiration handler runs on the main thread. - log.info("applicationDidEnterBackground: shutdown task run.") - Keybasego.KeybaseAppWillExit(PushNotifier()) - self.endShutdownTask() - } - - DispatchQueue.global(qos: .default).async { - Keybasego.KeybaseAppBeginBackgroundTask(PushNotifier()) - DispatchQueue.main.async { - self.endShutdownTask() - } - } - } - } - - // Main thread only: serializes the expiration handler and the background - // work both trying to end the same task. - private func endShutdownTask() { - let task = self.shutdownTask - guard task != .invalid else { return } - self.shutdownTask = .invalid - UIApplication.shared.endBackgroundTask(task) + lifecycle.didEnterBackground(application) } override func applicationDidBecomeActive(_ application: UIApplication) { log.info("applicationDidBecomeActive: hiding keyz screen.") hideCover() - log.info("applicationDidBecomeActive: notifying service.") - // Forwarded from sceneDidBecomeActive, where applicationState still reads - // .inactive; notifyAppState would stop the http server. - Keybasego.KeybaseSetAppStateForeground() + lifecycle.didBecomeActive() // Re-emit a notification the user tapped while React Native wasn't ready yet. KbEmitStoredNotificationOnBecomeActive() @@ -460,12 +420,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationWillEnterForeground: hiding keyz screen.") PerfFPSMonitor.appWillEnterForeground() hideCover() - // HTTP and gregor should come up before React Native resumes painting (image - // loads race a stopped http server). BACKGROUNDACTIVE starts those without - // claiming the user is on-screen — FOREGROUND waits for didBecomeActive. - // Can't use notifyAppState here: applicationState is still .background. - Keybasego.KeybaseSetAppStateBackgroundActive() - NSLog("applicationWillEnterForeground: done") + lifecycle.uiInactive() } func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { @@ -474,6 +429,69 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } +// Hands lifecycle events to Go on the main thread, in callback order: every Go +// lifecycle call returns at once, except the exit work, which runBounded caps. +// Also owns the UIKit background tasks that keep the app alive while Go does +// its background work. Main thread only. +// +// Native reports only UI state; Go derives the app state (go/libkb/lifecycle). +// Nothing here may derive state, and UIApplication.applicationState lags inside +// the scene-forwarded callbacks anyway. +final class AppLifecycleForwarder { + // Upper bound on how long the expiration handler and willTerminate hold the + // main thread for Go's last work (flush, a pending-message warning). + private static let exitWorkTimeout: TimeInterval = 1 + + // willEnterForeground and willResignActive. + func uiInactive() { Keybasego.KeybaseAppUIInactive() } + func didBecomeActive() { Keybasego.KeybaseAppUIActive() } + + func willTerminate() { + runBounded { Keybasego.KeybaseAppWillExit(PushNotifier()) } + } + + // Every background entry starts its own task, which lasts until Go's + // background task has ended. Background time is per app, so every task still + // open expires together, and Go ends all of its background tasks at once. + func didEnterBackground(_ application: UIApplication) { + // The task's id, or .invalid once it has ended. + var task = UIBackgroundTaskIdentifier.invalid + func end() { + guard task != .invalid else { return } + application.endBackgroundTask(task) + task = .invalid + } + task = application.beginBackgroundTask(withName: "kb.didEnterBackground") { + guard task != .invalid else { return } + log.info("background task expired") + self.runBounded { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } + end() + } + // 0 while Go isn't running (before Init, after shutdown), and when the UI + // was already in the background with no Go background task running. + let token = Keybasego.KeybaseAppUIBackground(PushNotifier()) + guard token > 0 else { + end() + return + } + DispatchQueue.global(qos: .default).async { + Keybasego.KeybaseAppWaitBackgroundTask(token) + DispatchQueue.main.async { end() } + } + } + + // Every earlier event has already reached Go, so this keeps the order; the + // wait only bounds how long the app stays alive for the work. + private func runBounded(_ work: @escaping () -> Void) { + let done = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + work() + done.signal() + } + _ = done.wait(timeout: .now() + Self.exitWorkTimeout) + } +} + class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { // Extension point for config-plugins diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift index 17be345c2006..59b34b40f2f1 100644 --- a/shared/ios/Keybase/SceneDelegate.swift +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -10,9 +10,13 @@ class SceneDelegate: ExpoAppSceneDelegate { ) { super.scene(scene, willConnectTo: session, options: connectionOptions) - guard let window = self.window, - let appDelegate = UIApplication.shared.delegate as? AppDelegate - else { return } + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } + guard let window = self.window else { return } appDelegate.didStartReactNative(in: window) } + + override func sceneDidDisconnect(_ scene: UIScene) { + super.sceneDidDisconnect(scene) + (UIApplication.shared.delegate as? AppDelegate)?.didDisconnectScene() + } } From b66a547548d7b59ec0be1d8023d8a750536e58a7 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:04:12 -0400 Subject: [PATCH 06/16] refactor(android): report UI lifecycle events to Go instead of app states onStart and onResume report an inactive and then an active UI; the background report moves from onPause to onStop, where the UI has actually left the screen, so a dialog, permission prompt or picker no longer takes the service to the background and back. onDestroy still reports the exit. WithBackgroundActive opens a push window around its task instead of setting BACKGROUNDACTIVE and then guessing its way back out. Ending it in a finally also fixes a task that threw leaving the app stuck BACKGROUNDACTIVE. The push window is opened from here, and from Kotlin, only for now: it belongs in the bind layer, which is the only place that knows iOS needs no window at all. Keybase.appPushWindowBegin/End go away with it. --- .../KeybasePushNotificationListenerService.kt | 41 ++++++++----------- .../java/io/keybase/ossifrage/MainActivity.kt | 19 +++++---- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt index 396b0aabe2e9..6bdce186b21d 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt @@ -395,14 +395,18 @@ internal class NotificationData(type: String, bundle: Bundle) { } } -// Interface to run some task while in backgroundActive. -// If already foreground, ignore +// Runs some task inside a push window, which keeps a backgrounded app running +// while the task does. If already foreground, ignore. +// +// Transitional: it exists only because Android still opens the push window from +// here. It goes away, with Keybase.appPushWindow*, once the bind layer wraps +// push handling in the window itself. internal interface WithBackgroundActive { @Throws(Exception::class) fun task() @Throws(Exception::class) - fun whileActive(context: Context?) { + fun whileActive(context: Context) { try { // We are foreground don't show anything val isForeground = Keybase.isAppStateForeground() @@ -410,29 +414,18 @@ internal interface WithBackgroundActive { if (isForeground) { NativeLogger.info("WithBackgroundActive.whileActive app is foreground, returning early") return - } else { - NativeLogger.info("WithBackgroundActive.whileActive setting background active and calling task") - Keybase.setAppStateBackgroundActive() + } + // 0 when the app is active and nothing needs holding up. + val token = Keybase.appPushWindowBegin() + NativeLogger.info("WithBackgroundActive.whileActive push window $token, calling task") + try { task() NativeLogger.info("WithBackgroundActive.whileActive task completed") - - // Check if we are foreground now for some reason. In that case we don't want to go background again - val isForegroundNow = Keybase.isAppStateForeground() - NativeLogger.info("WithBackgroundActive.whileActive isForegroundNow: $isForegroundNow") - if (isForegroundNow) { - NativeLogger.info("WithBackgroundActive.whileActive app became foreground, returning") - return - } - val didEnterBackground = Keybase.appDidEnterBackground() - NativeLogger.info("WithBackgroundActive.whileActive didEnterBackground: $didEnterBackground") - if (didEnterBackground) { - if (context != null) { - NativeLogger.info("WithBackgroundActive.whileActive beginning background task") - Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(context, Bundle())) - } - } else { - NativeLogger.info("WithBackgroundActive.whileActive setting app state to background") - Keybase.setAppStateBackground() + } finally { + if (token > 0) { + // Hands over to a background task if the UI is still in the + // background and work must keep going. + Keybase.appPushWindowEnd(token, KBPushNotifier(context, Bundle())) } } } catch (ex: Exception) { diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index f4369a931039..c93a7982e1e0 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -93,11 +93,6 @@ class MainActivity : ReactActivity() { override fun onPause() { NativeLogger.info("Activity onPause") super.onPause() - if (Keybase.appDidEnterBackground()) { - Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(this, Bundle())) - } else { - Keybase.setAppStateBackground() - } } private fun getFileNameFromResolver(resolver: ContentResolver, uri: Uri, extension: String?): String { @@ -154,17 +149,27 @@ class MainActivity : ReactActivity() { return filePath } + // Native reports only what the UI is doing; Go derives the app state from + // these reports and the holds background work opens (go/libkb/lifecycle). override fun onResume() { NativeLogger.info("Activity onResume") super.onResume() - Keybase.setAppStateForeground() + Keybase.appUIActive() handleIntent() } override fun onStart() { NativeLogger.info("Activity onStart") super.onStart() - Keybase.setAppStateForeground() + Keybase.appUIInactive() + } + + override fun onStop() { + NativeLogger.info("Activity onStop") + super.onStop() + // The token is for iOS, which has to wait out Go's background task + // before the OS suspends it; Android keeps the process running. + Keybase.appUIBackground(KBPushNotifier(this, Bundle())) } override fun onDestroy() { From ac3debc84e286f2fd9c865ee6752f78420ea5242 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:04:20 -0400 Subject: [PATCH 07/16] feat(js): take the mobile app state from the service mobileAppStateChanged is now the store's only source for it. JS stops reading React Native's AppState: with the service deriving the state from what native reports, a second derivation in JS would be a second answer, arriving on its own schedule, with nothing ordering the two against each other. Go's two background states collapse to one here -- nothing in the UI distinguishes "backgrounded with work still running" from "backgrounded" -- and an unmapped state says so rather than leaving the store stuck on the one before it. Desktop ignores the value: its constant FOREGROUND describes nothing, and window focus is a separate fact. The two subscribers watching mobileAppState become one, since they now run off the same source. Known gap while the rest of this stack lands: the service only sends this on a change, so a client that connects after the launch transitions keeps mobileAppState 'unknown' until the next one. appFocused defaults to true, so a foreground launch still reads correctly; what a client can miss is the first contact-permission reload and one system-theme re-read. The client-state snapshot sent on subscribing closes this. --- shared/app/index.native.tsx | 22 +++++----- shared/constants/init/app-state.test.ts | 57 +++++++++++++++++++++++++ shared/constants/init/index.tsx | 34 +++++---------- shared/constants/init/shared.tsx | 33 ++++++++++++++ 4 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 shared/constants/init/app-state.test.ts diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index dfc15a3d789a..a1bd3ad7614d 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import Main from './main' import {KeyboardProvider} from 'react-native-keyboard-controller' import {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated' -import {AppRegistry, AppState, Appearance, Platform} from 'react-native' +import {AppRegistry, Appearance, Platform} from 'react-native' import {PortalProvider} from '@/common-adapters/portal.native' import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context' import {makeEngine} from '../engine' @@ -57,18 +57,18 @@ const initDarkMode = () => { } const useDarkHookup = () => { + // The store starts at 'unknown' and only the service can move it off that, which is later than + // this mounts, so assume on screen until told otherwise rather than dropping an early theme + // change. Being wrong costs at most one system theme change applied off screen -- which is what + // the gate exists to avoid, and which the next 'active' re-reads anyway. const appStateRef = React.useRef('active') const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode) - const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState) React.useEffect(() => { - const appStateChangeSub = AppState.addEventListener('change', nextAppState => { - appStateRef.current = nextAppState - if (nextAppState !== 'unknown' && nextAppState !== 'extension') { - setMobileAppState(nextAppState) - } - - if (nextAppState === 'active') { + const stopWatchingAppState = useShellState.subscribe((s, old) => { + if (s.mobileAppState === old.mobileAppState) return + appStateRef.current = s.mobileAppState + if (s.mobileAppState === 'active') { setSystemDarkMode(Appearance.getColorScheme() === 'dark') } }) @@ -81,10 +81,10 @@ const useDarkHookup = () => { }) return () => { - appStateChangeSub.remove() + stopWatchingAppState() darkSub.remove() } - }, [setSystemDarkMode, setMobileAppState]) + }, [setSystemDarkMode]) } const StoreHelper = (p: {children: React.ReactNode}): React.ReactNode => { diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts new file mode 100644 index 000000000000..c8b7f373e485 --- /dev/null +++ b/shared/constants/init/app-state.test.ts @@ -0,0 +1,57 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useShellState} from '@/stores/shell' +import {applyMobileAppState, _onEngineIncoming} from './shared' + +const g = globalThis as unknown as {isMobile: boolean} + +beforeEach(() => { + g.isMobile = true + resetAllStores() + // the shell store keeps its state across an account-level reset on purpose + useShellState.setState({mobileAppState: 'unknown'}) +}) + +afterEach(() => { + g.isMobile = false +}) + +describe('the app state the service derives', () => { + test.each([ + [T.RPCGen.MobileAppState.foreground, 'active'], + [T.RPCGen.MobileAppState.inactive, 'inactive'], + [T.RPCGen.MobileAppState.background, 'background'], + // nothing in the UI distinguishes "backgrounded with work still running" from "backgrounded" + [T.RPCGen.MobileAppState.backgroundactive, 'background'], + ])('%s becomes %s', (state, expected) => { + applyMobileAppState(state) + expect(useShellState.getState().mobileAppState).toBe(expected) + }) + + test('arrives through the notification', () => { + _onEngineIncoming({ + payload: {params: {state: T.RPCGen.MobileAppState.background}}, + type: 'keybase.1.NotifyApp.mobileAppStateChanged', + } as never) + expect(useShellState.getState().mobileAppState).toBe('background') + }) + + test('is applied in arrival order: the service sends it on one ordered stream', () => { + applyMobileAppState(T.RPCGen.MobileAppState.background) + applyMobileAppState(T.RPCGen.MobileAppState.foreground) + expect(useShellState.getState().mobileAppState).toBe('active') + }) + + test('a state we do not map leaves the app state alone rather than guessing', () => { + applyMobileAppState(T.RPCGen.MobileAppState.background) + applyMobileAppState(99 as T.RPCGen.MobileAppState) + expect(useShellState.getState().mobileAppState).toBe('background') + }) + + test('desktop has no lifecycle to learn, so its constant FOREGROUND is ignored', () => { + g.isMobile = false + applyMobileAppState(T.RPCGen.MobileAppState.foreground) + expect(useShellState.getState().mobileAppState).toBe('unknown') + }) +}) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 88b76432ee8e..985cf814046c 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -342,25 +342,19 @@ export const initPlatformListener = () => { const _initNativePlatformListener = () => { useShellState.subscribe((s, old) => { if (s.mobileAppState === old.mobileAppState) return - let appFocused: boolean - switch (s.mobileAppState) { - case 'active': - appFocused = true - break - case 'background': - appFocused = false - persistRoute(false, true, () => useConfigState.getState().startup.loaded) - break - case 'inactive': - appFocused = false - break - default: - appFocused = false + if (s.mobileAppState === 'background') { + persistRoute(false, true, () => useConfigState.getState().startup.loaded) } - // Native KeybaseSetAppState* is the only writer of Go MobileAppState. + // mobileAppState is the service's derived state, applied in constants/init/shared.tsx; + // nothing in JS derives it, so this only translates it into focus. logger.info(`app focus changed: ${s.mobileAppState}`) - s.dispatch.changedFocus(appFocused) + s.dispatch.changedFocus(s.mobileAppState === 'active') + + if (s.mobileAppState === 'active') { + // only reload on foreground + useSettingsContactsState.getState().dispatch.loadContactPermissions() + } }) const configureAndroidCacheDir = () => { @@ -412,14 +406,6 @@ const _initNativePlatformListener = () => { ignorePromise(f()) }) - useShellState.subscribe((s, old) => { - if (s.mobileAppState === old.mobileAppState) return - if (s.mobileAppState === 'active') { - // only reload on foreground - useSettingsContactsState.getState().dispatch.loadContactPermissions() - } - }) - if (isAndroid) { useDarkModeState.subscribe((s, old) => { if (s.darkModePreference === old.darkModePreference) return diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index c0266f11ad80..f754f7421dfc 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -237,6 +237,36 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => } } +// The service derives the app's lifecycle state from the UI reports native makes and is the only +// party that derives it; this is the whole of JS's model of it. Go's two background states are one +// state here: nothing in the UI distinguishes "backgrounded with work still running" from +// "backgrounded". +// +// Applied only on mobile. Desktop has no lifecycle to report, so the service's value there is a +// constant FOREGROUND that describes nothing -- desktop's window focus is a separate fact, written +// straight to `appFocused` by the window listeners. +export const applyMobileAppState = (state: T.RPCGen.MobileAppState) => { + if (!isMobile) { + return + } + switch (state) { + case T.RPCGen.MobileAppState.foreground: + useShellState.getState().dispatch.setMobileAppState('active') + break + case T.RPCGen.MobileAppState.inactive: + useShellState.getState().dispatch.setMobileAppState('inactive') + break + case T.RPCGen.MobileAppState.background: + case T.RPCGen.MobileAppState.backgroundactive: + useShellState.getState().dispatch.setMobileAppState('background') + break + default: + // a fifth state the service grew and we have not mapped: say so rather than leaving the store + // silently stuck on the one before it + logger.warn(`[AppState] unmapped state ${String(state)}, leaving the app state as it was`) + } +} + const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { const next = nextNavState as Util.NavState const prev = previousNavState as Util.NavState @@ -357,6 +387,9 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { } switch (action.type) { + case 'keybase.1.NotifyApp.mobileAppStateChanged': + applyMobileAppState(action.payload.params.state) + break case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params From 4acd79402b94d8cd238a6adc1f8ac18b47260984 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 11:46:01 -0400 Subject: [PATCH 08/16] fix(kbhttp): keep the http server up while the UI is inactive The app now reports INACTIVE where it used to report BACKGROUNDACTIVE: Control Center, system alerts and the app switcher, and -- since Expo forwards sceneWillEnterForeground into applicationWillEnterForeground -- every cold launch and every return from the background. In all of those the UI is on screen or about to be, and React Native is painting. Stopping the server there strands image loads against a stopped server, which is exactly what the willEnterForeground report used to exist to avoid. Only BACKGROUND takes it down now. --- go/kbhttp/manager/manager.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 50b14ae21425..a5db6f19e570 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -119,9 +119,12 @@ func (r *Srv) monitorAppState() { <-r.G().MobileAppState.NextUpdate(state) state = r.G().MobileAppState.State() switch state { - case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE: + // INACTIVE means the UI is on screen without receiving events, so the + // server has to stay up; only BACKGROUND takes it down. + case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_INACTIVE: r.startHTTPSrv() - case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_INACTIVE: + case keybase1.MobileAppState_BACKGROUND: r.httpSrv.Stop() } } From 82b45d44fc10e9131c86a2f045116629d93966a2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 16:32:53 -0400 Subject: [PATCH 09/16] fix(gregor): stay connected while the UI is inactive iOS used to report BACKGROUNDACTIVE from willEnterForeground so HTTP and gregor came up before React Native resumed painting. It now reports a UI inactive event, which the lifecycle controller turns into INACTIVE, and gregor disconnected on INACTIVE: it waited for didBecomeActive to connect, and when a background hold had it connected, coming back to the foreground tore the connection down only to reconnect moments later. Connect on INACTIVE as on BACKGROUNDACTIVE and disconnect only on BACKGROUND. Connect is a no-op on a live connection. --- go/service/gregor.go | 25 ++++++++++++++++++------- go/service/gregor_test.go | 11 +++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 5fe2ba3edbc9..bc6ec1c572fd 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -269,6 +269,22 @@ const ( monitorNoop ) +// mobileMonitorAction keeps gregor connected in every state but BACKGROUND. +// INACTIVE is also the scene coming to the foreground before it is active, so +// connecting there has gregor up before React Native resumes painting, and +// leaves a connection a background hold kept open in place. +func mobileMonitorAction(state keybase1.MobileAppState) int { + switch state { + case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_INACTIVE: + return monitorConnect + case keybase1.MobileAppState_BACKGROUND: + return monitorDisconnect + default: + return monitorNoop + } +} + func (g *gregorHandler) monitorAppState() { ctx := libkb.WithLogTag(context.Background(), "GRGRMON") // Wait for state updates and react accordingly @@ -279,15 +295,10 @@ func (g *gregorHandler) monitorAppState() { select { case <-g.G().MobileAppState.NextUpdate(state): state = g.G().MobileAppState.State() - switch state { - case keybase1.MobileAppState_FOREGROUND: + if state == keybase1.MobileAppState_FOREGROUND { g.forcePing(ctx) - monitorAction = monitorConnect - case keybase1.MobileAppState_BACKGROUNDACTIVE: - monitorAction = monitorConnect - case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_INACTIVE: - monitorAction = monitorDisconnect } + monitorAction = mobileMonitorAction(state) case <-g.G().DesktopAppState.NextSuspendUpdate(suspended): suspended = g.G().DesktopAppState.Suspended() if !suspended { diff --git a/go/service/gregor_test.go b/go/service/gregor_test.go index f90d63e17956..c2452f3bedf5 100644 --- a/go/service/gregor_test.go +++ b/go/service/gregor_test.go @@ -1139,3 +1139,14 @@ func badgerResync(ctx context.Context, t testing.TB, b *badges.Badger, chatRemot b.PushChatFullUpdate(ctx, update) b.PushState(ctx, state) } + +func TestMobileMonitorAction(t *testing.T) { + for state, want := range map[keybase1.MobileAppState]int{ + keybase1.MobileAppState_FOREGROUND: monitorConnect, + keybase1.MobileAppState_INACTIVE: monitorConnect, + keybase1.MobileAppState_BACKGROUNDACTIVE: monitorConnect, + keybase1.MobileAppState_BACKGROUND: monitorDisconnect, + } { + require.Equal(t, want, mobileMonitorAction(state), "state %v", state) + } +} From ab82b819ec9290402ee7f89eb5a07fa63bdf05d1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 16:34:44 -0400 Subject: [PATCH 10/16] fix(lifecycle): hold a background task while work must keep running A background task asked Stay once and then polled only the outbox, so three empty polls ended the hold even when Stay had kept it for an active coin flip or live location. Android used to stay BACKGROUNDACTIVE after the task, so a flip survived being backgrounded; now it lost the app state 15 seconds in. Poll Stay instead, and count the polls where nothing must keep running. The maximum duration and iOS expiration still bound the hold; the outbox is read only for the failure notice when time runs out. --- go/libkb/lifecycle/controller_test.go | 31 +++++++++++----- go/libkb/lifecycle/lifecycle.go | 39 ++++++++++----------- go/libkb/lifecycle/lifecycletest/harness.go | 20 ++++++----- 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index a4d6516f9034..1453b0c3f6a8 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -214,25 +214,38 @@ func TestBackgroundTaskTimesOutWhileDeliveriesFail(t *testing.T) { require.Equal(t, background, appState.State()) } -// Deliveries that reappear start the count of empty polls over. -func TestBackgroundTaskNeedsEmptyPollsInARow(t *testing.T) { - outbox := [][]chat1.OutboxRecord{nil, nil, make([]chat1.OutboxRecord, 1), nil, nil, nil} - var polls atomic.Int32 +// Work that reappears starts the count of idle polls over. +func TestBackgroundTaskNeedsIdlePollsInARow(t *testing.T) { + // The first answer is the task's check before it polls. + stay := []bool{true, false, false, true, false, false, false} + var asked atomic.Int32 var notified atomic.Int32 deps := noDeliveries(true) - deps.ActiveDeliveries = func(context.Context) ([]chat1.OutboxRecord, error) { - if i := int(polls.Add(1)) - 1; i < len(outbox) { - return outbox[i], nil + deps.Stay = func() bool { + if i := int(asked.Add(1)) - 1; i < len(stay) { + return stay[i] } - return nil, nil + return false } deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) } appState, clock, done := startPolledTask(t, lifecycle.DefaultBackgroundTaskMaxDuration, deps) - require.Equal(t, len(outbox), advancePolls(t, clock, done, 10), "the task ended with a message still sending") + require.Equal(t, len(stay)-1, advancePolls(t, clock, done, 10), "the task ended with work still running") require.Zero(t, notified.Load()) require.Equal(t, background, appState.State()) } +// Work other than a delivery, such as a coin flip or live location, keeps the +// task holding with an empty outbox until its maximum duration. +func TestBackgroundTaskHoldsWhileStayWithNothingToDeliver(t *testing.T) { + var notified atomic.Int32 + deps := noDeliveries(true) + deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) } + appState, clock, done := startPolledTask(t, 6*pollInterval, deps) + require.Equal(t, 6, advancePolls(t, clock, done, 10), "the task ended while work had to keep running") + require.EqualValues(t, 1, notified.Load()) + require.Equal(t, background, appState.State()) +} + // Native gives these last events only a short wait, so the state change and // the flush must happen before the slow pending-message warning. func TestExitEventsApplyBeforeNotifying(t *testing.T) { diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 4cf7c1d66055..8faf65efe77f 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -71,9 +71,11 @@ type Config struct { type BackgroundTaskDeps struct { // Stay reports whether any work must keep a backgrounded app running. A - // task asks it first, off the controller's lock: answering reads the - // outbox. - Stay func() bool + // task asks it at the start and on every poll, off the controller's lock: + // answering reads the outbox. + Stay func() bool + // ActiveDeliveries names the messages still sending when the task runs out + // of time, for the failure notice. ActiveDeliveries func(context.Context) ([]chat1.OutboxRecord, error) NextFailure func() (chan []chat1.OutboxRecord, func()) NotifyFailure func([]chat1.OutboxRecord) @@ -432,7 +434,7 @@ func (c *Controller) BackgroundSync() string { } // runBackgroundTask keeps the background task hold h while work must keep -// going: until outgoing messages are delivered, one fails, time runs out, the +// going: until Stay says nothing does, a message fails, time runs out, the // hold is ended (the UI left the background, expiration, termination) or the // controller is closed. func (c *Controller) runBackgroundTask(h *Hold, deps BackgroundTaskDeps) { @@ -467,31 +469,28 @@ func (c *Controller) runBackgroundTask(h *Hold, deps BackgroundTaskDeps) { } }) g.Go(func() error { - // An empty outbox can race a failure, so it takes three empty polls in - // a row to count as delivered. - emptyPolls := 0 - var pending []chat1.OutboxRecord + // An empty outbox can race a failure, so it takes three polls in a row + // with nothing to keep running to end the task. + idlePolls := 0 for { select { case <-clock.After(c.cfg.BackgroundTaskPollInterval): case <-ctx.Done(): return ctx.Err() } - obrs, err := deps.ActiveDeliveries(ctx) - switch { - case err != nil: - c.cfg.Debug("lifecycle: failed to query active deliveries: %s", err) - case len(obrs) == 0: - pending = nil - emptyPolls++ - if emptyPolls > 2 { - return errors.New("delivered everything") + if deps.Stay() { + idlePolls = 0 + } else { + idlePolls++ + if idlePolls > 2 { + return errors.New("nothing to keep running") } - default: - pending = obrs - emptyPolls = 0 } if clock.Now().Round(0).Sub(beginTime) >= c.cfg.BackgroundTaskMaxDuration { + pending, err := deps.ActiveDeliveries(ctx) + if err != nil { + c.cfg.Debug("lifecycle: failed to query active deliveries: %s", err) + } deps.NotifyFailure(pending) return errors.New("time expired") } diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 53d5bc8fa29b..9551074dd440 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -72,8 +72,8 @@ const ( // BackgroundSyncWait waits for a BackgroundSync that bails out on its own. BackgroundSyncWait - // BackgroundTaskDelivered finishes pending deliveries and polls until - // the task returns. + // BackgroundTaskDelivered finishes the pending work and polls until the + // task returns. BackgroundTaskDelivered BackgroundTaskFails // BackgroundTaskTimesUp advances the clock past the task's maximum @@ -169,8 +169,8 @@ type Harness struct { tokens map[int]int64 liveLocation *lifecycle.Hold - // A new background task asks Stay only once stayGate lets it, so the - // recorder sees the BACKGROUNDACTIVE the task may leave at once. + // A new background task asks Stay the first time only once stayGate lets + // it, so the recorder sees the BACKGROUNDACTIVE the task may leave at once. stayGate chan struct{} closing chan struct{} task int64 @@ -234,12 +234,15 @@ func (h *Harness) Warnings() int { return int(h.warnings.Load()) } func (h *Harness) warn() { h.warnings.Add(1) } func (h *Harness) deps() lifecycle.BackgroundTaskDeps { + var gated sync.Once return lifecycle.BackgroundTaskDeps{ Stay: func() bool { - select { - case <-h.stayGate: - case <-h.closing: - } + gated.Do(func() { + select { + case <-h.stayGate: + case <-h.closing: + } + }) return h.stay.Load() }, ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { @@ -340,6 +343,7 @@ func (h *Harness) perform(step Step) bool { case BackgroundSyncWait: h.wait(h.syncDone, "BackgroundSync") case BackgroundTaskDelivered: + h.stay.Store(false) h.pending.Store(0) for { h.Clock.Advance(pollInterval) From f56e54cd7eeb48bdc3184f446a24c37de5762530 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 16:37:37 -0400 Subject: [PATCH 11/16] fix(lifecycle): flush the local DBs once per backgrounding The controller flushed on every change into BACKGROUNDACTIVE or BACKGROUND from anything but BACKGROUND, so a plain backgrounding flushed twice (into BACKGROUNDACTIVE while the background task asks whether to stay, then into BACKGROUND milliseconds later) and every hold's end flushed again. Each flush is a full CompactRange. Flush when the state leaves FOREGROUND or INACTIVE for the background, and not on hold changes after that. Keep the two flushes master had beyond the backgrounding itself: the end of a push window in the background, since handling the push wrote to the DBs, and termination, whatever the state. Master flushed neither when a background sync nor when a background task ended. --- go/libkb/lifecycle/controller_test.go | 19 ++++-- go/libkb/lifecycle/lifecycle.go | 41 +++++++---- go/libkb/lifecycle/lifecycletest/scenarios.go | 68 +++++++++---------- 3 files changed, 74 insertions(+), 54 deletions(-) diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 1453b0c3f6a8..f5c17c009480 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -52,14 +52,14 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Positive(t, token) c.WaitBackgroundTask(token) require.Equal(t, background, appState.State()) - // Into BACKGROUNDACTIVE, then out of it. - require.Equal(t, 2, flushes) + // Once, on leaving the foreground; hold changes in the background don't + // flush again. + require.Equal(t, 1, flushes) first := c.AcquireBackgroundWork() require.Equal(t, backgroundActive, appState.State()) require.True(t, first.Release()) require.Zero(t, lifecycle.Holds(c)) require.Equal(t, background, appState.State()) - require.Equal(t, 3, flushes) second := c.AcquireBackgroundWork() require.False(t, first.Release()) // The stale Release left the newer hold alone. @@ -67,7 +67,7 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) require.True(t, second.Release()) require.Equal(t, background, appState.State()) - require.Equal(t, 4, flushes) + require.Equal(t, 1, flushes) } // Close waits for the running background tasks, so no later call may start @@ -247,15 +247,22 @@ func TestBackgroundTaskHoldsWhileStayWithNothingToDeliver(t *testing.T) { } // Native gives these last events only a short wait, so the state change and -// the flush must happen before the slow pending-message warning. +// any flush must happen before the slow pending-message warning. func TestExitEventsApplyBeforeNotifying(t *testing.T) { events := map[string]struct { prepare func(c *lifecycle.Controller) do func(c *lifecycle.Controller, notifyPending func()) + flushes int }{ "willTerminate": { prepare: func(c *lifecycle.Controller) { c.UIActive() }, do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, + flushes: 1, + }, + "willTerminate in the background": { + prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, + do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, + flushes: 1, }, "backgroundTaskExpired": { prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, @@ -274,7 +281,7 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { event.do(c, func() { notified = true require.Equal(t, background, appState.State()) - require.Equal(t, flushesBefore+1, flushes) + require.Equal(t, flushesBefore+event.flushes, flushes) }) require.True(t, notified) }) diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 8faf65efe77f..e0d31694dbb6 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -61,10 +61,11 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // Flush runs when the state moves into BACKGROUNDACTIVE or BACKGROUND from - // anything but BACKGROUND, where the OS may suspend or kill the process - // next. It runs at most once per controller call, under the controller's - // lock, so it must not block. + // Flush runs once per backgrounding, when the state leaves FOREGROUND or + // INACTIVE for BACKGROUNDACTIVE or BACKGROUND, where the OS may suspend or + // kill the process next; and after a push window or on termination in the + // background. It runs at most once per controller call, under the + // controller's lock, so it must not block. Flush func() Debug func(format string, args ...interface{}) } @@ -187,15 +188,25 @@ func (c *Controller) debugLocked(event string, format string, args ...interface{ c.ui, len(c.holds), c.appState.State()) } +func inBackground(state keybase1.MobileAppState) bool { + return state == keybase1.MobileAppState_BACKGROUND || state == keybase1.MobileAppState_BACKGROUNDACTIVE +} + // applyLocked writes the derived state. The OS may suspend or kill the -// process once the UI is in the background or nothing holds it up, so it -// flushes when the state moves into the background from anything but -// BACKGROUND. -func (c *Controller) applyLocked() { +// process once the UI leaves the screen, so it flushes when the state leaves +// the UI for the background. A flush is a full compaction, so hold changes +// in the background don't flush again. +func (c *Controller) applyLocked() { c.writeStateLocked(false) } + +// applyAfterWritesLocked is applyLocked for an event that ends work which +// wrote to the local DBs; it flushes whenever the state is in the background. +func (c *Controller) applyAfterWritesLocked() { c.writeStateLocked(true) } + +func (c *Controller) writeStateLocked(wrote bool) { prev := c.appState.State() state := derive(c.ui, len(c.holds)) - if c.appState.Update(state) && prev != keybase1.MobileAppState_BACKGROUND && - (state == keybase1.MobileAppState_BACKGROUND || state == keybase1.MobileAppState_BACKGROUNDACTIVE) { + c.appState.Update(state) + if inBackground(state) && (wrote || !inBackground(prev)) { c.cfg.Flush() } } @@ -345,14 +356,15 @@ func (c *Controller) WaitBackgroundTask(token int64) { c.debugLocked("waitBackgroundTask", "hold %d ended", token) } -// WillTerminate ends every hold: the process is about to die. notifyPending +// WillTerminate ends every hold and flushes: the process is about to die, +// maybe with writes since the app was backgrounded. notifyPending // warns about messages that won't send; it runs last because it can take // seconds and native waits only briefly. func (c *Controller) WillTerminate(notifyPending func()) { c.mu.Lock() c.setUILocked(UIBackground) c.dropLocked(func(*Hold) bool { return true }) - c.applyLocked() + c.applyAfterWritesLocked() c.debugLocked("willTerminate", "ended every hold") c.mu.Unlock() notifyPending() @@ -390,7 +402,8 @@ func (c *Controller) PushWindowBegin() int64 { // PushWindowEnd ends the push window's hold. If the UI is still in the // background, it first hands over to a background task, which keeps the app -// up while work must keep going. The token it returns is for the test harness; +// up while work must keep going. Handling the push wrote to the local DBs, so +// it flushes in the background. The token it returns is for the test harness; // native ignores it. func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 { c.mu.Lock() @@ -401,8 +414,8 @@ func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 { task = c.startTaskLocked(deps) } c.dropLocked(func(o *Hold) bool { return o == h }) + c.applyAfterWritesLocked() } - c.applyLocked() c.debugLocked("pushWindowEnd", "hold %d ended, background task hold %d", token, task) return task } diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index cc8f0aa9262c..70651fe33696 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -94,7 +94,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(WorkStarts, bg), step(PushWindowBegin, bg).returns(false), step(PushWindowEnd, bg).returns(false), @@ -106,7 +106,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps([]Step{ step(BackgroundSyncStart, bga).returns(true), - step(BackgroundSyncTimerFires, bg).flush(), + step(BackgroundSyncTimerFires, bg), }, toForeground), Observed: states(bg, bga, bg, ina, fg), }, @@ -115,7 +115,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), @@ -127,7 +127,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(WillResignActive, ina), - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(DidEnterBackground, bg).returns(false), step(WillEnterForeground, ina), step(WillEnterForeground, ina), @@ -137,7 +137,7 @@ var Scenarios = []Scenario{ step(WillResignActive, ina), step(DidBecomeActive, fg), step(WillResignActive, ina), - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), @@ -166,7 +166,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), @@ -209,7 +209,7 @@ var Scenarios = []Scenario{ Name: "ios background task completes", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskDelivered, bg).flush(), + step(BackgroundTaskDelivered, bg), step(BackgroundTaskExpired, bg), }, toForeground), Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), @@ -217,20 +217,20 @@ var Scenarios = []Scenario{ { Name: "ios background task fails", Platform: IOS, - Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task runs out of time", Platform: IOS, - Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task expires", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskExpired, bg).flush().warn(), + step(BackgroundTaskExpired, bg).warn(), step(BackgroundTaskWait, bg), step(BackgroundTaskExpired, bg), }), @@ -273,11 +273,11 @@ var Scenarios = []Scenario{ Name: "ios live location across background", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskDelivered, bg).flush(), + step(BackgroundTaskDelivered, bg), // A location update wakes the app while tracking. step(LiveLocationAcquire, bga), // Tracking ends. - step(LiveLocationRelease, bg).flush(), + step(LiveLocationRelease, bg), step(LiveLocationAcquire, bga), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -287,7 +287,7 @@ var Scenarios = []Scenario{ step(WorkStops, fg), step(WillResignActive, ina), step(DidEnterBackground, bga).flush().returns(false), - step(LiveLocationRelease, bg).flush(), + step(LiveLocationRelease, bg), }), Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), }, @@ -299,7 +299,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(DidEnterBackground, bga).returns(true), - step(BackgroundTaskFails, bg).flush().warn(), + step(BackgroundTaskFails, bg).warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -309,7 +309,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{ step(BackgroundTaskExpired, bga).warn(), step(BackgroundTaskWait, bga), - step(LiveLocationRelease, bg).flush(), + step(LiveLocationRelease, bg), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -318,8 +318,8 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flushes(2).returns(false), - step(WillTerminate, bg).warn(), + step(DidEnterBackground, bg).flush().returns(false), + step(WillTerminate, bg).flush().warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -356,7 +356,7 @@ var Scenarios = []Scenario{ Name: "android process stop and start", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), }, toForeground, []Step{ step(WorkStarts, fg), step(DidEnterBackground, bga).flush().returns(true), @@ -394,7 +394,7 @@ var Scenarios = []Scenario{ Name: "android push window in the background", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), }), @@ -416,8 +416,8 @@ var Scenarios = []Scenario{ Steps: []Step{ step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), - step(PushWindowEnd, bga).returns(true), - step(BackgroundTaskDelivered, bg).flush(), + step(PushWindowEnd, bga).flush().returns(true), + step(BackgroundTaskDelivered, bg), }, Observed: states(bg, bga, bg), }, @@ -426,7 +426,7 @@ var Scenarios = []Scenario{ Name: "android push window racing process start", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(WillEnterForeground, ina), // No background task outside the background, even with work pending. @@ -436,7 +436,7 @@ var Scenarios = []Scenario{ step(WorkStops, fg), step(PushWindowBegin, fg).returns(false), step(PushWindowEnd, fg).returns(false), - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), }, toForeground, []Step{ step(DidEnterBackground, bga).flush().returns(false), @@ -448,11 +448,11 @@ var Scenarios = []Scenario{ Name: "android push window hands over to a background task", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), - step(PushWindowEnd, bga).returns(true), - step(BackgroundTaskDelivered, bg).flush(), + step(PushWindowEnd, bga).flush().returns(true), + step(BackgroundTaskDelivered, bg), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -463,8 +463,8 @@ var Scenarios = []Scenario{ step(WorkStarts, fg), step(DidEnterBackground, bga).flush().returns(true), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bga).returns(true), - step(BackgroundTaskFails, bg).flush().warn(), + step(PushWindowEnd, bga).flush().returns(true), + step(BackgroundTaskFails, bg).warn(), }), Observed: states(bg, ina, fg, bga, bg), }, @@ -472,10 +472,10 @@ var Scenarios = []Scenario{ Name: "android overlapping push windows", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), - step(PushWindowEnd, bga).slot(0).returns(false), + step(PushWindowEnd, bga).slot(0).flush().returns(false), step(PushWindowEnd, bg).slot(1).flush().returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), @@ -489,7 +489,7 @@ var Scenarios = []Scenario{ step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), - step(BackgroundSyncTimerFires, bg).flush(), + step(BackgroundSyncTimerFires, bg), }, Observed: states(bg, bga, bg, bga, bg), }, @@ -511,11 +511,11 @@ var Scenarios = []Scenario{ Name: "android WorkManager BackgroundSync racing a push window", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flushes(2).returns(false), + step(DidEnterBackground, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bga).returns(false), - step(BackgroundSyncTimerFires, bg).flush(), + step(PushWindowEnd, bga).flush().returns(false), + step(BackgroundSyncTimerFires, bg), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, From 068ba44bc701ae9740dba64c1f66b35726f67850 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 16:50:35 -0400 Subject: [PATCH 12/16] fix(lifecycle): flush whenever the state reaches the background The flush rule was "the state leaves FOREGROUND/INACTIVE for the background", plus a forced flush after a push window and on termination. That missed the writes a hold makes: a background task, background sync or live location hold ends BACKGROUNDACTIVE -> BACKGROUND without a flush, so the OS suspends the app with a non-empty journal. It also flushed on every push window end in the background, even one that overlapped another window or handed over to a background task, so a burst of pushes started a compaction per push. The termination flush only starts goroutines that the dying process rarely lets finish. Flush on every change into BACKGROUND and nowhere else. BACKGROUND is where the OS suspends or kills the process, and reaching it means every hold that could have written has ended, so one flush covers them all: backgrounding with a task flushes once, when the task ends; overlapping holds flush once, when the last ends; entering BACKGROUNDACTIVE never flushes. Termination flushes only if it moves the state into BACKGROUND. --- go/libkb/appstate.go | 4 +- go/libkb/lifecycle/controller_test.go | 8 +-- go/libkb/lifecycle/lifecycle.go | 41 +++++--------- go/libkb/lifecycle/lifecycletest/scenarios.go | 54 +++++++++---------- go/libkb/lifecycle/scenario_test.go | 2 +- 5 files changed, 49 insertions(+), 60 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 2d1a58831959..7812fd4063f3 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -380,8 +380,8 @@ func (a *DesktopAppState) resetLocked() { // flushLocalDbs flushes the leveldb memtables in the background. An unclean // kill while suspended (routine on iOS) with a non-empty journal forces a // journal replay — or a whole-DB recovery — during the next launch, which is -// the main cold-start cost. Called when the app heads to the background so -// the journals are empty if the OS kills the process. +// the main cold-start cost. Called whenever the app state changes into +// BACKGROUND, so the journals are empty if the OS kills the process. func (g *GlobalContext) flushLocalDbs() { flush := func(name string, db *JSONLocalDb) { if db == nil { diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index f5c17c009480..745cb7ac86b4 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -52,14 +52,15 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Positive(t, token) c.WaitBackgroundTask(token) require.Equal(t, background, appState.State()) - // Once, on leaving the foreground; hold changes in the background don't - // flush again. require.Equal(t, 1, flushes) + // Entering BACKGROUNDACTIVE doesn't flush; ending its last hold does. first := c.AcquireBackgroundWork() require.Equal(t, backgroundActive, appState.State()) + require.Equal(t, 1, flushes) require.True(t, first.Release()) require.Zero(t, lifecycle.Holds(c)) require.Equal(t, background, appState.State()) + require.Equal(t, 2, flushes) second := c.AcquireBackgroundWork() require.False(t, first.Release()) // The stale Release left the newer hold alone. @@ -67,7 +68,7 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) require.True(t, second.Release()) require.Equal(t, background, appState.State()) - require.Equal(t, 1, flushes) + require.Equal(t, 3, flushes) } // Close waits for the running background tasks, so no later call may start @@ -267,6 +268,7 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { "backgroundTaskExpired": { prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) }, + flushes: 1, }, } for name, event := range events { diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index e0d31694dbb6..e1697e216a17 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -61,11 +61,9 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // Flush runs once per backgrounding, when the state leaves FOREGROUND or - // INACTIVE for BACKGROUNDACTIVE or BACKGROUND, where the OS may suspend or - // kill the process next; and after a push window or on termination in the - // background. It runs at most once per controller call, under the - // controller's lock, so it must not block. + // Flush runs whenever the state changes into BACKGROUND, where the OS may + // suspend or kill the process next. It runs under the controller's lock, + // so it must not block. Flush func() Debug func(format string, args ...interface{}) } @@ -188,25 +186,16 @@ func (c *Controller) debugLocked(event string, format string, args ...interface{ c.ui, len(c.holds), c.appState.State()) } -func inBackground(state keybase1.MobileAppState) bool { - return state == keybase1.MobileAppState_BACKGROUND || state == keybase1.MobileAppState_BACKGROUNDACTIVE -} - -// applyLocked writes the derived state. The OS may suspend or kill the -// process once the UI leaves the screen, so it flushes when the state leaves -// the UI for the background. A flush is a full compaction, so hold changes -// in the background don't flush again. -func (c *Controller) applyLocked() { c.writeStateLocked(false) } - -// applyAfterWritesLocked is applyLocked for an event that ends work which -// wrote to the local DBs; it flushes whenever the state is in the background. -func (c *Controller) applyAfterWritesLocked() { c.writeStateLocked(true) } - -func (c *Controller) writeStateLocked(wrote bool) { +// applyLocked writes the derived state, and flushes when it changes into +// BACKGROUND: the OS may suspend or kill the process from there, and every +// hold that kept it BACKGROUNDACTIVE may have written to the local DBs. A +// flush is a full compaction, so entering BACKGROUNDACTIVE doesn't flush; its +// writes are flushed once the last hold ends. +func (c *Controller) applyLocked() { prev := c.appState.State() state := derive(c.ui, len(c.holds)) c.appState.Update(state) - if inBackground(state) && (wrote || !inBackground(prev)) { + if state == keybase1.MobileAppState_BACKGROUND && prev != keybase1.MobileAppState_BACKGROUND { c.cfg.Flush() } } @@ -356,15 +345,14 @@ func (c *Controller) WaitBackgroundTask(token int64) { c.debugLocked("waitBackgroundTask", "hold %d ended", token) } -// WillTerminate ends every hold and flushes: the process is about to die, -// maybe with writes since the app was backgrounded. notifyPending +// WillTerminate ends every hold: the process is about to die. notifyPending // warns about messages that won't send; it runs last because it can take // seconds and native waits only briefly. func (c *Controller) WillTerminate(notifyPending func()) { c.mu.Lock() c.setUILocked(UIBackground) c.dropLocked(func(*Hold) bool { return true }) - c.applyAfterWritesLocked() + c.applyLocked() c.debugLocked("willTerminate", "ended every hold") c.mu.Unlock() notifyPending() @@ -402,8 +390,7 @@ func (c *Controller) PushWindowBegin() int64 { // PushWindowEnd ends the push window's hold. If the UI is still in the // background, it first hands over to a background task, which keeps the app -// up while work must keep going. Handling the push wrote to the local DBs, so -// it flushes in the background. The token it returns is for the test harness; +// up while work must keep going. The token it returns is for the test harness; // native ignores it. func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 { c.mu.Lock() @@ -414,7 +401,7 @@ func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 { task = c.startTaskLocked(deps) } c.dropLocked(func(o *Hold) bool { return o == h }) - c.applyAfterWritesLocked() + c.applyLocked() } c.debugLocked("pushWindowEnd", "hold %d ended, background task hold %d", token, task) return task diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 70651fe33696..5ca406b50df5 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -62,7 +62,7 @@ var toForeground = []Step{ var iosToBackgroundTask = []Step{ step(WorkStarts, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).flush().returns(true), + step(DidEnterBackground, bga).returns(true), } // Scenarios replays whole native event sequences. Consumers of the app state @@ -106,7 +106,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps([]Step{ step(BackgroundSyncStart, bga).returns(true), - step(BackgroundSyncTimerFires, bg), + step(BackgroundSyncTimerFires, bg).flush(), }, toForeground), Observed: states(bg, bga, bg, ina, fg), }, @@ -209,7 +209,7 @@ var Scenarios = []Scenario{ Name: "ios background task completes", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskDelivered, bg), + step(BackgroundTaskDelivered, bg).flush(), step(BackgroundTaskExpired, bg), }, toForeground), Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), @@ -217,20 +217,20 @@ var Scenarios = []Scenario{ { Name: "ios background task fails", Platform: IOS, - Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task runs out of time", Platform: IOS, - Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task expires", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskExpired, bg).warn(), + step(BackgroundTaskExpired, bg).flush().warn(), step(BackgroundTaskWait, bg), step(BackgroundTaskExpired, bg), }), @@ -273,11 +273,11 @@ var Scenarios = []Scenario{ Name: "ios live location across background", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskDelivered, bg), + step(BackgroundTaskDelivered, bg).flush(), // A location update wakes the app while tracking. step(LiveLocationAcquire, bga), // Tracking ends. - step(LiveLocationRelease, bg), + step(LiveLocationRelease, bg).flush(), step(LiveLocationAcquire, bga), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -286,8 +286,8 @@ var Scenarios = []Scenario{ step(LiveLocationAcquire, fg), step(WorkStops, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).flush().returns(false), - step(LiveLocationRelease, bg), + step(DidEnterBackground, bga).returns(false), + step(LiveLocationRelease, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), }, @@ -299,7 +299,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(DidEnterBackground, bga).returns(true), - step(BackgroundTaskFails, bg).warn(), + step(BackgroundTaskFails, bg).flush().warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -309,7 +309,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{ step(BackgroundTaskExpired, bga).warn(), step(BackgroundTaskWait, bga), - step(LiveLocationRelease, bg), + step(LiveLocationRelease, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -319,7 +319,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(DidEnterBackground, bg).flush().returns(false), - step(WillTerminate, bg).flush().warn(), + step(WillTerminate, bg).warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -345,7 +345,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(LiveLocationAcquire, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).flush().returns(false), + step(DidEnterBackground, bga).returns(false), step(WillTerminate, bg).flush().warn(), step(LiveLocationRelease, bg), }), @@ -359,7 +359,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), }, toForeground, []Step{ step(WorkStarts, fg), - step(DidEnterBackground, bga).flush().returns(true), + step(DidEnterBackground, bga).returns(true), step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), @@ -416,8 +416,8 @@ var Scenarios = []Scenario{ Steps: []Step{ step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), - step(PushWindowEnd, bga).flush().returns(true), - step(BackgroundTaskDelivered, bg), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskDelivered, bg).flush(), }, Observed: states(bg, bga, bg), }, @@ -439,7 +439,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), }, toForeground, []Step{ - step(DidEnterBackground, bga).flush().returns(false), + step(DidEnterBackground, bga).returns(false), step(PushWindowEnd, bg).flush().returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg), @@ -451,8 +451,8 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), - step(PushWindowEnd, bga).flush().returns(true), - step(BackgroundTaskDelivered, bg), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskDelivered, bg).flush(), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -461,10 +461,10 @@ var Scenarios = []Scenario{ Platform: Android, Steps: steps(toForeground, []Step{ step(WorkStarts, fg), - step(DidEnterBackground, bga).flush().returns(true), + step(DidEnterBackground, bga).returns(true), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bga).flush().returns(true), - step(BackgroundTaskFails, bg).warn(), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskFails, bg).flush().warn(), }), Observed: states(bg, ina, fg, bga, bg), }, @@ -475,7 +475,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), - step(PushWindowEnd, bga).slot(0).flush().returns(false), + step(PushWindowEnd, bga).slot(0).returns(false), step(PushWindowEnd, bg).slot(1).flush().returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), @@ -489,7 +489,7 @@ var Scenarios = []Scenario{ step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), - step(BackgroundSyncTimerFires, bg), + step(BackgroundSyncTimerFires, bg).flush(), }, Observed: states(bg, bga, bg, bga, bg), }, @@ -514,8 +514,8 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bga).flush().returns(false), - step(BackgroundSyncTimerFires, bg), + step(PushWindowEnd, bga).returns(false), + step(BackgroundSyncTimerFires, bg).flush(), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go index e885949acbf1..a5534d650ed5 100644 --- a/go/libkb/lifecycle/scenario_test.go +++ b/go/libkb/lifecycle/scenario_test.go @@ -78,7 +78,7 @@ func TestHarnessCloseEndsRunningWork(t *testing.T) { "background task": { {Do: lifecycletest.DidBecomeActive, Want: keybase1.MobileAppState_FOREGROUND}, {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_FOREGROUND}, - {Do: lifecycletest.DidEnterBackground, Want: bga, Flushes: 1, Returns: lifecycletest.ReturnTrue}, + {Do: lifecycletest.DidEnterBackground, Want: bga, Returns: lifecycletest.ReturnTrue}, }, } for name, steps := range cases { From b0e7ea7f79eee5e32413a108cc64aa05b37fa0ca Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 16:51:44 -0400 Subject: [PATCH 13/16] refactor(lifecycle): drop the unused changed result from AppState.Update The controller's flush rule was the only caller that read it; it now compares the state before and after the write itself. --- go/libkb/appstate.go | 13 ++++++------- go/libkb/appstate_test.go | 10 +++++----- go/libkb/lifecycle/lifecycle.go | 2 +- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 7812fd4063f3..bad931a827b9 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -76,10 +76,10 @@ func (a *MobileAppState) NextUpdate(lastState keybase1.MobileAppState) <-chan st return a.changed } -func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bool) { +func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) { if a.state == state { a.G().Log.Debug("MobileAppState.Update: same-value update: %v", state) - return false + return } a.G().Log.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v", state, a.state) @@ -113,11 +113,10 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo // order is already the writing order; what is missing is one ordered // stream per connection to carry it. a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) - return true } -// Update sets the current app state and returns whether the value changed; -// only a change wakes NextUpdate callers and has side effects. +// Update sets the current app state; only a change wakes NextUpdate callers +// and has side effects. // // Connected clients are told from here, the one place the value changes, which // is also before lifecycle's Flush hook runs. On iOS that is as early as a @@ -127,11 +126,11 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo // AppWaitBackgroundTask returns), not until clients have received it. A client // acting on the notification is racing the OS, and what it can lose is bounded // by whatever it last wrote of its own accord. -func (a *MobileAppState) Update(state keybase1.MobileAppState) (changed bool) { +func (a *MobileAppState) Update(state keybase1.MobileAppState) { defer a.G().Trace(fmt.Sprintf("MobileAppState.Update(%v)", state), nil)() a.Lock() defer a.Unlock() - return a.updateLocked(state) + a.updateLocked(state) } // State returns the current app state diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index a7b1251ae629..5c33f1e1d764 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -41,12 +41,12 @@ func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) { defer tc.Cleanup() a := NewMobileAppState(tc.G) - require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + a.Update(keybase1.MobileAppState_BACKGROUND) _, mtime := a.StateAndMtime() require.NotNil(t, mtime) next := a.NextUpdate(keybase1.MobileAppState_BACKGROUND) - require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + a.Update(keybase1.MobileAppState_BACKGROUND) requireOpen(t, next) _, mtime2 := a.StateAndMtime() require.Same(t, mtime, mtime2) @@ -54,7 +54,7 @@ func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) { // A stale lastState wakes immediately. requireClosed(t, a.NextUpdate(keybase1.MobileAppState_FOREGROUND)) - require.True(t, a.Update(keybase1.MobileAppState_FOREGROUND)) + a.Update(keybase1.MobileAppState_FOREGROUND) requireClosed(t, next) } @@ -69,10 +69,10 @@ func TestMobileAppStateBackgroundCancelsRPCsOnlyOnChange(t *testing.T) { } first := register() - require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + a.Update(keybase1.MobileAppState_BACKGROUND) requireClosed(t, first.Done()) second := register() - require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + a.Update(keybase1.MobileAppState_BACKGROUND) requireOpen(t, second.Done()) } diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index e1697e216a17..acd3ff6c91ba 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -44,7 +44,7 @@ const ( // AppState is the part of libkb.MobileAppState the controller drives. type AppState interface { State() keybase1.MobileAppState - Update(state keybase1.MobileAppState) (changed bool) + Update(state keybase1.MobileAppState) NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} } From 9708328249a76adde05459d98958062bbf6a2cd3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 17:09:32 -0400 Subject: [PATCH 14/16] fix(lifecycle): flush on leaving the UI, and coalesce the flush at a hold's end Flushing only on a change into BACKGROUND had three problems. Leaving the UI for BACKGROUNDACTIVE (a hold open, such as live location for hours) no longer flushed, so the whole foreground session's writes sat in the journal until the hold ended, and a jetsam kill or swipe-away meanwhile paid a journal replay on the next cold start. The flush started exactly when the last hold ended, which is when iOS ends the background task and suspends the app, so the fire-and-forget flush raced suspension. And sequential holds, such as pushes a few seconds apart, each ended in BACKGROUND and each started a full compaction. Flush on every change from FOREGROUND/INACTIVE into BACKGROUNDACTIVE or BACKGROUND, which runs within the background task's lifetime and covers the foreground session. Also flush on BACKGROUNDACTIVE -> BACKGROUND, to cover what the holds wrote, unless a flush started less than FlushCoalesceInterval (default 10s) ago: a backgrounding whose task ends at once flushes once, a long task or hold flushes again at its end, and a burst of short holds coalesces. --- go/libkb/appstate.go | 5 +- go/libkb/lifecycle/controller_test.go | 81 ++++++++++++++++--- go/libkb/lifecycle/lifecycle.go | 52 +++++++++--- go/libkb/lifecycle/lifecycletest/harness.go | 8 +- go/libkb/lifecycle/lifecycletest/scenarios.go | 42 +++++----- go/libkb/lifecycle/scenario_test.go | 2 +- 6 files changed, 142 insertions(+), 48 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index bad931a827b9..7de3d5dba773 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -379,8 +379,9 @@ func (a *DesktopAppState) resetLocked() { // flushLocalDbs flushes the leveldb memtables in the background. An unclean // kill while suspended (routine on iOS) with a non-empty journal forces a // journal replay — or a whole-DB recovery — during the next launch, which is -// the main cold-start cost. Called whenever the app state changes into -// BACKGROUND, so the journals are empty if the OS kills the process. +// the main cold-start cost. lifecycle calls it when the app leaves the UI and +// when background work ends, so the journals are empty if the OS kills the +// process. func (g *GlobalContext) flushLocalDbs() { flush := func(name string, db *JSONLocalDb) { if db == nil { diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 745cb7ac86b4..1600c3a7ef3f 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -46,21 +46,16 @@ func noDeliveries(stay bool) lifecycle.BackgroundTaskDeps { // An id is never reused, so releasing an old hold again can't end a newer one. func TestHoldReleaseIsIdempotent(t *testing.T) { appState, _ := newAppState(t) - flushes := 0 - c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) + c := lifecycle.New(appState, lifecycle.Config{}) token := c.UIBackground(noDeliveries(false)) require.Positive(t, token) c.WaitBackgroundTask(token) require.Equal(t, background, appState.State()) - require.Equal(t, 1, flushes) - // Entering BACKGROUNDACTIVE doesn't flush; ending its last hold does. first := c.AcquireBackgroundWork() require.Equal(t, backgroundActive, appState.State()) - require.Equal(t, 1, flushes) require.True(t, first.Release()) require.Zero(t, lifecycle.Holds(c)) require.Equal(t, background, appState.State()) - require.Equal(t, 2, flushes) second := c.AcquireBackgroundWork() require.False(t, first.Release()) // The stale Release left the newer hold alone. @@ -68,7 +63,61 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) require.True(t, second.Release()) require.Equal(t, background, appState.State()) - require.Equal(t, 3, flushes) +} + +// Leaving the UI always flushes; the end of the last hold flushes unless a +// flush started less than FlushCoalesceInterval ago. +func TestFlushRule(t *testing.T) { + const interval = 10 * time.Second + appState, _ := newAppState(t) + appState.Update(background) + clock := lifecycletest.NewFakeClock() + flushes := 0 + c := lifecycle.New(appState, lifecycle.Config{ + Clock: clock, + FlushCoalesceInterval: interval, + Flush: func() { flushes++ }, + }) + defer c.Close() + flushesAfter := func(what string, do func(), want keybase1.MobileAppState, n int) { + t.Helper() + before := flushes + do() + require.Equal(t, want, appState.State(), what) + require.Equal(t, n, flushes-before, what) + } + var hold *lifecycle.Hold + acquire := func() { hold = c.AcquireBackgroundWork() } + release := func() { require.True(t, hold.Release()) } + + flushesAfter("entering BACKGROUNDACTIVE", acquire, backgroundActive, 0) + flushesAfter("the first hold's end", release, background, 1) + flushesAfter("a hold within the interval", func() { acquire(); release() }, background, 0) + clock.Advance(interval - time.Second) + flushesAfter("a hold just within the interval", func() { acquire(); release() }, background, 0) + clock.Advance(time.Second) + flushesAfter("a hold once the interval has passed", func() { acquire(); release() }, background, 1) + + flushesAfter("coming to the foreground", c.UIActive, foreground, 0) + flushesAfter("going inactive", c.UIInactive, inactive, 0) + flushesAfter("leaving the UI with a hold open, within the interval", func() { + acquire() + lifecycletest.ToBackground(c) + }, backgroundActive, 1) + flushesAfter("the hold's end at once", release, background, 0) + clock.Advance(interval) + flushesAfter("a hold's end once the interval has passed", func() { acquire(); release() }, background, 1) + + c.UIActive() + clock.Advance(time.Hour) + flushesAfter("leaving the UI while a hold keeps the app running", func() { + acquire() + lifecycletest.ToBackground(c) + }, backgroundActive, 1) + clock.Advance(time.Hour) + flushesAfter("that hold's end", release, background, 1) + c.UIActive() + flushesAfter("termination from the foreground", func() { c.WillTerminate(noop) }, background, 1) } // Close waits for the running background tasks, so no later call may start @@ -250,23 +299,28 @@ func TestBackgroundTaskHoldsWhileStayWithNothingToDeliver(t *testing.T) { // Native gives these last events only a short wait, so the state change and // any flush must happen before the slow pending-message warning. func TestExitEventsApplyBeforeNotifying(t *testing.T) { + // A task that has run past the flush coalescing interval, so its end flushes. + backgroundTask := func(c *lifecycle.Controller, clock *lifecycletest.FakeClock) { + require.Positive(t, c.UIBackground(noDeliveries(true))) + clock.Advance(lifecycle.DefaultFlushCoalesceInterval) + } events := map[string]struct { - prepare func(c *lifecycle.Controller) + prepare func(c *lifecycle.Controller, clock *lifecycletest.FakeClock) do func(c *lifecycle.Controller, notifyPending func()) flushes int }{ "willTerminate": { - prepare: func(c *lifecycle.Controller) { c.UIActive() }, + prepare: func(c *lifecycle.Controller, _ *lifecycletest.FakeClock) { c.UIActive() }, do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, flushes: 1, }, "willTerminate in the background": { - prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, + prepare: backgroundTask, do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, flushes: 1, }, "backgroundTaskExpired": { - prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, + prepare: backgroundTask, do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) }, flushes: 1, }, @@ -275,9 +329,10 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { t.Run(name, func(t *testing.T) { appState, _ := newAppState(t) var flushes int - c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) + clock := lifecycletest.NewFakeClock() + c := lifecycle.New(appState, lifecycle.Config{Clock: clock, Flush: func() { flushes++ }}) defer c.Close() - event.prepare(c) + event.prepare(c, clock) flushesBefore := flushes notified := false event.do(c, func() { diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index acd3ff6c91ba..2c75e60ed6d2 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -52,6 +52,7 @@ const ( DefaultBackgroundSyncWindow = 10 * time.Second DefaultBackgroundTaskPollInterval = 5 * time.Second DefaultBackgroundTaskMaxDuration = 10 * time.Minute + DefaultFlushCoalesceInterval = 10 * time.Second ) // Config holds the controller's dependencies. Zero fields get defaults: the @@ -61,9 +62,12 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // Flush runs whenever the state changes into BACKGROUND, where the OS may - // suspend or kill the process next. It runs under the controller's lock, - // so it must not block. + // FlushCoalesceInterval is how soon after a flush the end of a hold skips + // its own; see applyLocked. + FlushCoalesceInterval time.Duration + // Flush runs when the app leaves the UI and when the last hold ends after + // a while (see applyLocked). It runs under the controller's lock, so it + // must not block. Flush func() Debug func(format string, args ...interface{}) } @@ -124,6 +128,8 @@ type Controller struct { // closed stops new tasks once Close is waiting for the running ones, so // nothing joins wg while Close waits on it. closed bool + // lastFlush is when the last flush started, zero before the first. + lastFlush time.Time } func New(appState AppState, cfg Config) *Controller { @@ -139,6 +145,9 @@ func New(appState AppState, cfg Config) *Controller { if cfg.BackgroundTaskMaxDuration == 0 { cfg.BackgroundTaskMaxDuration = DefaultBackgroundTaskMaxDuration } + if cfg.FlushCoalesceInterval == 0 { + cfg.FlushCoalesceInterval = DefaultFlushCoalesceInterval + } if cfg.Flush == nil { cfg.Flush = func() {} } @@ -186,18 +195,41 @@ func (c *Controller) debugLocked(event string, format string, args ...interface{ c.ui, len(c.holds), c.appState.State()) } -// applyLocked writes the derived state, and flushes when it changes into -// BACKGROUND: the OS may suspend or kill the process from there, and every -// hold that kept it BACKGROUNDACTIVE may have written to the local DBs. A -// flush is a full compaction, so entering BACKGROUNDACTIVE doesn't flush; its -// writes are flushed once the last hold ends. +func isUIState(state keybase1.MobileAppState) bool { + return state == keybase1.MobileAppState_FOREGROUND || state == keybase1.MobileAppState_INACTIVE +} + +// applyLocked writes the derived state and flushes the local DBs, so an +// unclean kill doesn't cost a journal replay at the next launch: +// +// - Always when the UI leaves the screen (FOREGROUND/INACTIVE to +// BACKGROUNDACTIVE or BACKGROUND). That covers the whole foreground +// session's writes, even when a hold such as live location then keeps +// the app BACKGROUNDACTIVE for hours, and the flush starts while the +// background task still keeps the process running; the fire-and-forget +// flush would otherwise race the suspension that follows the task's end. +// - When the last hold ends (BACKGROUNDACTIVE to BACKGROUND), to cover the +// holds' writes, unless a flush started less than FlushCoalesceInterval +// ago. A flush is a full compaction: this keeps a backgrounding whose +// task ends at once, or a burst of pushes seconds apart, to one. func (c *Controller) applyLocked() { prev := c.appState.State() state := derive(c.ui, len(c.holds)) c.appState.Update(state) - if state == keybase1.MobileAppState_BACKGROUND && prev != keybase1.MobileAppState_BACKGROUND { - c.cfg.Flush() + leftUI := isUIState(prev) && !isUIState(state) + holdsEnded := prev == keybase1.MobileAppState_BACKGROUNDACTIVE && state == keybase1.MobileAppState_BACKGROUND + if !leftUI && !holdsEnded { + return + } + // Round(0) drops the monotonic reading, so time the device spends asleep + // counts toward the interval. + now := c.cfg.Clock.Now().Round(0) + if holdsEnded && !c.lastFlush.IsZero() && now.Sub(c.lastFlush) < c.cfg.FlushCoalesceInterval { + c.debugLocked("flush", "skipped, the last flush started %v ago", now.Sub(c.lastFlush)) + return } + c.lastFlush = now + c.cfg.Flush() } func (c *Controller) acquireLocked(reason Reason) *Hold { diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 9551074dd440..65da2f99ce62 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -180,9 +180,10 @@ type Harness struct { } const ( - syncWindow = 10 * time.Second - pollInterval = 5 * time.Second - maxDuration = 10 * time.Minute + syncWindow = 10 * time.Second + pollInterval = 5 * time.Second + maxDuration = 10 * time.Minute + flushCoalesce = 10 * time.Second ) // NewHarness moves appState to the initial state and starts @@ -206,6 +207,7 @@ func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *H BackgroundSyncWindow: syncWindow, BackgroundTaskPollInterval: pollInterval, BackgroundTaskMaxDuration: maxDuration, + FlushCoalesceInterval: flushCoalesce, Flush: func() { h.flushes.Add(1) }, Debug: func(format string, args ...interface{}) { t.Logf(format, args...) }, }) diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 5ca406b50df5..3c9dbd4e51ca 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -62,11 +62,15 @@ var toForeground = []Step{ var iosToBackgroundTask = []Step{ step(WorkStarts, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).returns(true), + step(DidEnterBackground, bga).flush().returns(true), } // Scenarios replays whole native event sequences. Consumers of the app state // can play them with their own checks (see Play). +// +// The fake clock moves only when a step waits on it (a sync window, a task's +// polls or maximum duration), so a hold that ends at the same time as the +// last flush doesn't flush again. var Scenarios = []Scenario{ {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, { @@ -217,7 +221,7 @@ var Scenarios = []Scenario{ { Name: "ios background task fails", Platform: IOS, - Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { @@ -230,7 +234,7 @@ var Scenarios = []Scenario{ Name: "ios background task expires", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskExpired, bg).flush().warn(), + step(BackgroundTaskExpired, bg).warn(), step(BackgroundTaskWait, bg), step(BackgroundTaskExpired, bg), }), @@ -277,7 +281,7 @@ var Scenarios = []Scenario{ // A location update wakes the app while tracking. step(LiveLocationAcquire, bga), // Tracking ends. - step(LiveLocationRelease, bg).flush(), + step(LiveLocationRelease, bg), step(LiveLocationAcquire, bga), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -286,8 +290,8 @@ var Scenarios = []Scenario{ step(LiveLocationAcquire, fg), step(WorkStops, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).returns(false), - step(LiveLocationRelease, bg).flush(), + step(DidEnterBackground, bga).flush().returns(false), + step(LiveLocationRelease, bg), }), Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), }, @@ -299,7 +303,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(DidEnterBackground, bga).returns(true), - step(BackgroundTaskFails, bg).flush().warn(), + step(BackgroundTaskFails, bg).warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -309,7 +313,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{ step(BackgroundTaskExpired, bga).warn(), step(BackgroundTaskWait, bga), - step(LiveLocationRelease, bg).flush(), + step(LiveLocationRelease, bg), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -333,7 +337,7 @@ var Scenarios = []Scenario{ Name: "ios termination during a background task", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(WillTerminate, bg).flush().warn(), + step(WillTerminate, bg).warn(), step(BackgroundTaskWait, bg), step(BackgroundTaskExpired, bg), }), @@ -345,8 +349,8 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(LiveLocationAcquire, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).returns(false), - step(WillTerminate, bg).flush().warn(), + step(DidEnterBackground, bga).flush().returns(false), + step(WillTerminate, bg).warn(), step(LiveLocationRelease, bg), }), Observed: states(bg, ina, fg, ina, bga, bg), @@ -359,7 +363,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), }, toForeground, []Step{ step(WorkStarts, fg), - step(DidEnterBackground, bga).returns(true), + step(DidEnterBackground, bga).flush().returns(true), step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), @@ -396,7 +400,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), + step(PushWindowEnd, bg).returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -439,8 +443,8 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), }, toForeground, []Step{ - step(DidEnterBackground, bga).returns(false), - step(PushWindowEnd, bg).flush().returns(false), + step(DidEnterBackground, bga).flush().returns(false), + step(PushWindowEnd, bg).returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg), }, @@ -461,10 +465,10 @@ var Scenarios = []Scenario{ Platform: Android, Steps: steps(toForeground, []Step{ step(WorkStarts, fg), - step(DidEnterBackground, bga).returns(true), + step(DidEnterBackground, bga).flush().returns(true), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bga).returns(true), - step(BackgroundTaskFails, bg).flush().warn(), + step(BackgroundTaskFails, bg).warn(), }), Observed: states(bg, ina, fg, bga, bg), }, @@ -476,7 +480,7 @@ var Scenarios = []Scenario{ step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), step(PushWindowEnd, bga).slot(0).returns(false), - step(PushWindowEnd, bg).slot(1).flush().returns(false), + step(PushWindowEnd, bg).slot(1).returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -528,7 +532,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(WillTerminate, bg).flush().warn(), step(PushWindowBegin, bga).returns(true), - step(WillTerminate, bg).flush().warn(), + step(WillTerminate, bg).warn(), step(WorkStarts, bg), step(PushWindowEnd, bg).returns(false), }), diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go index a5534d650ed5..e885949acbf1 100644 --- a/go/libkb/lifecycle/scenario_test.go +++ b/go/libkb/lifecycle/scenario_test.go @@ -78,7 +78,7 @@ func TestHarnessCloseEndsRunningWork(t *testing.T) { "background task": { {Do: lifecycletest.DidBecomeActive, Want: keybase1.MobileAppState_FOREGROUND}, {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_FOREGROUND}, - {Do: lifecycletest.DidEnterBackground, Want: bga, Returns: lifecycletest.ReturnTrue}, + {Do: lifecycletest.DidEnterBackground, Want: bga, Flushes: 1, Returns: lifecycletest.ReturnTrue}, }, } for name, steps := range cases { From 61cb122b7ee57c3e96362aa18011888ce28c4a10 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 17:33:22 -0400 Subject: [PATCH 15/16] fix(lifecycle): flush on every move into the background and on exit Coalescing the end of a hold's flush against the last one dropped writes: a hold that ended less than the interval after the last flush never flushed what it wrote, a backward wall-clock jump suppressed flushes, and an exit event inside the window skipped the final flush. With LevelDb.Flush reduced to a memtable flush, flushes are cheap enough not to skip any. Flush on every change into BACKGROUNDACTIVE or BACKGROUND from anything but BACKGROUND -- the UI leaving the screen and every hold's end -- and always on WillTerminate and BackgroundTaskExpired, even when the state doesn't change. The interval, the last-flush time and the clock reads go. --- go/libkb/appstate.go | 10 +-- go/libkb/lifecycle/controller_test.go | 54 +++++--------- go/libkb/lifecycle/lifecycle.go | 63 +++++++--------- go/libkb/lifecycle/lifecycletest/harness.go | 8 +-- go/libkb/lifecycle/lifecycletest/scenarios.go | 71 +++++++++---------- 5 files changed, 88 insertions(+), 118 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 7de3d5dba773..1d38085a88be 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -109,7 +109,7 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) { // each notification to its own goroutine, so two changes in quick // succession can reach a client in either order, and the last one it // applies may not be the latest. Update has a single writer -- - // lifecycle.Controller.applyLocked, under Controller.mu -- so the queueing + // lifecycle.Controller, under Controller.mu -- so the queueing // order is already the writing order; what is missing is one ordered // stream per connection to carry it. a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) @@ -379,9 +379,11 @@ func (a *DesktopAppState) resetLocked() { // flushLocalDbs flushes the leveldb memtables in the background. An unclean // kill while suspended (routine on iOS) with a non-empty journal forces a // journal replay — or a whole-DB recovery — during the next launch, which is -// the main cold-start cost. lifecycle calls it when the app leaves the UI and -// when background work ends, so the journals are empty if the OS kills the -// process. +// the main cold-start cost. lifecycle calls it on every move into the +// background (the UI leaving the screen, every hold's end) and on every exit +// event, so the journals are empty if the OS kills the process. Flushing that +// often, never skipping one so every write is covered, relies on +// LevelDb.Flush being a cheap memtable flush rather than a compaction. func (g *GlobalContext) flushLocalDbs() { flush := func(name string, db *JSONLocalDb) { if db == nil { diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 1600c3a7ef3f..071e347de0b6 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -65,20 +65,14 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, background, appState.State()) } -// Leaving the UI always flushes; the end of the last hold flushes unless a -// flush started less than FlushCoalesceInterval ago. +// Every change into BACKGROUNDACTIVE or BACKGROUND from anything but +// BACKGROUND flushes, and so does every exit event, whatever the state. func TestFlushRule(t *testing.T) { - const interval = 10 * time.Second appState, _ := newAppState(t) - appState.Update(background) - clock := lifecycletest.NewFakeClock() flushes := 0 - c := lifecycle.New(appState, lifecycle.Config{ - Clock: clock, - FlushCoalesceInterval: interval, - Flush: func() { flushes++ }, - }) + c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) defer c.Close() + c.UIActive() flushesAfter := func(what string, do func(), want keybase1.MobileAppState, n int) { t.Helper() before := flushes @@ -90,31 +84,22 @@ func TestFlushRule(t *testing.T) { acquire := func() { hold = c.AcquireBackgroundWork() } release := func() { require.True(t, hold.Release()) } - flushesAfter("entering BACKGROUNDACTIVE", acquire, backgroundActive, 0) - flushesAfter("the first hold's end", release, background, 1) - flushesAfter("a hold within the interval", func() { acquire(); release() }, background, 0) - clock.Advance(interval - time.Second) - flushesAfter("a hold just within the interval", func() { acquire(); release() }, background, 0) - clock.Advance(time.Second) - flushesAfter("a hold once the interval has passed", func() { acquire(); release() }, background, 1) + // The background task finds nothing to keep running and ends at once. + flushesAfter("plain backgrounding, through BACKGROUNDACTIVE", func() { lifecycletest.ToBackground(c) }, background, 2) + flushesAfter("a hold starting", acquire, backgroundActive, 0) + flushesAfter("a hold's end", release, background, 1) + flushesAfter("a hold's end right after the last flush", func() { acquire(); release() }, background, 1) + flushesAfter("termination already in the background", func() { c.WillTerminate(noop) }, background, 1) + flushesAfter("expiration already in the background", func() { c.BackgroundTaskExpired(noop) }, background, 1) flushesAfter("coming to the foreground", c.UIActive, foreground, 0) flushesAfter("going inactive", c.UIInactive, inactive, 0) - flushesAfter("leaving the UI with a hold open, within the interval", func() { - acquire() - lifecycletest.ToBackground(c) - }, backgroundActive, 1) - flushesAfter("the hold's end at once", release, background, 0) - clock.Advance(interval) - flushesAfter("a hold's end once the interval has passed", func() { acquire(); release() }, background, 1) - - c.UIActive() - clock.Advance(time.Hour) + flushesAfter("coming back from inactive", c.UIActive, foreground, 0) flushesAfter("leaving the UI while a hold keeps the app running", func() { acquire() lifecycletest.ToBackground(c) }, backgroundActive, 1) - clock.Advance(time.Hour) + flushesAfter("expiration with a hold still open", func() { c.BackgroundTaskExpired(noop) }, backgroundActive, 1) flushesAfter("that hold's end", release, background, 1) c.UIActive() flushesAfter("termination from the foreground", func() { c.WillTerminate(noop) }, background, 1) @@ -299,18 +284,16 @@ func TestBackgroundTaskHoldsWhileStayWithNothingToDeliver(t *testing.T) { // Native gives these last events only a short wait, so the state change and // any flush must happen before the slow pending-message warning. func TestExitEventsApplyBeforeNotifying(t *testing.T) { - // A task that has run past the flush coalescing interval, so its end flushes. - backgroundTask := func(c *lifecycle.Controller, clock *lifecycletest.FakeClock) { + backgroundTask := func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) - clock.Advance(lifecycle.DefaultFlushCoalesceInterval) } events := map[string]struct { - prepare func(c *lifecycle.Controller, clock *lifecycletest.FakeClock) + prepare func(c *lifecycle.Controller) do func(c *lifecycle.Controller, notifyPending func()) flushes int }{ "willTerminate": { - prepare: func(c *lifecycle.Controller, _ *lifecycletest.FakeClock) { c.UIActive() }, + prepare: func(c *lifecycle.Controller) { c.UIActive() }, do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, flushes: 1, }, @@ -329,10 +312,9 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { t.Run(name, func(t *testing.T) { appState, _ := newAppState(t) var flushes int - clock := lifecycletest.NewFakeClock() - c := lifecycle.New(appState, lifecycle.Config{Clock: clock, Flush: func() { flushes++ }}) + c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) defer c.Close() - event.prepare(c, clock) + event.prepare(c) flushesBefore := flushes notified := false event.do(c, func() { diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 2c75e60ed6d2..2f5d4f2bcef0 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -52,7 +52,6 @@ const ( DefaultBackgroundSyncWindow = 10 * time.Second DefaultBackgroundTaskPollInterval = 5 * time.Second DefaultBackgroundTaskMaxDuration = 10 * time.Minute - DefaultFlushCoalesceInterval = 10 * time.Second ) // Config holds the controller's dependencies. Zero fields get defaults: the @@ -62,12 +61,10 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // FlushCoalesceInterval is how soon after a flush the end of a hold skips - // its own; see applyLocked. - FlushCoalesceInterval time.Duration - // Flush runs when the app leaves the UI and when the last hold ends after - // a while (see applyLocked). It runs under the controller's lock, so it - // must not block. + // Flush runs on every move into the background and on every exit event + // (see applyLocked). It runs under the controller's lock, so it must not + // block, and often, so it must be cheap: a memtable flush, not a + // compaction. Flush func() Debug func(format string, args ...interface{}) } @@ -128,8 +125,6 @@ type Controller struct { // closed stops new tasks once Close is waiting for the running ones, so // nothing joins wg while Close waits on it. closed bool - // lastFlush is when the last flush started, zero before the first. - lastFlush time.Time } func New(appState AppState, cfg Config) *Controller { @@ -145,9 +140,6 @@ func New(appState AppState, cfg Config) *Controller { if cfg.BackgroundTaskMaxDuration == 0 { cfg.BackgroundTaskMaxDuration = DefaultBackgroundTaskMaxDuration } - if cfg.FlushCoalesceInterval == 0 { - cfg.FlushCoalesceInterval = DefaultFlushCoalesceInterval - } if cfg.Flush == nil { cfg.Flush = func() {} } @@ -200,35 +192,32 @@ func isUIState(state keybase1.MobileAppState) bool { } // applyLocked writes the derived state and flushes the local DBs, so an -// unclean kill doesn't cost a journal replay at the next launch: +// unclean kill doesn't cost a journal replay at the next launch. It flushes on +// every change into BACKGROUNDACTIVE or BACKGROUND from anything but +// BACKGROUND: the UI leaving the screen covers the foreground session's +// writes, and the end of every hold (BACKGROUNDACTIVE to BACKGROUND) covers +// the hold's. A hold starting (BACKGROUND to BACKGROUNDACTIVE) has written +// nothing yet. Leaving the UI flushes even when a hold then keeps the app +// running, since that hold can last for hours, and the flush starts while the +// background task still keeps the process running. // -// - Always when the UI leaves the screen (FOREGROUND/INACTIVE to -// BACKGROUNDACTIVE or BACKGROUND). That covers the whole foreground -// session's writes, even when a hold such as live location then keeps -// the app BACKGROUNDACTIVE for hours, and the flush starts while the -// background task still keeps the process running; the fire-and-forget -// flush would otherwise race the suspension that follows the task's end. -// - When the last hold ends (BACKGROUNDACTIVE to BACKGROUND), to cover the -// holds' writes, unless a flush started less than FlushCoalesceInterval -// ago. A flush is a full compaction: this keeps a backgrounding whose -// task ends at once, or a burst of pushes seconds apart, to one. +// Every write is covered only because no flush is skipped, which relies on +// Flush being cheap. func (c *Controller) applyLocked() { prev := c.appState.State() state := derive(c.ui, len(c.holds)) c.appState.Update(state) - leftUI := isUIState(prev) && !isUIState(state) - holdsEnded := prev == keybase1.MobileAppState_BACKGROUNDACTIVE && state == keybase1.MobileAppState_BACKGROUND - if !leftUI && !holdsEnded { - return - } - // Round(0) drops the monotonic reading, so time the device spends asleep - // counts toward the interval. - now := c.cfg.Clock.Now().Round(0) - if holdsEnded && !c.lastFlush.IsZero() && now.Sub(c.lastFlush) < c.cfg.FlushCoalesceInterval { - c.debugLocked("flush", "skipped, the last flush started %v ago", now.Sub(c.lastFlush)) - return + if state != prev && !isUIState(state) && prev != keybase1.MobileAppState_BACKGROUND { + c.cfg.Flush() } - c.lastFlush = now +} + +// applyExitLocked is applyLocked for an event that precedes the process being +// killed or suspended. It always flushes, even when the state doesn't change: +// writes since the last flush, such as those of a hold still open, would +// otherwise cost a journal replay at the next launch. +func (c *Controller) applyExitLocked() { + c.appState.Update(derive(c.ui, len(c.holds))) c.cfg.Flush() } @@ -384,7 +373,7 @@ func (c *Controller) WillTerminate(notifyPending func()) { c.mu.Lock() c.setUILocked(UIBackground) c.dropLocked(func(*Hold) bool { return true }) - c.applyLocked() + c.applyExitLocked() c.debugLocked("willTerminate", "ended every hold") c.mu.Unlock() notifyPending() @@ -397,7 +386,7 @@ func (c *Controller) WillTerminate(notifyPending func()) { func (c *Controller) BackgroundTaskExpired(notifyPending func()) { c.mu.Lock() ended := c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask }) - c.applyLocked() + c.applyExitLocked() c.debugLocked("backgroundTaskExpired", "ended %d background task holds", ended) c.mu.Unlock() if ended > 0 { diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 65da2f99ce62..9551074dd440 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -180,10 +180,9 @@ type Harness struct { } const ( - syncWindow = 10 * time.Second - pollInterval = 5 * time.Second - maxDuration = 10 * time.Minute - flushCoalesce = 10 * time.Second + syncWindow = 10 * time.Second + pollInterval = 5 * time.Second + maxDuration = 10 * time.Minute ) // NewHarness moves appState to the initial state and starts @@ -207,7 +206,6 @@ func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *H BackgroundSyncWindow: syncWindow, BackgroundTaskPollInterval: pollInterval, BackgroundTaskMaxDuration: maxDuration, - FlushCoalesceInterval: flushCoalesce, Flush: func() { h.flushes.Add(1) }, Debug: func(format string, args ...interface{}) { t.Logf(format, args...) }, }) diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 3c9dbd4e51ca..b6c46e643194 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -68,9 +68,8 @@ var iosToBackgroundTask = []Step{ // Scenarios replays whole native event sequences. Consumers of the app state // can play them with their own checks (see Play). // -// The fake clock moves only when a step waits on it (a sync window, a task's -// polls or maximum duration), so a hold that ends at the same time as the -// last flush doesn't flush again. +// A step that takes the app through BACKGROUNDACTIVE to BACKGROUND, such as a +// backgrounding whose task ends at once, flushes twice. var Scenarios = []Scenario{ {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, { @@ -98,7 +97,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WorkStarts, bg), step(PushWindowBegin, bg).returns(false), step(PushWindowEnd, bg).returns(false), @@ -119,7 +118,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), @@ -131,7 +130,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(DidEnterBackground, bg).returns(false), step(WillEnterForeground, ina), step(WillEnterForeground, ina), @@ -141,7 +140,7 @@ var Scenarios = []Scenario{ step(WillResignActive, ina), step(DidBecomeActive, fg), step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), @@ -170,7 +169,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), @@ -214,14 +213,14 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(BackgroundTaskDelivered, bg).flush(), - step(BackgroundTaskExpired, bg), + step(BackgroundTaskExpired, bg).flush(), }, toForeground), Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), }, { Name: "ios background task fails", Platform: IOS, - Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { @@ -234,9 +233,9 @@ var Scenarios = []Scenario{ Name: "ios background task expires", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(BackgroundTaskExpired, bg).warn(), + step(BackgroundTaskExpired, bg).flush().warn(), step(BackgroundTaskWait, bg), - step(BackgroundTaskExpired, bg), + step(BackgroundTaskExpired, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -247,7 +246,7 @@ var Scenarios = []Scenario{ step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), - step(BackgroundTaskExpired, fg), + step(BackgroundTaskExpired, fg).flush(), }), Observed: states(bg, ina, fg, ina, bga, ina, fg), }, @@ -256,7 +255,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(WillEnterForeground, ina), - step(BackgroundTaskExpired, ina), + step(BackgroundTaskExpired, ina).flush(), step(BackgroundTaskDelivered, ina), step(DidBecomeActive, fg), }), @@ -281,7 +280,7 @@ var Scenarios = []Scenario{ // A location update wakes the app while tracking. step(LiveLocationAcquire, bga), // Tracking ends. - step(LiveLocationRelease, bg), + step(LiveLocationRelease, bg).flush(), step(LiveLocationAcquire, bga), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -291,7 +290,7 @@ var Scenarios = []Scenario{ step(WorkStops, fg), step(WillResignActive, ina), step(DidEnterBackground, bga).flush().returns(false), - step(LiveLocationRelease, bg), + step(LiveLocationRelease, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), }, @@ -303,7 +302,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(DidEnterBackground, bga).returns(true), - step(BackgroundTaskFails, bg).warn(), + step(BackgroundTaskFails, bg).flush().warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -311,9 +310,9 @@ var Scenarios = []Scenario{ Name: "ios background task expiration keeps live location running", Platform: IOS, Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{ - step(BackgroundTaskExpired, bga).warn(), + step(BackgroundTaskExpired, bga).flush().warn(), step(BackgroundTaskWait, bga), - step(LiveLocationRelease, bg), + step(LiveLocationRelease, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -322,8 +321,8 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), - step(WillTerminate, bg).warn(), + step(DidEnterBackground, bg).flushes(2).returns(false), + step(WillTerminate, bg).flush().warn(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -337,9 +336,9 @@ var Scenarios = []Scenario{ Name: "ios termination during a background task", Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ - step(WillTerminate, bg).warn(), + step(WillTerminate, bg).flush().warn(), step(BackgroundTaskWait, bg), - step(BackgroundTaskExpired, bg), + step(BackgroundTaskExpired, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg), }, @@ -350,7 +349,7 @@ var Scenarios = []Scenario{ step(LiveLocationAcquire, fg), step(WillResignActive, ina), step(DidEnterBackground, bga).flush().returns(false), - step(WillTerminate, bg).warn(), + step(WillTerminate, bg).flush().warn(), step(LiveLocationRelease, bg), }), Observed: states(bg, ina, fg, ina, bga, bg), @@ -360,7 +359,7 @@ var Scenarios = []Scenario{ Name: "android process stop and start", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), }, toForeground, []Step{ step(WorkStarts, fg), step(DidEnterBackground, bga).flush().returns(true), @@ -398,9 +397,9 @@ var Scenarios = []Scenario{ Name: "android push window in the background", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).returns(false), + step(PushWindowEnd, bg).flush().returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -430,7 +429,7 @@ var Scenarios = []Scenario{ Name: "android push window racing process start", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), step(WillEnterForeground, ina), // No background task outside the background, even with work pending. @@ -440,11 +439,11 @@ var Scenarios = []Scenario{ step(WorkStops, fg), step(PushWindowBegin, fg).returns(false), step(PushWindowEnd, fg).returns(false), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), }, toForeground, []Step{ step(DidEnterBackground, bga).flush().returns(false), - step(PushWindowEnd, bg).returns(false), + step(PushWindowEnd, bg).flush().returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg), }, @@ -452,7 +451,7 @@ var Scenarios = []Scenario{ Name: "android push window hands over to a background task", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), step(PushWindowEnd, bga).returns(true), @@ -468,7 +467,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bga).flush().returns(true), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bga).returns(true), - step(BackgroundTaskFails, bg).warn(), + step(BackgroundTaskFails, bg).flush().warn(), }), Observed: states(bg, ina, fg, bga, bg), }, @@ -476,11 +475,11 @@ var Scenarios = []Scenario{ Name: "android overlapping push windows", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), step(PushWindowEnd, bga).slot(0).returns(false), - step(PushWindowEnd, bg).slot(1).returns(false), + step(PushWindowEnd, bg).slot(1).flush().returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -515,7 +514,7 @@ var Scenarios = []Scenario{ Name: "android WorkManager BackgroundSync racing a push window", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(BackgroundSyncStart, bga).returns(true), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bga).returns(false), @@ -532,7 +531,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(WillTerminate, bg).flush().warn(), step(PushWindowBegin, bga).returns(true), - step(WillTerminate, bg).warn(), + step(WillTerminate, bg).flush().warn(), step(WorkStarts, bg), step(PushWindowEnd, bg).returns(false), }), From 4b7dc6fe53f8796aac82a7ae2656e0d6a7a659db Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 22 Sep 2026 17:46:16 -0400 Subject: [PATCH 16/16] fix(lifecycle): flush when any hold ends in the background A hold ending while another stayed open left the state BACKGROUNDACTIVE, so nothing flushed: under a live location hold, hours of push windows, background tasks, syncs and location posts went unflushed. dropLocked, the one place holds end, now marks the end and the next apply flushes for it whenever the resulting state is in the background, once per event even when the end also moves BACKGROUNDACTIVE to BACKGROUND. Exit events (WillTerminate, BackgroundTaskExpired) now force a flush only when the resulting state is in the background; in FOREGROUND or INACTIVE the UI's own departure will flush. applyExitLocked is folded into applyLocked, which takes the event name and an exit flag and logs the event and transition whenever it flushes. --- go/libkb/appstate.go | 10 +-- go/libkb/lifecycle/controller_test.go | 19 +++-- go/libkb/lifecycle/lifecycle.go | 73 ++++++++++--------- go/libkb/lifecycle/lifecycletest/scenarios.go | 35 ++++----- 4 files changed, 76 insertions(+), 61 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 1d38085a88be..aec3d1ddfabf 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -379,11 +379,11 @@ func (a *DesktopAppState) resetLocked() { // flushLocalDbs flushes the leveldb memtables in the background. An unclean // kill while suspended (routine on iOS) with a non-empty journal forces a // journal replay — or a whole-DB recovery — during the next launch, which is -// the main cold-start cost. lifecycle calls it on every move into the -// background (the UI leaving the screen, every hold's end) and on every exit -// event, so the journals are empty if the OS kills the process. Flushing that -// often, never skipping one so every write is covered, relies on -// LevelDb.Flush being a cheap memtable flush rather than a compaction. +// the main cold-start cost. lifecycle calls it whenever the UI leaves the +// screen, a hold ends or an exit event arrives while the app is in the +// background, so the journals are empty if the OS kills the process. +// Flushing that often, never skipping one so every write is covered, relies +// on LevelDb.Flush being a cheap memtable flush rather than a compaction. func (g *GlobalContext) flushLocalDbs() { flush := func(name string, db *JSONLocalDb) { if db == nil { diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 071e347de0b6..4043bbd1b538 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -65,8 +65,9 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, background, appState.State()) } -// Every change into BACKGROUNDACTIVE or BACKGROUND from anything but -// BACKGROUND flushes, and so does every exit event, whatever the state. +// With the resulting state BACKGROUNDACTIVE or BACKGROUND, every change from +// anything but BACKGROUND, every hold's end and every exit event flushes, once +// per event. Nothing flushes in FOREGROUND or INACTIVE. func TestFlushRule(t *testing.T) { appState, _ := newAppState(t) flushes := 0 @@ -85,20 +86,28 @@ func TestFlushRule(t *testing.T) { release := func() { require.True(t, hold.Release()) } // The background task finds nothing to keep running and ends at once. - flushesAfter("plain backgrounding, through BACKGROUNDACTIVE", func() { lifecycletest.ToBackground(c) }, background, 2) + flushesAfter("plain backgrounding, then the task's end", func() { lifecycletest.ToBackground(c) }, background, 2) flushesAfter("a hold starting", acquire, backgroundActive, 0) flushesAfter("a hold's end", release, background, 1) flushesAfter("a hold's end right after the last flush", func() { acquire(); release() }, background, 1) flushesAfter("termination already in the background", func() { c.WillTerminate(noop) }, background, 1) flushesAfter("expiration already in the background", func() { c.BackgroundTaskExpired(noop) }, background, 1) + outer := c.AcquireBackgroundWork() + flushesAfter("a hold starting under another", acquire, backgroundActive, 0) + flushesAfter("a hold's end while another stays open", release, backgroundActive, 1) + flushesAfter("the last hold's end", func() { require.True(t, outer.Release()) }, background, 1) + flushesAfter("coming to the foreground", c.UIActive, foreground, 0) + flushesAfter("expiration in the foreground", func() { c.BackgroundTaskExpired(noop) }, foreground, 0) + flushesAfter("a hold's end in the foreground", func() { acquire(); release() }, foreground, 0) flushesAfter("going inactive", c.UIInactive, inactive, 0) + flushesAfter("expiration while inactive", func() { c.BackgroundTaskExpired(noop) }, inactive, 0) flushesAfter("coming back from inactive", c.UIActive, foreground, 0) - flushesAfter("leaving the UI while a hold keeps the app running", func() { + flushesAfter("leaving the UI while a hold keeps the app running, then the task's end", func() { acquire() lifecycletest.ToBackground(c) - }, backgroundActive, 1) + }, backgroundActive, 2) flushesAfter("expiration with a hold still open", func() { c.BackgroundTaskExpired(noop) }, backgroundActive, 1) flushesAfter("that hold's end", release, background, 1) c.UIActive() diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 2f5d4f2bcef0..cce4f73ff955 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -61,10 +61,10 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // Flush runs on every move into the background and on every exit event - // (see applyLocked). It runs under the controller's lock, so it must not - // block, and often, so it must be cheap: a memtable flush, not a - // compaction. + // Flush runs on every move into the background, and on every hold's end + // and exit event in the background (see applyLocked). It runs under the + // controller's lock, so it must not block, and often, so it must be cheap: + // a memtable flush, not a compaction. Flush func() Debug func(format string, args ...interface{}) } @@ -125,6 +125,9 @@ type Controller struct { // closed stops new tasks once Close is waiting for the running ones, so // nothing joins wg while Close waits on it. closed bool + // holdEnded records that dropLocked ended a hold since the last + // applyLocked, which flushes for it. + holdEnded bool } func New(appState AppState, cfg Config) *Controller { @@ -192,35 +195,35 @@ func isUIState(state keybase1.MobileAppState) bool { } // applyLocked writes the derived state and flushes the local DBs, so an -// unclean kill doesn't cost a journal replay at the next launch. It flushes on -// every change into BACKGROUNDACTIVE or BACKGROUND from anything but -// BACKGROUND: the UI leaving the screen covers the foreground session's -// writes, and the end of every hold (BACKGROUNDACTIVE to BACKGROUND) covers -// the hold's. A hold starting (BACKGROUND to BACKGROUNDACTIVE) has written -// nothing yet. Leaving the UI flushes even when a hold then keeps the app -// running, since that hold can last for hours, and the flush starts while the -// background task still keeps the process running. +// unclean kill doesn't cost a journal replay at the next launch. It flushes +// when the resulting state is BACKGROUNDACTIVE or BACKGROUND and one of these +// happened: the state changed from anything but BACKGROUND (the UI leaving the +// screen covers the foreground session's writes), a hold ended (covering the +// hold's writes, even while another hold keeps the app running, since a live +// location hold can last for hours), or exit is set (the process is about to +// be killed or suspended, so writes of holds still open are covered too). One +// event flushes once, whichever of these apply. A hold starting (BACKGROUND +// to BACKGROUNDACTIVE) has written nothing yet. In FOREGROUND and INACTIVE +// nothing flushes: the UI's own departure will. // // Every write is covered only because no flush is skipped, which relies on // Flush being cheap. -func (c *Controller) applyLocked() { +func (c *Controller) applyLocked(event string, exit bool) { prev := c.appState.State() state := derive(c.ui, len(c.holds)) c.appState.Update(state) - if state != prev && !isUIState(state) && prev != keybase1.MobileAppState_BACKGROUND { + holdEnded := c.holdEnded + c.holdEnded = false + if isUIState(state) { + return + } + moved := state != prev && prev != keybase1.MobileAppState_BACKGROUND + if moved || holdEnded || exit { + c.debugLocked(event, "flushing: %v -> %v, hold ended: %v, exit: %v", prev, state, holdEnded, exit) c.cfg.Flush() } } -// applyExitLocked is applyLocked for an event that precedes the process being -// killed or suspended. It always flushes, even when the state doesn't change: -// writes since the last flush, such as those of a hold still open, would -// otherwise cost a journal replay at the next launch. -func (c *Controller) applyExitLocked() { - c.appState.Update(derive(c.ui, len(c.holds))) - c.cfg.Flush() -} - func (c *Controller) acquireLocked(reason Reason) *Hold { c.nextID++ h := &Hold{c: c, id: c.nextID, reason: reason, done: make(chan struct{})} @@ -228,12 +231,14 @@ func (c *Controller) acquireLocked(reason Reason) *Hold { return h } -// dropLocked ends every hold match selects and returns how many it ended. +// dropLocked ends every hold match selects and returns how many it ended. It +// is the one place holds end; the next applyLocked flushes for them. func (c *Controller) dropLocked(match func(*Hold) bool) (dropped int) { for id, h := range c.holds { if match(h) { delete(c.holds, id) close(h.done) + c.holdEnded = true dropped++ } } @@ -288,7 +293,7 @@ func (c *Controller) AcquireBackgroundWork() *Hold { c.mu.Lock() defer c.mu.Unlock() h := c.acquireLocked(ReasonLiveLocation) - c.applyLocked() + c.applyLocked("acquire", false) c.debugLocked("acquire", "%v hold %d", h.reason, h.id) return h } @@ -299,7 +304,7 @@ func (c *Controller) release(h *Hold) bool { if c.dropLocked(func(o *Hold) bool { return o == h }) == 0 { return false } - c.applyLocked() + c.applyLocked("release", false) c.debugLocked("release", "%v hold %d", h.reason, h.id) return true } @@ -308,7 +313,7 @@ func (c *Controller) UIActive() { c.mu.Lock() defer c.mu.Unlock() c.setUILocked(UIActive) - c.applyLocked() + c.applyLocked("uiActive", false) c.debugLocked("uiActive", "applied") } @@ -319,7 +324,7 @@ func (c *Controller) UIInactive() { c.mu.Lock() defer c.mu.Unlock() c.setUILocked(UIInactive) - c.applyLocked() + c.applyLocked("uiInactive", false) c.debugLocked("uiInactive", "applied") } @@ -342,7 +347,7 @@ func (c *Controller) UIBackground(deps BackgroundTaskDeps) int64 { } c.setUILocked(UIBackground) token := c.startTaskLocked(deps) - c.applyLocked() + c.applyLocked("uiBackground", false) c.debugLocked("uiBackground", "background task hold %d", token) return token } @@ -373,7 +378,7 @@ func (c *Controller) WillTerminate(notifyPending func()) { c.mu.Lock() c.setUILocked(UIBackground) c.dropLocked(func(*Hold) bool { return true }) - c.applyExitLocked() + c.applyLocked("willTerminate", true) c.debugLocked("willTerminate", "ended every hold") c.mu.Unlock() notifyPending() @@ -386,7 +391,7 @@ func (c *Controller) WillTerminate(notifyPending func()) { func (c *Controller) BackgroundTaskExpired(notifyPending func()) { c.mu.Lock() ended := c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask }) - c.applyExitLocked() + c.applyLocked("backgroundTaskExpired", true) c.debugLocked("backgroundTaskExpired", "ended %d background task holds", ended) c.mu.Unlock() if ended > 0 { @@ -404,7 +409,7 @@ func (c *Controller) PushWindowBegin() int64 { return 0 } h := c.acquireLocked(ReasonPushWindow) - c.applyLocked() + c.applyLocked("pushWindowBegin", false) c.debugLocked("pushWindowBegin", "hold %d", h.id) return h.id } @@ -422,7 +427,7 @@ func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 { task = c.startTaskLocked(deps) } c.dropLocked(func(o *Hold) bool { return o == h }) - c.applyLocked() + c.applyLocked("pushWindowEnd", false) } c.debugLocked("pushWindowEnd", "hold %d ended, background task hold %d", token, task) return task @@ -439,7 +444,7 @@ func (c *Controller) BackgroundSync() string { return msg } h := c.acquireLocked(ReasonBackgroundSync) - c.applyLocked() + c.applyLocked("backgroundSyncBegin", false) c.debugLocked("backgroundSyncBegin", "hold %d", h.id) c.mu.Unlock() var msg string diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index b6c46e643194..11172ff03af7 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -68,7 +68,8 @@ var iosToBackgroundTask = []Step{ // Scenarios replays whole native event sequences. Consumers of the app state // can play them with their own checks (see Play). // -// A step that takes the app through BACKGROUNDACTIVE to BACKGROUND, such as a +// Each move into the background from the UI and each hold's end in the +// background flushes, so a step that does two of these, such as a // backgrounding whose task ends at once, flushes twice. var Scenarios = []Scenario{ {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, @@ -246,7 +247,7 @@ var Scenarios = []Scenario{ step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), - step(BackgroundTaskExpired, fg).flush(), + step(BackgroundTaskExpired, fg), }), Observed: states(bg, ina, fg, ina, bga, ina, fg), }, @@ -255,7 +256,7 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(WillEnterForeground, ina), - step(BackgroundTaskExpired, ina).flush(), + step(BackgroundTaskExpired, ina), step(BackgroundTaskDelivered, ina), step(DidBecomeActive, fg), }), @@ -289,7 +290,7 @@ var Scenarios = []Scenario{ step(LiveLocationAcquire, fg), step(WorkStops, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).flush().returns(false), + step(DidEnterBackground, bga).flushes(2).returns(false), step(LiveLocationRelease, bg).flush(), }), Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), @@ -348,7 +349,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(LiveLocationAcquire, fg), step(WillResignActive, ina), - step(DidEnterBackground, bga).flush().returns(false), + step(DidEnterBackground, bga).flushes(2).returns(false), step(WillTerminate, bg).flush().warn(), step(LiveLocationRelease, bg), }), @@ -399,7 +400,7 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), + step(PushWindowEnd, bg).flushes(2).returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -409,7 +410,7 @@ var Scenarios = []Scenario{ Platform: Android, Steps: []Step{ step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), + step(PushWindowEnd, bg).flushes(2).returns(false), }, Observed: states(bg, bga, bg), }, @@ -419,7 +420,7 @@ var Scenarios = []Scenario{ Steps: []Step{ step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), - step(PushWindowEnd, bga).returns(true), + step(PushWindowEnd, bga).flush().returns(true), step(BackgroundTaskDelivered, bg).flush(), }, Observed: states(bg, bga, bg), @@ -442,8 +443,8 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), }, toForeground, []Step{ - step(DidEnterBackground, bga).flush().returns(false), - step(PushWindowEnd, bg).flush().returns(false), + step(DidEnterBackground, bga).flushes(2).returns(false), + step(PushWindowEnd, bg).flushes(2).returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg), }, @@ -454,7 +455,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), - step(PushWindowEnd, bga).returns(true), + step(PushWindowEnd, bga).flush().returns(true), step(BackgroundTaskDelivered, bg).flush(), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), @@ -466,7 +467,7 @@ var Scenarios = []Scenario{ step(WorkStarts, fg), step(DidEnterBackground, bga).flush().returns(true), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bga).returns(true), + step(PushWindowEnd, bga).flush().returns(true), step(BackgroundTaskFails, bg).flush().warn(), }), Observed: states(bg, ina, fg, bga, bg), @@ -478,8 +479,8 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), - step(PushWindowEnd, bga).slot(0).returns(false), - step(PushWindowEnd, bg).slot(1).flush().returns(false), + step(PushWindowEnd, bga).slot(0).flushes(2).returns(false), + step(PushWindowEnd, bg).slot(1).flushes(2).returns(false), }), Observed: states(bg, ina, fg, bga, bg, bga, bg), }, @@ -490,7 +491,7 @@ var Scenarios = []Scenario{ Platform: Android, Steps: []Step{ step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), + step(PushWindowEnd, bg).flushes(2).returns(false), step(BackgroundSyncStart, bga).returns(true), step(BackgroundSyncTimerFires, bg).flush(), }, @@ -501,7 +502,7 @@ var Scenarios = []Scenario{ Platform: Android, Steps: []Step{ step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), + step(PushWindowEnd, bg).flushes(2).returns(false), step(BackgroundSyncStart, bga).returns(true), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -517,7 +518,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bg).flushes(2).returns(false), step(BackgroundSyncStart, bga).returns(true), step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bga).returns(false), + step(PushWindowEnd, bga).flushes(2).returns(false), step(BackgroundSyncTimerFires, bg).flush(), }), Observed: states(bg, ina, fg, bga, bg, bga, bg),