diff --git a/go/avatars/appstate.go b/go/avatars/appstate.go new file mode 100644 index 000000000000..39e15f0d4827 --- /dev/null +++ b/go/avatars/appstate.go @@ -0,0 +1,64 @@ +package avatars + +import ( + "sync" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" +) + +// backgroundFlusher runs flush each time the app enters BACKGROUND, from +// start until stop. +type backgroundFlusher struct { + mu sync.Mutex + stopCh chan struct{} + doneCh chan struct{} + // flushes counts flushes; tests use it. + flushes int +} + +func (f *backgroundFlusher) start(m libkb.MetaContext, flush func(libkb.MetaContext)) { + f.mu.Lock() + defer f.mu.Unlock() + if f.stopCh != nil { + return + } + f.stopCh = make(chan struct{}) + f.doneCh = make(chan struct{}) + stopCh, doneCh := f.stopCh, f.doneCh + // Armed here, not in the goroutine, so a change made before the goroutine + // first runs still wakes it. + state := m.G().MobileAppState.State() + changed := m.G().MobileAppState.NextUpdate(state) + go func() { + defer close(doneCh) + for { + select { + case <-changed: + case <-stopCh: + return + } + state = m.G().MobileAppState.State() + changed = m.G().MobileAppState.NextUpdate(state) + if state == keybase1.MobileAppState_BACKGROUND { + flush(m) + f.mu.Lock() + f.flushes++ + f.mu.Unlock() + } + } + }() +} + +// stop ends the watcher goroutine and waits for it to exit. +func (f *backgroundFlusher) stop() { + f.mu.Lock() + stopCh, doneCh := f.stopCh, f.doneCh + f.stopCh, f.doneCh = nil, nil + f.mu.Unlock() + if stopCh == nil { + return + } + close(stopCh) + <-doneCh +} diff --git a/go/avatars/appstate_test.go b/go/avatars/appstate_test.go new file mode 100644 index 000000000000..5b03630d6478 --- /dev/null +++ b/go/avatars/appstate_test.go @@ -0,0 +1,121 @@ +package avatars + +import ( + "runtime" + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// waitFlushes waits until f has flushed at least want times. +func waitFlushes(t *testing.T, f *backgroundFlusher, want int) { + t.Helper() + require.Eventually(t, func() bool { + return flushes(f) >= want + }, 10*time.Second, time.Millisecond, "did not reach %d flushes", want) +} + +func flushes(f *backgroundFlusher) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type bgSource interface { + libkb.AvatarLoaderSource + flusher() *backgroundFlusher +} + +func (c *FullCachingSource) flusher() *backgroundFlusher { return &c.bgFlusher } +func (c *URLCachingSource) flusher() *backgroundFlusher { return &c.bgFlusher } + +func forEachSource(t *testing.T, f func(t *testing.T, tc libkb.TestContext, s bgSource)) { + sources := map[string]func(t *testing.T, g *libkb.GlobalContext) bgSource{ + "full": func(t *testing.T, g *libkb.GlobalContext) bgSource { + s := NewFullCachingSource(g, time.Hour, 10) + s.tempDir = t.TempDir() + return s + }, + "url": func(_ *testing.T, _ *libkb.GlobalContext) bgSource { + return NewURLCachingSource(time.Hour, 10) + }, + } + for name, mk := range sources { + t.Run(name, func(t *testing.T) { + tc := libkb.SetupTest(t, "avatars", 1) + defer tc.Cleanup() + f(t, tc, mk(t, tc.G)) + }) + } +} + +func TestAvatarsFlushSeedsFromState(t *testing.T) { + forEachSource(t, func(t *testing.T, tc libkb.TestContext, s bgSource) { + m := libkb.NewMetaContextForTest(tc) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + s.StartBackgroundTasks(m) + defer s.StopBackgroundTasks(m) + + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + } { + tc.G.MobileAppState.Update(next) + } + waitFlushes(t, s.flusher(), 1) + require.Equal(t, 1, flushes(s.flusher()), + "flushed on start already being in BACKGROUND, or flushed more than once for one transition into it") + }) +} + +func TestAvatarsMonitorExitsOnStop(t *testing.T) { + forEachSource(t, func(t *testing.T, tc libkb.TestContext, s bgSource) { + m := libkb.NewMetaContextForTest(tc) + // Warm up lazily started goroutines before taking the baseline. + s.StartBackgroundTasks(m) + s.StopBackgroundTasks(m) + baseline := runtime.NumGoroutine() + + const cycles = 50 + for range cycles { + s.StartBackgroundTasks(m) + s.StopBackgroundTasks(m) + } + require.Eventually(t, func() bool { + return runtime.NumGoroutine() < baseline+cycles/2 + }, 10*time.Second, 10*time.Millisecond, "goroutines leaked across Start/Stop") + }) +} + +// Start/Stop racing app-state changes neither deadlocks nor leaks. +func TestAvatarsMonitorStress(t *testing.T) { + forEachSource(t, func(t *testing.T, tc libkb.TestContext, s bgSource) { + m := libkb.NewMetaContextForTest(tc) + baseline := runtime.NumGoroutine() + done := make(chan struct{}) + go func() { + defer close(done) + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } + for i := range 400 { + tc.G.MobileAppState.Update(states[i%len(states)]) + } + }() + for range 100 { + s.StartBackgroundTasks(m) + s.StopBackgroundTasks(m) + } + <-done + require.Eventually(t, func() bool { + return runtime.NumGoroutine() < baseline+10 + }, 10*time.Second, 10*time.Millisecond, "goroutines leaked") + }) +} diff --git a/go/avatars/fullcaching.go b/go/avatars/fullcaching.go index e934f896d2d0..cea758e3abfa 100644 --- a/go/avatars/fullcaching.go +++ b/go/avatars/fullcaching.go @@ -91,6 +91,7 @@ type FullCachingSource struct { started bool diskLRU *lru.DiskLRU diskLRUCleanerCancel context.CancelFunc + bgFlusher backgroundFlusher staleThreshold time.Duration simpleSource libkb.AvatarLoaderSource @@ -212,10 +213,15 @@ func (c *FullCachingSource) StartBackgroundTasks(mctx libkb.MetaContext) { return } c.started = true - go c.monitorAppState(mctx) + c.bgFlusher.start(mctx, func(m libkb.MetaContext) { + c.debug(m, "backgroundFlusher: flushing diskLRU") + if err := c.diskLRU.Flush(m.Ctx(), m.G()); err != nil { + c.debug(m, "backgroundFlusher: unable to flush diskLRU %v", err) + } + }) c.populateCacheCh = make(chan populateArg, 100) for range 10 { - go c.populateCacheWorker(mctx) + go c.populateCacheWorker(mctx, c.populateCacheCh) } mctx, cancel := mctx.WithContextCancel() c.diskLRUCleanerCancel = cancel @@ -230,6 +236,7 @@ func (c *FullCachingSource) StopBackgroundTasks(mctx libkb.MetaContext) { return } c.started = false + c.bgFlusher.stop() close(c.populateCacheCh) if c.diskLRUCleanerCancel != nil { c.diskLRUCleanerCancel() @@ -251,21 +258,6 @@ func (c *FullCachingSource) isStale(m libkb.MetaContext, item lru.DiskLRUEntry) return m.G().GetClock().Now().Sub(item.Ctime) > c.staleThreshold } -func (c *FullCachingSource) monitorAppState(m libkb.MetaContext) { - c.debug(m, "monitorAppState: starting up") - state := keybase1.MobileAppState_FOREGROUND - for { - <-m.G().MobileAppState.NextUpdate(state) - state = m.G().MobileAppState.State() - if state == keybase1.MobileAppState_BACKGROUND { - c.debug(m, "monitorAppState: backgrounded") - if err := c.diskLRU.Flush(m.Ctx(), m.G()); err != nil { - c.debug(m, "monitorAppState: unable to flush diskLRU %v", err) - } - } - } -} - func (c *FullCachingSource) processLRUHit(entry lru.DiskLRUEntry) (res lruEntry) { var ok bool if _, ok = entry.Value.(map[string]any); ok { @@ -392,8 +384,8 @@ func (c *FullCachingSource) removeFile(m libkb.MetaContext, ent *lru.DiskLRUEntr } } -func (c *FullCachingSource) populateCacheWorker(m libkb.MetaContext) { - for arg := range c.populateCacheCh { +func (c *FullCachingSource) populateCacheWorker(m libkb.MetaContext, populateCacheCh <-chan populateArg) { + for arg := range populateCacheCh { err := c.populateCacheJob(m, arg) if err != nil { c.debug(m, "populateCacheWorker: %s", err) diff --git a/go/avatars/urlcaching.go b/go/avatars/urlcaching.go index 96b543848302..0adf0b7accc9 100644 --- a/go/avatars/urlcaching.go +++ b/go/avatars/urlcaching.go @@ -14,6 +14,7 @@ type URLCachingSource struct { diskLRU *lru.DiskLRU staleThreshold time.Duration simpleSource *SimpleSource + bgFlusher backgroundFlusher // testing only staleFetchCh chan struct{} @@ -30,10 +31,14 @@ func NewURLCachingSource(staleThreshold time.Duration, size int) *URLCachingSour } func (c *URLCachingSource) StartBackgroundTasks(m libkb.MetaContext) { - go c.monitorAppState(m) + c.bgFlusher.start(m, func(m libkb.MetaContext) { + c.debug(m, "backgroundFlusher: flushing diskLRU") + c.diskLRU.Flush(m.Ctx(), m.G()) + }) } func (c *URLCachingSource) StopBackgroundTasks(m libkb.MetaContext) { + c.bgFlusher.stop() c.diskLRU.Flush(m.Ctx(), m.G()) } @@ -49,19 +54,6 @@ func (c *URLCachingSource) isStale(m libkb.MetaContext, item lru.DiskLRUEntry) b return m.G().GetClock().Now().Sub(item.Ctime) > c.staleThreshold } -func (c *URLCachingSource) monitorAppState(m libkb.MetaContext) { - c.debug(m, "monitorAppState: starting up") - state := keybase1.MobileAppState_FOREGROUND - for { - <-m.G().MobileAppState.NextUpdate(state) - state = m.G().MobileAppState.State() - if state == keybase1.MobileAppState_BACKGROUND { - c.debug(m, "monitorAppState: backgrounded") - c.diskLRU.Flush(m.Ctx(), m.G()) - } - } -} - func (c *URLCachingSource) specLoad(m libkb.MetaContext, names []string, formats []keybase1.AvatarFormat) (res avatarLoadSpec, err error) { for _, name := range names { for _, format := range formats { diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 4aa2ea2827de..d6680cb7629e 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" @@ -186,6 +186,15 @@ type ShareIntentDonator interface { DeleteDonation(conversationID string) } +// NativeLocationWatcher is implemented by the native iOS layer. It runs the OS +// location service while live location is on and reports each fix through +// LocationUpdate, so live location works without JS. When nil (Android, +// desktop), the chat UI watches position instead. +type NativeLocationWatcher interface { + StartWatching() + StopWatching() +} + // shareIntentDonatorAdapter adapts keybase.ShareIntentDonator to types.ShareIntentDonator. type shareIntentDonatorAdapter struct { wrapped ShareIntentDonator @@ -325,10 +334,10 @@ func setInited() { func InitOnce(homeDir, mobileSharedHome, logFile, runModeStr string, accessGroupOverride bool, dnsNSFetcher ExternalDNSNSFetcher, nvh NativeVideoHelper, mobileOsVersion string, isIPad bool, installReferrerListener NativeInstallReferrerListener, isIOS bool, - shareIntentDonator ShareIntentDonator, + shareIntentDonator ShareIntentDonator, locationWatcher NativeLocationWatcher, ) { startOnce.Do(func() { - if err := Init(homeDir, mobileSharedHome, logFile, runModeStr, accessGroupOverride, dnsNSFetcher, nvh, mobileOsVersion, isIPad, installReferrerListener, isIOS, shareIntentDonator); err != nil { + if err := Init(homeDir, mobileSharedHome, logFile, runModeStr, accessGroupOverride, dnsNSFetcher, nvh, mobileOsVersion, isIPad, installReferrerListener, isIOS, shareIntentDonator, locationWatcher); err != nil { log("Init error: %s", err) } }) @@ -338,7 +347,7 @@ func InitOnce(homeDir, mobileSharedHome, logFile, runModeStr string, func Init(homeDir, mobileSharedHome, logFile, runModeStr string, accessGroupOverride bool, externalDNSNSFetcher ExternalDNSNSFetcher, nvh NativeVideoHelper, mobileOsVersion string, isIPad bool, installReferrerListener NativeInstallReferrerListener, isIOS bool, - shareIntentDonator ShareIntentDonator, + shareIntentDonator ShareIntentDonator, locationWatcher NativeLocationWatcher, ) (err error) { // Dump all goroutines on a fatal error; the GOTRACEBACK env var can't be // used here since the runtime reads it before Init runs. @@ -432,9 +441,11 @@ func Init(homeDir, mobileSharedHome, logFile, runModeStr string, kbSvc = service.NewService(kbCtx, false) // LoginAttemptNone: the login attempt happens inside RunBackgroundOperations // below, off the Init path. It can block for seconds (leveldb - // open/recovery, keychain reads) and Init runs on the native main thread; - // GetBootstrapStatus waits for the attempt so the GUI doesn't see a stale - // logged-out state. + // open/recovery, keychain reads) and Init runs on the native main thread. + // The loopback listener is therefore up while the attempt is still running, + // so a client can connect and subscribe before there is any session to + // report: its first clientState has no session at all in that window, and + // the attempt settling sends another that does. phase := time.Now() if err = kbSvc.StartLoopbackServer(libkb.LoginAttemptNone); err != nil { log("failed to start loopback: %s", err) @@ -460,6 +471,7 @@ func Init(homeDir, mobileSharedHome, logFile, runModeStr string, if shareIntentDonator != nil { kbChatCtx.ShareIntentDonator = shareIntentDonatorAdapter{wrapped: shareIntentDonator} } + kbChatCtx.LocationWatcher = locationWatcher // Runs the startup login attempt and then the long-lived background // tasks. Off the Init thread so a slow login can't hold up app launch; // must start after the chat context fields above are set since chat @@ -737,7 +749,7 @@ func ensureConnection() error { // Reset unconditionally resets the socket connection. Use this only when the // caller genuinely means "tear down whatever connection is current" (e.g. -// iOS invalidate, Android destroy/engineReset) — it will happily close a +// iOS invalidate, Android destroy) — it will happily close a // connection some concurrent failure-driven caller never saw fail. Callers // reacting to a failure on a specific connection should use ResetIfCurrent // instead so a stale complaint can't clobber a connection that has already @@ -875,65 +887,61 @@ 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() + defer kbCtx.Trace("AppUIInactive", nil)() + kbCtx.MobileLifecycle.UIInactive() } -// 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 { +// LocationUpdate reports every location fix from the native location service; +// the tracker decides which ones to record. +func LocationUpdate(lat, lon float64, accuracy int) { + if !isInited() || !kbCtx.ActiveDevice.HaveKeys() { 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) + locationUpdate(kbChatCtx.LiveLocationTracker, lat, lon, accuracy) } -func SetAppStateInactive() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateInactive", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) +func locationUpdate(tracker types.LiveLocationTracker, lat, lon float64, accuracy int) { + tracker.NativeLocationUpdate(context.Background(), chat1.Coordinate{Lat: lat, Lon: lon, Accuracy: float64(accuracy)}) } -func SetAppStateBackgroundActive() { +// DeliverPushTap resolves a tapped notification's payload to the route it opens +// and parks it for the client to take. +// +// The one door a tap comes through, and the only thing anywhere that may name +// an account to switch to. Native calls it from its notification-tap handler +// and nowhere else -- on iOS UNUserNotificationCenter's didReceive, on Android +// the unexported PushTapActivity -- so a URL another app, a web page or a +// universal link opens cannot reach it, and cannot switch accounts. A silent or +// background push does not come through here at all: those are +// HandleBackgroundNotification, which never routes. +func DeliverPushTap(payloadJSON string) { if !isInited() { + log("DeliverPushTap: dropping a tap taken before Init") + return + } + ctx := context.Background() + route, ok := libkb.ResolvePushTap(payloadJSON) + if !ok { + kbCtx.Log.CDebugf(ctx, "DeliverPushTap: a tap with nothing to open") return } - defer kbCtx.Trace("SetAppStateBackgroundActive", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + kbCtx.Log.CDebugf(ctx, "DeliverPushTap: %s (for another account: %v)", route.Url, route.TargetUID != "") + kbCtx.PendingPushTap.Set(ctx, route) } func waitForInit(maxDur time.Duration) error { @@ -962,39 +970,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 +996,108 @@ 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) { +// inPushWindow runs work, which handles a push or a notification action. On +// Android it holds a backgrounded app up while work runs, and work learns +// whether the UI is active, in which case nothing is held. pusher warns about +// messages that won't send if the window hands over to a background task. +func inPushWindow(pusher PushNotifier, work func(uiActive bool) error) error { if !isInited() { - return + return work(false) } - 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") + return runPushWindow(kbCtx.MobileLifecycle, runtime.GOOS, backgroundTaskDeps(pusher), work) +} + +func runPushWindow(lc *lifecycle.Controller, goos string, deps lifecycle.BackgroundTaskDeps, + work func(uiActive bool) error, +) error { + if goos != "android" { + // iOS handles a push within the time it grants for it and suspends the + // app at the completion handler, so nothing needs holding up; a window + // would only take the app through BACKGROUNDACTIVE and back. work only + // uses uiActive on Android. + return work(false) + } + token := lc.PushWindowBegin() + if token == 0 { + return work(true) + } + defer lc.PushWindowEnd(token, deps) + return work(false) +} + +// 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 } - 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("AppWaitBackgroundTask", nil)() + kbCtx.MobileLifecycle.WaitBackgroundTask(token) +} + +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/bind/keybase_test.go b/go/bind/keybase_test.go index 19be3b5cd5aa..5d3c0f27d878 100644 --- a/go/bind/keybase_test.go +++ b/go/bind/keybase_test.go @@ -273,7 +273,7 @@ func TestResetIfCurrent_DoubleResetSameEpochIsHarmless(t *testing.T) { } // Test 4: Reset is the unconditional escape hatch used by invalidate/ -// destroy/engineReset. It must close whatever connection is current +// destroy. It must close whatever connection is current // regardless of any epoch bookkeeping. func TestReset_UnconditionallyClosesCurrentConnection(t *testing.T) { resetConnStateForTest(t) @@ -528,7 +528,7 @@ func TestConcurrentReadWriteAndResetsThroughRealEntryPoints(t *testing.T) { }) } - // Unconditional resetters: e.g. concurrent invalidate/engineReset. + // Unconditional resetters: e.g. concurrent invalidate/destroy. for range resetters { wg.Go(func() { for range iterations { diff --git a/go/bind/location_test.go b/go/bind/location_test.go new file mode 100644 index 000000000000..a1c1f600b2f8 --- /dev/null +++ b/go/bind/location_test.go @@ -0,0 +1,162 @@ +package keybase + +import ( + "context" + "encoding/base64" + "fmt" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/maps" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/kbtest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/clockwork" + "github.com/stretchr/testify/require" +) + +type countingLocationWatcher struct{ starts, stops chan struct{} } + +func (w countingLocationWatcher) StartWatching() { w.starts <- struct{}{} } +func (w countingLocationWatcher) StopWatching() { w.stops <- struct{}{} } + +type nilCtxFactory struct{} + +func (nilCtxFactory) NewKeyFinder() types.KeyFinder { return nil } +func (nilCtxFactory) NewUPAKFinder() types.UPAKFinder { return nil } + +func TestLocationUpdateReachesTrackers(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LocationUpdateReachesTrackers", 0) + defer tc.Cleanup() + tc.G.ChatHelper = kbtest.NewMockChatHelper() + tc.G.SetUIRouter(kbtest.NewMockUIRouter(nil)) + watcher := countingLocationWatcher{starts: make(chan struct{}, 10), stops: make(chan struct{}, 10)} + var nativeWatcher NativeLocationWatcher = watcher + g := globals.NewContext(tc.G, &globals.ChatContext{CtxFactory: nilCtxFactory{}, LocationWatcher: nativeWatcher}) + tracker := maps.NewLiveLocationTracker(g) + clock := clockwork.NewFakeClock() + tracker.SetClock(clock) + ctx := context.Background() + lifecycletest.ToBackground(tc.G.MobileLifecycle) + + startTracking := func(msgID chat1.MessageID) types.LiveLocationKey { + tracker.StartTracking(ctx, chat1.ConversationID("conv"), msgID, clock.Now().Add(time.Hour)) + select { + case <-watcher.starts: + case <-time.After(10 * time.Second): + require.Fail(t, "native watch never started") + } + return types.LiveLocationKey(base64.StdEncoding.EncodeToString( + fmt.Appendf(nil, "%s:%d", chat1.ConversationID("conv"), msgID))) + } + stopTracking := func() { + tracker.StopAllTracking(ctx) + select { + case <-tracker.Stop(ctx): + case <-time.After(10 * time.Second): + require.Fail(t, "tracker did not stop") + } + select { + case <-watcher.stops: + default: + require.Fail(t, "native watch never stopped") + } + require.Equal(t, keybase1.MobileAppState_BACKGROUND, tc.G.MobileAppState.State()) + } + fix := func(lat float64) chat1.Coordinate { return chat1.Coordinate{Lat: lat, Lon: -73.25, Accuracy: 12} } + // waitRecorded waits for the tracker at key to take the fix at lat, and + // returns its coordinates. The tracker also takes the last coordinate it + // has when it starts, which can repeat the first fix; repeats are dropped. + waitRecorded := func(key types.LiveLocationKey, lat float64) (res []chat1.Coordinate) { + require.Eventually(t, func() bool { + coords := tracker.GetCoordinates(ctx, key) + return coords[len(coords)-1] == fix(lat) + }, 10*time.Second, time.Millisecond, "coordinate never reached the tracker") + for _, c := range tracker.GetCoordinates(ctx, key) { + if len(res) == 0 || res[len(res)-1] != c { + res = append(res, c) + } + } + return res + } + + key := startTracking(1) + // The first fix is recorded even in the background. + locationUpdate(tracker, 40.5, -73.25, 12) + require.Equal(t, []chat1.Coordinate{fix(40.5)}, waitRecorded(key, 40.5)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, tc.G.MobileAppState.State()) + // About 11m north, too short a move to record in the background, then + // about 111m further. The coordinates arrive in order, so once the last one + // is in, the short move would be too. + locationUpdate(tracker, 40.5001, -73.25, 12) + locationUpdate(tracker, 40.5011, -73.25, 12) + require.Equal(t, []chat1.Coordinate{fix(40.5), fix(40.5011)}, waitRecorded(key, 40.5011)) + stopTracking() + + // A new watch records its first fix however short the move. + key = startTracking(2) + locationUpdate(tracker, 40.5012, -73.25, 12) + waitRecorded(key, 40.5012) + stopTracking() +} + +type recordingLiveLocationTracker struct { + types.LiveLocationTracker + sync.Mutex + coords []chat1.Coordinate +} + +func (r *recordingLiveLocationTracker) NativeLocationUpdate(_ context.Context, coord chat1.Coordinate) { + r.Lock() + defer r.Unlock() + r.coords = append(r.coords, coord) +} + +func (r *recordingLiveLocationTracker) Coords() []chat1.Coordinate { + r.Lock() + defer r.Unlock() + return append([]chat1.Coordinate(nil), r.coords...) +} + +func TestLocationUpdateGuards(t *testing.T) { + resetConnStateForTest(t) + savedChatCtx := kbChatCtx + t.Cleanup(func() { kbChatCtx = savedChatCtx }) + setInitComplete := func(v bool) { + initMutex.Lock() + defer initMutex.Unlock() + initComplete = v + } + + tc := libkb.SetupTest(t, "LocationUpdateGuards", 0) + defer tc.Cleanup() + tracker := &recordingLiveLocationTracker{} + kbCtx = tc.G + kbChatCtx = &globals.ChatContext{LiveLocationTracker: tracker} + + setInitComplete(true) + LocationUpdate(1, 2, 3) + require.Empty(t, tracker.Coords(), "dropped while logged out") + + sigKey, err := libkb.GenerateNaclSigningKeyPair() + require.NoError(t, err) + encKey, err := libkb.GenerateNaclDHKeyPair() + require.NoError(t, err) + uv := keybase1.UserVersion{Uid: keybase1.MakeTestUID(1), EldestSeqno: 1} + require.NoError(t, tc.G.ActiveDevice.Set(libkb.NewMetaContextForTest(tc), uv, keybase1.DeviceID("dev"), + sigKey, encKey, "testuser-device", 0, libkb.KeychainModeNone)) + + setInitComplete(false) + LocationUpdate(1, 2, 3) + require.Empty(t, tracker.Coords(), "dropped before Init completes") + + setInitComplete(true) + LocationUpdate(1, 2, 3) + require.Equal(t, []chat1.Coordinate{{Lat: 1, Lon: 2, Accuracy: 3}}, tracker.Coords()) +} diff --git a/go/bind/notifications.go b/go/bind/notifications.go index f3ca13136805..93eef9f7e9ec 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -114,47 +114,63 @@ type ChatNotification struct { Uid string } -func HandlePostTextReply(strConvID, tlfName string, intMessageID int, body string) (err error) { +// HandlePostTextReply sends a notification quick reply, in the foreground too. +// pusher warns about the reply if it won't send. +func HandlePostTextReply(strConvID, tlfName string, intMessageID int, body string, pusher PushNotifier) (err error) { ctx := context.Background() defer kbCtx.CTrace(ctx, "HandlePostTextReply", &err)() defer func() { err = flattenError(err) }() - outboxID, err := storage.NewOutboxID() + return inPushWindow(pusher, func(bool) error { + return postTextReply(ctx, globals.NewContext(kbCtx, kbChatCtx), strConvID, tlfName, intMessageID, body) + }) +} + +// postTextReply sends a notification quick reply and marks the conversation +// read. The send is nonblocking: an error means the message couldn't be +// queued, not that delivery failed. +func postTextReply(ctx context.Context, gc *globals.Context, strConvID, tlfName string, intMessageID int, + body string, +) error { + convID, err := chat1.MakeConvID(strConvID) if err != nil { return err } - convID, err := chat1.MakeConvID(strConvID) + if intMessageID < 0 { + return fmt.Errorf("invalid message ID: %d", intMessageID) + } + uid, err := utils.AssertLoggedInUID(ctx, gc) if err != nil { return err } - _, err = kbCtx.ChatHelper.SendTextByIDNonblock(context.Background(), convID, tlfName, body, &outboxID, nil) - - kbCtx.Log.CDebugf(ctx, "Marking as read from QuickReply: convID: %s", strConvID) - gc := globals.NewContext(kbCtx, kbChatCtx) - uid, err := utils.AssertLoggedInUID(ctx, gc) + outboxID, err := storage.NewOutboxID() if err != nil { return err } - - if intMessageID < 0 { - return fmt.Errorf("invalid message ID: %d", intMessageID) + if _, err := gc.ChatHelper.SendTextByIDNonblock(ctx, convID, tlfName, body, &outboxID, nil); err != nil { + return err } + gc.Log.CDebugf(ctx, "Marking as read from QuickReply: convID: %s", strConvID) msgID := chat1.MessageID(intMessageID) - if err = kbChatCtx.InboxSource.MarkAsRead(context.Background(), convID, uid, &msgID, false /* forceUnread */); err != nil { - kbCtx.Log.CDebugf(ctx, "Failed to mark as read from QuickReply: convID: %s. Err: %s", strConvID, err) - // We don't want to fail this method call just because we couldn't mark it as aread - err = nil + if err := gc.InboxSource.MarkAsRead(ctx, convID, uid, &msgID, false /* forceUnread */); err != nil { + // The reply went out; failing to mark it read doesn't fail the reply. + gc.Log.CDebugf(ctx, "Failed to mark as read from QuickReply: convID: %s. Err: %s", strConvID, err) } - return nil } var spoileRegexp = regexp.MustCompile(`!>(.*?) 0 || len(chatNotification.Message.ServerMessage) > 0) { - // Lock and check if we've already processed this notification. - seenNotificationsMtx.Lock() - defer seenNotificationsMtx.Unlock() - if _, ok := getSeenNotificationsCache().Get(dupKey); ok { - // Cancel any duplicate visible notifications + ackPush := func() { if ack != nil { ack.Ack(ctx, []string{pushID}) } - kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: duplicate notification convID=%s msgID=%d", strConvID, intMessageID) - // Return nil (not an error) so Android does not treat this as failure and show a fallback notification. - return nil } - // Add to cache before displaying so that any concurrent goroutine that - // reaches the second check while DisplayChatNotification is running will - // see the entry and bail out rather than displaying a duplicate. - getSeenNotificationsCache().Add(dupKey, struct{}{}) - pusher.DisplayChatNotification(&chatNotification) - if ack != nil { - ack.Ack(ctx, []string{pushID}) + if displayOnce(dupKey, &chatNotification, pusher, runtime.GOOS, uiActive, ackPush) { + kbCtx.Log.CDebugf(ctx, "HandleBackgroundNotification: duplicate notification convID=%s msgID=%d", strConvID, intMessageID) } } return nil } + +// displayOnce displays n unless its push was already handled, then acks the +// push. On Android, while the UI is active it only acks: the app already shows +// the message. iOS always displays, because its display also removes the +// server's generic notification for this message, which can land while the +// push is being handled; a local notification never shows while active. +func displayOnce(dupKey string, n *ChatNotification, pusher PushNotifier, goos string, uiActive bool, + ack func(), +) (dup bool) { + seenNotificationsMtx.Lock() + defer seenNotificationsMtx.Unlock() + if _, ok := getSeenNotificationsCache().Get(dupKey); ok { + // Cancel any duplicate visible notifications + ack() + return true + } + // Add to cache before displaying so that any concurrent goroutine that + // reaches the check while DisplayChatNotification is running sees the + // entry and bails out rather than displaying a duplicate. + getSeenNotificationsCache().Add(dupKey, struct{}{}) + if !uiActive || goos != "android" { + pusher.DisplayChatNotification(n) + } + ack() + return false +} diff --git a/go/bind/notifications_test.go b/go/bind/notifications_test.go new file mode 100644 index 000000000000..dea2bf766df5 --- /dev/null +++ b/go/bind/notifications_test.go @@ -0,0 +1,211 @@ +package keybase + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/types" + "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/gregor1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +type replyChatHelper struct { + libkb.ChatHelper + sendErr error + sent []string +} + +func (h *replyChatHelper) SendTextByIDNonblock(_ context.Context, _ chat1.ConversationID, _ string, text string, + _ *chat1.OutboxID, _ *chat1.MessageID, +) (chat1.OutboxID, error) { + if h.sendErr != nil { + return nil, h.sendErr + } + h.sent = append(h.sent, text) + return nil, nil +} + +type replyInboxSource struct { + types.InboxSource + markErr error + marked []chat1.MessageID +} + +func (s *replyInboxSource) MarkAsRead(_ context.Context, _ chat1.ConversationID, _ gregor1.UID, + msgID *chat1.MessageID, _ bool, +) error { + s.marked = append(s.marked, *msgID) + return s.markErr +} + +func TestPostTextReply(t *testing.T) { + const convID = "0000bbbbccccddddeeeeffff0000aaaabbbbccccddddeeeeffff0000aaaabbbb" + setup := func(t *testing.T, loggedIn bool) (*globals.Context, *replyChatHelper, *replyInboxSource) { + tc := libkb.SetupTest(t, "PostTextReply", 0) + t.Cleanup(tc.Cleanup) + helper := &replyChatHelper{} + inbox := &replyInboxSource{} + tc.G.ChatHelper = helper + if loggedIn { + uid := keybase1.MakeTestUID(1) + deviceID := keybase1.DeviceID("00000000000000000000000000000018") + sigKey, err := libkb.GenerateNaclSigningKeyPair() + require.NoError(t, err) + encKey, err := libkb.GenerateNaclDHKeyPair() + require.NoError(t, err) + require.NoError(t, tc.G.ActiveDevice.Set(libkb.NewMetaContextForTest(tc), + keybase1.UserVersion{Uid: uid, EldestSeqno: 1}, deviceID, + sigKey, encKey, "testuser-device", 0, libkb.KeychainModeNone)) + require.NoError(t, tc.G.Env.GetConfigWriter().SetUserConfig( + libkb.NewUserConfig(uid, "testuser", nil, deviceID), true)) + require.NoError(t, tc.G.Env.GetConfigWriter().SwitchUser("testuser")) + } + return globals.NewContext(tc.G, &globals.ChatContext{InboxSource: inbox}), helper, inbox + } + ctx := context.Background() + + t.Run("sends and marks read", func(t *testing.T) { + gc, helper, inbox := setup(t, true) + require.NoError(t, postTextReply(ctx, gc, convID, "testuser", 5, "hi")) + require.Equal(t, []string{"hi"}, helper.sent) + require.Equal(t, []chat1.MessageID{5}, inbox.marked) + }) + t.Run("send error is returned", func(t *testing.T) { + gc, helper, inbox := setup(t, true) + helper.sendErr = errors.New("outbox full") + require.EqualError(t, postTextReply(ctx, gc, convID, "testuser", 5, "hi"), "outbox full") + require.Empty(t, inbox.marked) + }) + t.Run("mark read failure doesn't fail a sent reply", func(t *testing.T) { + gc, helper, inbox := setup(t, true) + inbox.markErr = errors.New("offline") + require.NoError(t, postTextReply(ctx, gc, convID, "testuser", 5, "hi")) + require.Equal(t, []string{"hi"}, helper.sent) + }) + t.Run("logged out doesn't send", func(t *testing.T) { + gc, helper, _ := setup(t, false) + require.ErrorAs(t, postTextReply(ctx, gc, convID, "testuser", 5, "hi"), &libkb.LoginRequiredError{}) + require.Empty(t, helper.sent) + }) + t.Run("invalid message ID doesn't send", func(t *testing.T) { + gc, helper, _ := setup(t, true) + require.Error(t, postTextReply(ctx, gc, convID, "testuser", -1, "hi")) + require.Empty(t, helper.sent) + }) +} + +// pendingDeliveryDeps reports a message still sending, so a push window that +// may hand over to a background task does. +func pendingDeliveryDeps() lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + Stay: func() bool { return true }, + ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { + return make([]chat1.OutboxRecord, 1), nil + }, + NextFailure: func() (chan []chat1.OutboxRecord, func()) { return make(chan []chat1.OutboxRecord), func() {} }, + NotifyFailure: func([]chat1.OutboxRecord) {}, + } +} + +func TestBackgroundNotificationOpensAndClosesPushWindow(t *testing.T) { + const ( + fg = keybase1.MobileAppState_FOREGROUND + bg = keybase1.MobileAppState_BACKGROUND + bga = keybase1.MobileAppState_BACKGROUNDACTIVE + ) + for _, platform := range []lifecycletest.Platform{lifecycletest.IOS, lifecycletest.Android} { + t.Run(platform.String(), func(t *testing.T) { + tc := libkb.SetupTest(t, "PushWindow", 0) + defer tc.Cleanup() + h := lifecycletest.NewHarness(t, libkb.NewMobileAppState(tc.G), platform) + defer h.Close() + h.Controller.UIInactive() + lifecycletest.ToBackground(h.Controller) + require.Equal(t, bg, h.AppState.State()) + seen := len(h.Recorder.States()) + + unboxFailed := errors.New("unbox failed") + var during keybase1.MobileAppState + err := runPushWindow(h.Controller, platform.String(), pendingDeliveryDeps(), func(uiActive bool) error { + require.False(t, uiActive) + during = h.AppState.State() + return unboxFailed + }) + require.ErrorIs(t, err, unboxFailed) + if platform == lifecycletest.IOS { + require.Equal(t, bg, during, "iOS handles the push without holding the app up") + require.Equal(t, bg, h.AppState.State()) + h.Recorder.Sync(t) + require.Len(t, h.Recorder.States(), seen, "the push never reached the controller") + } else { + require.Equal(t, bga, during, "the push is handled in BACKGROUNDACTIVE") + require.Equal(t, bga, h.AppState.State(), "a background task keeps sending") + h.Controller.BackgroundTaskExpired(func() {}) + require.Equal(t, bg, h.AppState.State(), "the background task held the app, not the push window") + } + + h.Controller.UIActive() + ran := false + require.NoError(t, runPushWindow(h.Controller, platform.String(), pendingDeliveryDeps(), func(uiActive bool) error { + require.Equal(t, platform == lifecycletest.Android, uiActive, "only Android's work asks") + ran = true + return nil + })) + require.True(t, ran, "the work runs while the UI is active") + require.Equal(t, fg, h.AppState.State()) + }) + } +} + +type recordingPusher struct { + PushNotifier + displayed []string +} + +func (p *recordingPusher) DisplayChatNotification(n *ChatNotification) { + p.displayed = append(p.displayed, n.ConvID) +} + +func TestBackgroundNotificationActiveSkipsDisplayButAcks(t *testing.T) { + for _, goos := range []string{"android", "ios"} { + t.Run(goos, func(t *testing.T) { + pusher := &recordingPusher{} + acks := 0 + ack := func() { acks++ } + show := func(convID string, uiActive bool) bool { + return displayOnce(convID+"||1", &ChatNotification{ConvID: convID}, pusher, goos, uiActive, ack) + } + // The seen cache is global, so each run needs its own push ids. + run := fmt.Sprintf("%s/%d/", t.Name(), time.Now().UnixNano()) + active := run + "active" + require.False(t, show(active, true)) + if goos == "android" { + require.Empty(t, pusher.displayed, "the app already shows the message") + } else { + require.Equal(t, []string{active}, pusher.displayed, + "iOS displays to remove the server's generic notification; the local one never shows while active") + } + require.Equal(t, 1, acks, "the push is acked so the server's fallback doesn't show it") + displayed := len(pusher.displayed) + + require.True(t, show(active, false), "a push handled while active isn't shown later") + require.Len(t, pusher.displayed, displayed) + require.Equal(t, 2, acks) + + background := run + "background" + require.False(t, show(background, false)) + require.Equal(t, background, pusher.displayed[len(pusher.displayed)-1]) + require.Len(t, pusher.displayed, displayed+1) + require.Equal(t, 3, acks) + }) + } +} diff --git a/go/chat/archive.go b/go/chat/archive.go index 69af6e9cdfb1..e410da08b860 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -42,11 +42,23 @@ type ChatArchiveRegistry struct { flushDelay time.Duration stopCh chan struct{} clock clockwork.Clock - eg errgroup.Group + // eg holds the current run's loop. Each run gets its own, since Stop + // waits on it from a goroutine and a Group cannot be added to while + // somebody waits on it. + eg *errgroup.Group // Changes to flush to disk? dirty bool remoteClient func() chat1.RemoteInterface runningJobs map[chat1.ArchiveJobID]types.PauseArchiveFn + // launching holds jobs started by a resume that have not registered as + // running yet, so an overlapping resume does not start them again. Each + // entry is its launch's number, so a launch that ends clears only its + // own entry and never that of a later launch of the same job. + launching map[chat1.ArchiveJobID]uint64 + lastLaunch uint64 + // runJob, if set, runs a launched job in place of a ChatArchiver. Tests + // only. + runJob func(ctx context.Context, uid gregor1.UID, req chat1.ArchiveChatJobRequest) error edb *encrypteddb.EncryptedDB jobHistory chat1.ArchiveChatHistory @@ -80,6 +92,7 @@ func NewChatArchiveRegistry(g *globals.Context, remoteClient func() chat1.Remote clock: clockwork.NewRealClock(), flushDelay: 15 * time.Second, runningJobs: make(map[chat1.ArchiveJobID]types.PauseArchiveFn), + launching: make(map[chat1.ArchiveJobID]uint64), jobHistory: chat1.ArchiveChatHistory{JobHistory: make(map[chat1.ArchiveJobID]chat1.ArchiveChatJob)}, edb: encrypteddb.New(g.ExternalG(), dbFn, keyFn), } @@ -135,94 +148,142 @@ func (r *ChatArchiveRegistry) flushLocked(ctx context.Context) error { return nil } -func (r *ChatArchiveRegistry) flushLoop(stopCh chan struct{}) error { +// archiveRunsIn is whether jobs run in state; they pause in every other. +func archiveRunsIn(state keybase1.MobileAppState) bool { + return state == keybase1.MobileAppState_FOREGROUND +} + +// runEnded reports whether the run stopCh belongs to is over. Stop closes +// stopCh under r's lock, so under that lock a closed channel means the run is +// over, whether or not a later Start (possibly for another user) has since +// replaced r.stopCh. +func runEnded(stopCh chan struct{}) bool { + select { + case <-stopCh: + return true + default: + return false + } +} + +func (r *ChatArchiveRegistry) flush(ctx context.Context, stopCh chan struct{}) { + var err error + defer r.Trace(ctx, &err, "flush")() + r.Lock() + defer r.Unlock() + if runEnded(stopCh) { + return + } + err = r.flushLocked(ctx) +} + +func (r *ChatArchiveRegistry) bgPauseAllJobs(ctx context.Context, stopCh chan struct{}) { + r.Lock() + defer r.Unlock() + if runEnded(stopCh) { + return + } + _ = r.bgPauseAllJobsLocked(ctx) +} + +// loop runs one run of the registry until stopCh closes: it flushes on a +// timer, pauses running jobs whenever the app leaves the foreground, and +// resumes paused jobs once the app has been in the foreground for +// resumeJobsDelay. +func (r *ChatArchiveRegistry) loop(stopCh chan struct{}, state keybase1.MobileAppState) error { ctx := context.Background() - r.Debug(ctx, "flushLoop: starting") + r.Debug(ctx, "loop: starting in %v", state) + defer r.Debug(ctx, "loop: shutting down") + flushCh := r.clock.After(r.flushDelay) + resume := time.NewTimer(r.resumeJobsDelay) + if !archiveRunsIn(state) { + resume.Stop() + } + // changed is refreshed only when the loop reads a new state: a resume + // can skip on a state the loop has not seen yet, and a fresh NextUpdate + // taken after the state came back would miss that change. + changed := r.G().MobileAppState.NextUpdate(state) for { select { case <-stopCh: - r.Debug(ctx, "flushLoop: shutting down") return nil - case <-r.clock.After(r.flushDelay): - func() { - var err error - defer r.Trace(ctx, &err, "flushLoop")() - r.Lock() - defer r.Unlock() - err = r.flushLocked(ctx) - if err != nil { - r.Debug(ctx, "flushLoop: failed to flush: %s", err) - } - }() + case <-flushCh: + r.flush(ctx, stopCh) + flushCh = r.clock.After(r.flushDelay) + case <-changed: + state = r.G().MobileAppState.State() + changed = r.G().MobileAppState.NextUpdate(state) + r.Debug(ctx, "loop: next state -> %v", state) + if archiveRunsIn(state) { + resume.Reset(r.resumeJobsDelay) + } else { + resume.Stop() + r.bgPauseAllJobs(ctx, stopCh) + } + case <-resume.C: + if err := r.resumeAllBgJobs(ctx, stopCh); err != nil { + r.Debug(ctx, err.Error()) + } } } } func (r *ChatArchiveRegistry) resumeAllBgJobs(ctx context.Context, stopCh chan struct{}) (err error) { defer r.Trace(ctx, &err, "resumeAllBgJobs")() - select { - case <-stopCh: - return nil - case <-ctx.Done(): - return ctx.Err() - case <-time.After(r.resumeJobsDelay): - } r.Lock() defer r.Unlock() + if runEnded(stopCh) { + return nil + } + if state := r.G().MobileAppState.State(); !archiveRunsIn(state) { + r.Debug(ctx, "resumeAllBgJobs: not resuming in %v", state) + return nil + } err = r.initLocked(ctx) if err != nil { return err } for _, job := range r.jobHistory.JobHistory { if job.Status == chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED { - go func(job chat1.ArchiveChatJob) { - ctx := globals.ChatCtx(context.Background(), r.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, NewSimpleIdentifyNotifier(r.G())) - _, err := NewChatArchiver(r.G(), r.uid, r.remoteClient).ArchiveChat(ctx, job.Request) - if err != nil { - r.Debug(ctx, err.Error()) - } - }(job) + r.launchLocked(ctx, job.Request) } } return nil } -func (r *ChatArchiveRegistry) monitorAppState(stopCh chan struct{}) error { - appState := keybase1.MobileAppState_FOREGROUND - ctx, cancel := context.WithCancel(context.Background()) - for { - select { - case <-stopCh: - cancel() - return nil - case <-r.G().MobileAppState.NextUpdate(appState): - appState = r.G().MobileAppState.State() - r.Debug(ctx, "monitorAppState: next state -> %v", appState) - switch appState { - case keybase1.MobileAppState_FOREGROUND: - go func() { - ierr := r.resumeAllBgJobs(ctx, stopCh) - if ierr != nil { - r.Debug(ctx, ierr.Error()) - } - }() - default: - cancel() - ctx, cancel = context.WithCancel(context.Background()) - - func() { - var err error - defer r.Trace(ctx, &err, "monitorAppState")() - r.Lock() - defer r.Unlock() - err = r.bgPauseAllJobsLocked(ctx) - }() - } - } +// launchLocked runs a job in the background unless an earlier launch of it +// has not registered yet. The job registers itself as running through Set. +func (r *ChatArchiveRegistry) launchLocked(ctx context.Context, req chat1.ArchiveChatJobRequest) { + jobID := req.JobID + if _, ok := r.launching[jobID]; ok { + r.Debug(ctx, "launch: %v is already starting", jobID) + return } + r.lastLaunch++ + launch := r.lastLaunch + r.launching[jobID] = launch + uid, runJob := r.uid, r.runJob + go func() { + ctx := globals.ChatCtx(context.Background(), r.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, NewSimpleIdentifyNotifier(r.G())) + var err error + if runJob != nil { + err = runJob(ctx, uid, req) + } else { + _, err = NewChatArchiver(r.G(), uid, r.remoteClient).ArchiveChat(ctx, req) + } + if err != nil { + r.Debug(ctx, err.Error()) + } + r.Lock() + defer r.Unlock() + if r.launching[jobID] == launch { + delete(r.launching, jobID) + } + }() } -// Resumes previously BACKGROUND_PAUSED jobs, after a delay. +// Resumes previously BACKGROUND_PAUSED jobs, after a delay, if the app is in +// the foreground. func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { defer r.Trace(ctx, nil, "Start")() r.Lock() @@ -233,15 +294,11 @@ func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { r.uid = uid r.started = true r.stopCh = make(chan struct{}) + r.eg = new(errgroup.Group) stopCh := r.stopCh + state := r.G().MobileAppState.State() r.eg.Go(func() error { - return r.flushLoop(stopCh) - }) - r.eg.Go(func() error { - return r.resumeAllBgJobs(context.Background(), stopCh) - }) - r.eg.Go(func() error { - return r.monitorAppState(stopCh) + return r.loop(stopCh, state) }) } @@ -284,9 +341,10 @@ func (r *ChatArchiveRegistry) Stop(ctx context.Context) chan struct{} { } r.started = false close(r.stopCh) + eg := r.eg go func() { r.Debug(context.Background(), "Stop: waiting for shutdown") - _ = r.eg.Wait() + _ = eg.Wait() r.Debug(context.Background(), "Stop: shutdown complete") close(ch) }() @@ -395,9 +453,20 @@ func (r *ChatArchiveRegistry) Set(ctx context.Context, cancel types.PauseArchive case chat1.ArchiveChatJobStatus_COMPLETE, chat1.ArchiveChatJobStatus_ERROR: delete(r.runningJobs, jobID) case chat1.ArchiveChatJobStatus_RUNNING: - if cancel != nil { - r.runningJobs[jobID] = cancel + if cancel == nil { + break } + delete(r.launching, jobID) + // The loop pauses running jobs under this lock when the app leaves + // the foreground. A job registering while the app is out of it came + // after that pause, so it is paused here. + if state := r.G().MobileAppState.State(); !archiveRunsIn(state) { + r.Debug(ctx, "Set: pausing %v in %v", jobID, state) + cancel() + job.Status = chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED + break + } + r.runningJobs[jobID] = cancel } r.jobHistory.JobHistory[jobID] = job.DeepCopy() @@ -463,14 +532,7 @@ func (r *ChatArchiveRegistry) Resume(ctx context.Context, jobID chat1.ArchiveJob return fmt.Errorf("Cannot resume a non-paused job. Found status %v", job.Status) } - // Resume the job in the background, the job will register itself as running - go func() { - ctx := globals.ChatCtx(context.Background(), r.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, NewSimpleIdentifyNotifier(r.G())) - _, err := NewChatArchiver(r.G(), r.uid, r.remoteClient).ArchiveChat(ctx, job.Request) - if err != nil { - r.Debug(ctx, err.Error()) - } - }() + r.launchLocked(ctx, job.Request) return nil } diff --git a/go/chat/archive_appstate_test.go b/go/chat/archive_appstate_test.go new file mode 100644 index 000000000000..1cb4aad29b2f --- /dev/null +++ b/go/chat/archive_appstate_test.go @@ -0,0 +1,536 @@ +package chat + +import ( + "context" + "fmt" + "math/rand" + "runtime" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/encrypteddb" + "github.com/keybase/client/go/externalstest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// archiveJobRunner stands in for ChatArchiver: a launched job waits for +// release, registers as running through Set, and runs until paused. +type archiveJobRunner struct { + r *ChatArchiveRegistry + release chan struct{} + + mu sync.Mutex + launches map[chat1.ArchiveJobID]int + active int + launched chan chat1.ArchiveJobID +} + +func (a *archiveJobRunner) run(ctx context.Context, uid gregor1.UID, req chat1.ArchiveChatJobRequest) error { + a.mu.Lock() + a.launches[req.JobID]++ + a.active++ + a.mu.Unlock() + defer func() { + a.mu.Lock() + a.active-- + a.mu.Unlock() + }() + select { + case a.launched <- req.JobID: + default: + } + <-a.release + pauseCh := make(chan struct{}) + var once sync.Once + pause := func() { once.Do(func() { close(pauseCh) }) } + job := chat1.ArchiveChatJob{Request: req, Status: chat1.ArchiveChatJobStatus_RUNNING} + if err := a.r.Set(ctx, pause, job); err != nil { + return err + } + <-pauseCh + return nil +} + +func (a *archiveJobRunner) counts() (launches map[chat1.ArchiveJobID]int, active int) { + a.mu.Lock() + defer a.mu.Unlock() + launches = make(map[chat1.ArchiveJobID]int, len(a.launches)) + for id, n := range a.launches { + launches[id] = n + } + return launches, a.active +} + +var archiveTestJobIDs = []chat1.ArchiveJobID{"job-a", "job-b", "job-c"} + +// setupAppStateArchive returns a registry whose history holds paused jobs +// and is treated as already read from disk. +func setupAppStateArchive(t *testing.T, released bool) (*ChatArchiveRegistry, *archiveJobRunner, libkb.TestContext) { + tc := externalstest.SetupTest(t, "archive-appstate", 0) + t.Cleanup(tc.Cleanup) + g := globals.NewContext(tc.G, &globals.ChatContext{CtxFactory: appStateCtxFactory{}}) + r := NewChatArchiveRegistry(g, nil) + r.resumeJobsDelay = 0 + // The real key needs a logged-in user. + r.edb = encrypteddb.New(tc.G, func(g *libkb.GlobalContext) *libkb.JSONLocalDb { return g.LocalChatDb }, + func(context.Context) ([32]byte, error) { return [32]byte{}, nil }) + r.inited = true + for _, id := range archiveTestJobIDs { + r.jobHistory.JobHistory[id] = chat1.ArchiveChatJob{ + Request: chat1.ArchiveChatJobRequest{JobID: id}, + Status: chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED, + } + } + runner := &archiveJobRunner{ + r: r, + release: make(chan struct{}), + launches: make(map[chat1.ArchiveJobID]int), + launched: make(chan chat1.ArchiveJobID, 100), + } + if released { + close(runner.release) + } + r.runJob = runner.run + return r, runner, tc +} + +func archiveStatuses(r *ChatArchiveRegistry) (statuses map[chat1.ArchiveJobID]chat1.ArchiveChatJobStatus, running int) { + r.Lock() + defer r.Unlock() + statuses = make(map[chat1.ArchiveJobID]chat1.ArchiveChatJobStatus) + for id, job := range r.jobHistory.JobHistory { + statuses[id] = job.Status + } + return statuses, len(r.runningJobs) +} + +func requireArchiveStopped(t *testing.T, r *ChatArchiveRegistry) { + t.Helper() + select { + case <-r.Stop(context.TODO()): + case <-time.After(10 * time.Second): + require.FailNow(t, "Stop did not finish") + } +} + +func requireArchiveJobsRunning(t *testing.T, r *ChatArchiveRegistry) { + t.Helper() + require.Eventually(t, func() bool { + statuses, running := archiveStatuses(r) + for _, status := range statuses { + if status != chat1.ArchiveChatJobStatus_RUNNING { + return false + } + } + return running == len(statuses) + }, 10*time.Second, time.Millisecond, "jobs did not resume") +} + +func requireArchiveJobsPaused(t *testing.T, r *ChatArchiveRegistry, runner *archiveJobRunner) { + t.Helper() + require.Eventually(t, func() bool { + statuses, running := archiveStatuses(r) + for _, status := range statuses { + if status != chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED { + return false + } + } + _, active := runner.counts() + return running == 0 && active == 0 + }, 10*time.Second, time.Millisecond, "jobs did not pause") +} + +func TestArchiveConcurrentResumesLaunchOnce(t *testing.T) { + r, runner, _ := setupAppStateArchive(t, false) + stopCh := make(chan struct{}) + r.Lock() + r.started = true + r.stopCh = stopCh + r.Unlock() + defer close(stopCh) + + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + assert.NoError(t, r.resumeAllBgJobs(context.Background(), stopCh)) + }) + } + wg.Wait() + for range archiveTestJobIDs { + select { + case <-runner.launched: + case <-time.After(10 * time.Second): + require.FailNow(t, "jobs did not launch") + } + } + launches, _ := runner.counts() + for _, id := range archiveTestJobIDs { + require.Equal(t, 1, launches[id], "launches of %v before it registered", id) + } + + close(runner.release) + requireArchiveJobsRunning(t, r) + for range 5 { + require.NoError(t, r.resumeAllBgJobs(context.Background(), stopCh)) + } + launches, _ = runner.counts() + for _, id := range archiveTestJobIDs { + require.Equal(t, 1, launches[id], "launches of %v after it registered", id) + } + r.Lock() + require.NoError(t, r.bgPauseAllJobsLocked(context.Background())) + r.Unlock() + requireArchiveJobsPaused(t, r, runner) +} + +// A run's loop that is still going after Stop neither pauses the jobs nor +// flushes the history of whatever run comes next. +func TestArchiveEndedRunLoopTouchesNothing(t *testing.T) { + r, _, _ := setupAppStateArchive(t, true) + ctx := context.Background() + r.Start(ctx, gregor1.UID([]byte{1, 2, 3, 4})) + defer requireArchiveStopped(t, r) + requireArchiveJobsRunning(t, r) + + ended := make(chan struct{}) + close(ended) + r.bgPauseAllJobs(ctx, ended) + statuses, running := archiveStatuses(r) + require.Equal(t, len(archiveTestJobIDs), running, "an ended run paused jobs") + for id, status := range statuses { + require.Equal(t, chat1.ArchiveChatJobStatus_RUNNING, status, "%v", id) + } + + r.Lock() + r.dirty = true + r.Unlock() + r.flush(ctx, ended) + r.Lock() + defer r.Unlock() + require.True(t, r.dirty, "an ended run flushed") +} + +// A job launched by one resume, passed over by a pause because it had not +// registered yet, and skipped by the next resume because it was still +// launching, runs once it registers in the foreground. +func TestArchiveRelaunchAfterPauseWhileLaunching(t *testing.T) { + r, runner, tc := setupAppStateArchive(t, false) + stopCh := make(chan struct{}) + r.Lock() + r.started = true + r.stopCh = stopCh + r.Unlock() + defer close(stopCh) + ctx := context.Background() + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + require.NoError(t, r.resumeAllBgJobs(ctx, stopCh)) + for range archiveTestJobIDs { + select { + case <-runner.launched: + case <-time.After(10 * time.Second): + require.FailNow(t, "jobs did not launch") + } + } + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + r.Lock() + require.NoError(t, r.bgPauseAllJobsLocked(ctx)) + r.Unlock() + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + require.NoError(t, r.resumeAllBgJobs(ctx, stopCh)) + launches, _ := runner.counts() + for _, id := range archiveTestJobIDs { + require.Equal(t, 1, launches[id], "launches of %v", id) + } + + close(runner.release) + requireArchiveJobsRunning(t, r) + r.Lock() + require.NoError(t, r.bgPauseAllJobsLocked(ctx)) + r.Unlock() + requireArchiveJobsPaused(t, r, runner) +} + +// A job that registers as running while the app is not in the foreground is +// paused at once, and resumes on the next FOREGROUND. +func TestArchiveSetWhileInactivePauses(t *testing.T) { + r, _, tc := setupAppStateArchive(t, true) + ctx := context.Background() + tc.G.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + r.Start(ctx, gregor1.UID([]byte{1, 2, 3, 4})) + defer requireArchiveStopped(t, r) + + jobID := chat1.ArchiveJobID("job-manual") + paused := make(chan struct{}) + var once sync.Once + job := chat1.ArchiveChatJob{ + Request: chat1.ArchiveChatJobRequest{JobID: jobID}, + Status: chat1.ArchiveChatJobStatus_RUNNING, + } + require.NoError(t, r.Set(ctx, func() { once.Do(func() { close(paused) }) }, job)) + select { + case <-paused: + default: + require.FailNow(t, "Set did not pause the job") + } + statuses, running := archiveStatuses(r) + require.Equal(t, chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED, statuses[jobID]) + require.Zero(t, running) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + requireArchiveJobsRunning(t, r) +} + +// A pause that lands while launched jobs have not registered yet leaves them +// paused once they do, and the next FOREGROUND resumes them. +func TestArchivePauseBeforeRegistration(t *testing.T) { + r, runner, tc := setupAppStateArchive(t, false) + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireArchiveStopped(t, r) + for range archiveTestJobIDs { + select { + case <-runner.launched: + case <-time.After(10 * time.Second): + require.FailNow(t, "jobs did not launch") + } + } + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + close(runner.release) + requireArchiveJobsPaused(t, r, runner) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + requireArchiveJobsRunning(t, r) +} + +// A resume whose timer fired as its run stopped must not launch jobs in the +// run, possibly another user's, that started next; that run resumes on its +// own schedule. +func TestArchiveStaleResumeAfterRestart(t *testing.T) { + r, _, _ := setupAppStateArchive(t, true) + r.resumeJobsDelay = time.Hour + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + r.Lock() + oldStopCh := r.stopCh + r.Unlock() + requireArchiveStopped(t, r) + r.Start(context.TODO(), gregor1.UID([]byte{5, 6, 7, 8})) + defer requireArchiveStopped(t, r) + + require.NoError(t, r.resumeAllBgJobs(context.Background(), oldStopCh)) + r.Lock() + defer r.Unlock() + require.Empty(t, r.launching, "stale resume launched jobs") +} + +// A resume whose timer fired just as a plain Stop, with no Start following +// it, took the lock must not launch jobs: there is no live run left to +// launch them into. +func TestArchiveResumeAfterPlainStopLaunchesNothing(t *testing.T) { + r, _, _ := setupAppStateArchive(t, true) + r.resumeJobsDelay = time.Hour + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + r.Lock() + stopCh := r.stopCh + r.Unlock() + requireArchiveStopped(t, r) + + require.NoError(t, r.resumeAllBgJobs(context.Background(), stopCh)) + r.Lock() + defer r.Unlock() + require.Empty(t, r.launching, "resumed after a plain Stop") +} + +// A launch that ends after a later launch of the same job started must not +// clear the later one's entry, or the next resume starts the job again +// before the later launch registers. +func TestArchiveEndedLaunchKeepsLaterLaunch(t *testing.T) { + r, _, _ := setupAppStateArchive(t, true) + jobID := archiveTestJobIDs[0] + r.jobHistory.JobHistory = map[chat1.ArchiveJobID]chat1.ArchiveChatJob{jobID: { + Request: chat1.ArchiveChatJobRequest{JobID: jobID}, + Status: chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED, + }} + var mu sync.Mutex + launches := 0 + firstExit := make(chan struct{}) + secondLaunched := make(chan struct{}) + secondRelease := make(chan struct{}) + r.runJob = func(ctx context.Context, uid gregor1.UID, req chat1.ArchiveChatJobRequest) error { + mu.Lock() + launches++ + n := launches + mu.Unlock() + switch n { + case 1: + pauseCh := make(chan struct{}) + job := chat1.ArchiveChatJob{Request: req, Status: chat1.ArchiveChatJobStatus_RUNNING} + if err := r.Set(ctx, func() { close(pauseCh) }, job); err != nil { + return err + } + <-pauseCh + <-firstExit + case 2: + close(secondLaunched) + <-secondRelease + } + return nil + } + launchCount := func() int { + mu.Lock() + defer mu.Unlock() + return launches + } + stopCh := make(chan struct{}) + r.Lock() + r.started = true + r.stopCh = stopCh + r.Unlock() + defer close(stopCh) + ctx := context.Background() + + require.NoError(t, r.resumeAllBgJobs(ctx, stopCh)) + require.Eventually(t, func() bool { + _, running := archiveStatuses(r) + return running == 1 + }, 10*time.Second, time.Millisecond, "first launch did not register") + r.Lock() + require.NoError(t, r.bgPauseAllJobsLocked(ctx)) + r.Unlock() + require.NoError(t, r.resumeAllBgJobs(ctx, stopCh)) + select { + case <-secondLaunched: + case <-time.After(10 * time.Second): + require.FailNow(t, "second launch did not start") + } + + close(firstExit) + require.Never(t, func() bool { + if err := r.resumeAllBgJobs(ctx, stopCh); err != nil { + return true + } + return launchCount() > 2 + }, 300*time.Millisecond, 10*time.Millisecond, "job launched again before its launch registered") + close(secondRelease) +} + +func TestArchiveStartInBackgroundDoesNotResume(t *testing.T) { + r, runner, tc := setupAppStateArchive(t, true) + for _, state := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } { + tc.G.MobileAppState.Update(state) + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + select { + case id := <-runner.launched: + require.FailNow(t, fmt.Sprintf("resumed %v at a Start in %v", id, state)) + case <-time.After(200 * time.Millisecond): + } + requireArchiveStopped(t, r) + } + + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireArchiveStopped(t, r) + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + requireArchiveJobsRunning(t, r) + launches, _ := runner.counts() + for _, id := range archiveTestJobIDs { + require.Equal(t, 1, launches[id]) + } +} + +func TestArchiveScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + r, runner, tc := setupAppStateArchive(t, true) + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireArchiveStopped(t, r) + lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + if step.Want == keybase1.MobileAppState_FOREGROUND { + requireArchiveJobsRunning(t, r) + } else { + requireArchiveJobsPaused(t, r, runner) + } + }) + }) + } +} + +// Rapid transitions race resumes against pauses, and Starts and Stops against +// the loop. +func TestArchiveAppStateStress(t *testing.T) { + r, runner, tc := setupAppStateArchive(t, true) + // Pauses flush, and the first flush opens the local db and its goroutines. + r.Lock() + r.dirty = true + require.NoError(t, r.flushLocked(context.Background())) + r.Unlock() + baseline := runtime.NumGoroutine() + uid := gregor1.UID([]byte{1, 2, 3, 4}) + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } + r.Start(context.TODO(), uid) + done := make(chan struct{}) + go func() { + defer close(done) + var wg sync.WaitGroup + for w := range 4 { + wg.Go(func() { + rng := rand.New(rand.NewSource(int64(w))) + for range 300 { + tc.G.MobileAppState.Update(states[rng.Intn(len(states))]) + } + }) + } + for w := range 2 { + wg.Go(func() { + rng := rand.New(rand.NewSource(int64(99 + w))) + for range 40 { + switch rng.Intn(3) { + case 0: + r.Start(context.TODO(), uid) + case 1: + <-r.Stop(context.TODO()) + default: + // Start again without waiting for the old run. + r.Stop(context.TODO()) + } + } + }) + } + wg.Wait() + }() + select { + case <-done: + case <-time.After(60 * time.Second): + require.FailNow(t, "deadlock") + } + + r.Start(context.TODO(), uid) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + requireArchiveJobsPaused(t, r, runner) + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + requireArchiveJobsRunning(t, r) + for id, n := range func() map[chat1.ArchiveJobID]int { l, _ := runner.counts(); return l }() { + require.Positive(t, n, "%v", id) + } + _, active := runner.counts() + require.Equal(t, len(archiveTestJobIDs), active, "one live run per job") + requireArchiveStopped(t, r) + requireArchiveJobsPaused(t, r, runner) + requireNoGoroutineLeak(t, baseline) +} diff --git a/go/chat/attachment_httpsrv.go b/go/chat/attachment_httpsrv.go index e4d49e735369..505d8595c88b 100644 --- a/go/chat/attachment_httpsrv.go +++ b/go/chat/attachment_httpsrv.go @@ -121,13 +121,11 @@ func (r *AttachmentHTTPSrv) genURLKey(prefix string, payload any) (string, error } func (r *AttachmentHTTPSrv) getURL(ctx context.Context, prefix string, payload any) string { - if !r.httpSrv.Active() { - r.Debug(ctx, "getURL: http server failed to start earlier") - return "" - } + // Addr fails only before the server first binds; while it is stopped it + // returns where the server comes back. addr, err := r.httpSrv.Addr() if err != nil { - r.Debug(ctx, "getURL: failed to get HTTP server address: %s", err) + r.Debug(ctx, "getURL: no HTTP server address: %s", err) return "" } key, err := r.genURLKey(prefix, payload) @@ -149,6 +147,10 @@ func (r *AttachmentHTTPSrv) GetURL(ctx context.Context, convID chat1.Conversatio ConvID: convID, MsgID: msgID, }) + if url == "" { + // Without a server there is no URL; the query alone would be a garbage one. + return "" + } url += fmt.Sprintf("&prev=%v&noanim=%v&isemoji=%v", preview, noAnim, isEmoji) r.Debug(ctx, "GetURL: handler URL: convID: %s msgID: %d %s", convID, msgID, url) return url diff --git a/go/chat/attachment_httpsrv_appstate_test.go b/go/chat/attachment_httpsrv_appstate_test.go new file mode 100644 index 000000000000..5fde9a33c894 --- /dev/null +++ b/go/chat/attachment_httpsrv_appstate_test.go @@ -0,0 +1,89 @@ +package chat + +import ( + "context" + "net" + "strings" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/externalstest" + "github.com/keybase/client/go/kbhttp/manager" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +type startOnlyAttachmentFetcher struct { + types.AttachmentFetcher +} + +func (startOnlyAttachmentFetcher) OnStart(libkb.MetaContext) {} + +// requireSrvServing waits until the server does or does not accept connections +// at the address it hands out. +func requireSrvServing(t *testing.T, srv *manager.Srv, serving bool) { + t.Helper() + require.Eventually(t, func() bool { + addr, err := srv.Addr() + if err != nil { + return false + } + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + conn.Close() + } + return (err == nil) == serving + }, 10*time.Second, time.Millisecond, "server serving != %v", serving) +} + +func TestGetURLWhileStoppedUsesLastAddress(t *testing.T) { + tc := externalstest.SetupTest(t, "attachment-url-stopped", 0) + defer tc.Cleanup() + tc.G.ConnectionManager = libkb.NewConnectionManager() + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + g := globals.NewContext(tc.G, &globals.ChatContext{}) + httpSrv := manager.NewSrv(tc.G) + srv := NewAttachmentHTTPSrv(g, httpSrv, startOnlyAttachmentFetcher{}, nil) + g.AttachmentURLSrv = srv + emoji := NewDevConvEmojiSource(g, nil) + ctx := context.TODO() + msg := chat1.EmojiMessage{ConvID: convLoaderTestConvID, MsgID: 3} + + type urls struct { + full, preview, emoji, emojiNoAnim, emojiNoAnimOnly string + } + get := func() urls { + var res urls + res.full = srv.GetURL(ctx, msg.ConvID, msg.MsgID, false, false, false) + res.preview = srv.GetURL(ctx, msg.ConvID, msg.MsgID, true, false, false) + source, noAnimSource, err := emoji.RemoteToLocalSource(ctx, chat1.NewEmojiRemoteSourceWithMessage(msg), false) + require.NoError(t, err) + res.emoji, res.emojiNoAnim = source.Httpsrv(), noAnimSource.Httpsrv() + source, _, err = emoji.RemoteToLocalSource(ctx, chat1.NewEmojiRemoteSourceWithMessage(msg), true) + require.NoError(t, err) + res.emojiNoAnimOnly = source.Httpsrv() + return res + } + + requireSrvServing(t, httpSrv, true) + addr, err := httpSrv.Addr() + require.NoError(t, err) + prefix := "http://" + addr + "/" + up := get() + for _, url := range []string{up.full, up.preview, up.emoji, up.emojiNoAnim, up.emojiNoAnimOnly} { + require.True(t, strings.HasPrefix(url, prefix), "url %q while serving", url) + } + require.Contains(t, up.preview, "&prev=true") + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + requireSrvServing(t, httpSrv, false) + require.Equal(t, up, get()) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + requireSrvServing(t, httpSrv, true) + require.Equal(t, up, get()) +} diff --git a/go/chat/convloader.go b/go/chat/convloader.go index 1523ac249411..fd8619ace27c 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -115,15 +115,23 @@ type BackgroundConvLoader struct { utils.DebugLabeler sync.Mutex - uid gregor1.UID - started bool - queue *jobQueue - stopCh chan struct{} - suspendCh chan chan struct{} + uid gregor1.UID + started bool + // gen counts Start and Stop calls, so a Start that waited for the + // previous run can tell whether a later call overtook it. + gen uint64 + queue *jobQueue + stopCh chan struct{} + // suspendCh wakes the loop when Suspend takes hold; the loop reads the + // suspension itself. + suspendCh chan struct{} resumeCh chan struct{} loadCh chan *clTask identNotifier types.IdentifyNotifier - eg errgroup.Group + // eg holds the last run's goroutines, which may still be exiting. Each + // run gets its own, since a Group cannot be added to while somebody + // waits on it. + eg *errgroup.Group clock clockwork.Clock resumeWait time.Duration @@ -135,7 +143,6 @@ type BackgroundConvLoader struct { // for testing, make this and can check conv load successes loads chan chat1.ConversationID testingNameInfoSource types.NameInfoSource - appStateCh chan struct{} } var _ types.ConvLoader = (*BackgroundConvLoader)(nil) @@ -145,7 +152,8 @@ func NewBackgroundConvLoader(g *globals.Context) *BackgroundConvLoader { Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "BackgroundConvLoader", false), stopCh: make(chan struct{}), - suspendCh: make(chan chan struct{}, 10), + suspendCh: make(chan struct{}, 1), + eg: new(errgroup.Group), identNotifier: NewCachingIdentifyNotifier(g), clock: clockwork.NewRealClock(), resumeWait: time.Second, @@ -154,9 +162,6 @@ func NewBackgroundConvLoader(g *globals.Context) *BackgroundConvLoader { } b.identNotifier.ResetOnGUIConnect() b.newQueue() - stopCh := b.stopCh - go func() { _ = b.monitorAppState(stopCh) }() - return b } @@ -170,80 +175,74 @@ func (b *BackgroundConvLoader) removeActiveLoadLocked(key string) { delete(b.activeLoads, key) } -func (b *BackgroundConvLoader) monitorAppState(stopCh chan struct{}) error { - ctx := context.Background() - b.Debug(ctx, "monitorAppState: starting up") - - suspended := false - state := keybase1.MobileAppState_FOREGROUND - for { - select { - case <-b.G().MobileAppState.NextUpdate(state): - state = b.G().MobileAppState.State() - switch state { - case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE: - b.Debug(ctx, "monitorAppState: active state: %v", state) - // Only resume if we had suspended earlier (frontend can spam us with these) - if suspended { - b.Debug(ctx, "monitorAppState: resuming load thread") - b.Resume(ctx) - suspended = false - } - case keybase1.MobileAppState_BACKGROUND: - b.Debug(ctx, "monitorAppState: backgrounded, suspending load thread") - if !suspended { - b.Suspend(ctx) - suspended = true - } - } - if b.appStateCh != nil { - b.appStateCh <- struct{}{} - } - case <-stopCh: - b.Debug(ctx, "monitorAppState: shutting down") - return nil - } - } +// suspendInAppState is whether background loads pause in state. INACTIVE +// (Control Center, system alerts) keeps loading, as does BACKGROUNDACTIVE. +func suspendInAppState(state keybase1.MobileAppState) bool { + return state == keybase1.MobileAppState_BACKGROUND } +// Start replaces any current run with one for uid, once the previous run's +// goroutines have exited. The last Start or Stop wins: a Start that a later +// Start or Stop overtook while it waited returns without starting a run. func (b *BackgroundConvLoader) Start(ctx context.Context, uid gregor1.UID) { - b.Lock() - defer b.Unlock() - if b.G().GetEnv().GetDisableBgConvLoader() { b.Debug(ctx, "BackgroundConvLoader disabled, aborting Start") return } b.Debug(ctx, "Start") - if b.started { - close(b.stopCh) - b.stopCh = make(chan struct{}) + b.Lock() + b.gen++ + gen := b.gen + prevRun := b.endRunLocked() + b.Unlock() + + // The previous run's goroutines take b's lock, so wait for them outside it. + _ = prevRun.Wait() + + b.Lock() + defer b.Unlock() + if b.gen != gen { + b.Debug(ctx, "Start: overtaken by a later Start or Stop") + return + } + // A wake-up the previous run never read would park this run's loop; a + // suspension still in force parks it anyway, through suspendCount. + select { + case <-b.suspendCh: + default: } b.newQueue() b.started = true b.uid = uid - stopCh := b.stopCh - b.eg.Go(func() error { return b.loop(uid, stopCh) }) - b.eg.Go(func() error { return b.loadLoop(uid, stopCh) }) + b.eg = new(errgroup.Group) + stopCh, eg, queue, loadCh := b.stopCh, b.eg, b.queue, b.loadCh + eg.Go(func() error { return b.loop(uid, stopCh, queue, loadCh) }) + eg.Go(func() error { return b.loadLoop(uid, stopCh, queue, loadCh) }) +} + +// endRunLocked ends the current run, if there is one, and returns the group of +// the last run's goroutines. +func (b *BackgroundConvLoader) endRunLocked() *errgroup.Group { + if b.started { + b.started = false + b.cancelActiveLoadsLocked() + close(b.stopCh) + b.stopCh = make(chan struct{}) + } + return b.eg } func (b *BackgroundConvLoader) Stop(ctx context.Context) chan struct{} { b.Lock() defer b.Unlock() b.Debug(ctx, "Stop") - b.cancelActiveLoadsLocked() + b.gen++ + eg := b.endRunLocked() ch := make(chan struct{}) - if b.started { - b.started = false - close(b.stopCh) - b.stopCh = make(chan struct{}) - go func() { - _ = b.eg.Wait() - close(ch) - }() - } else { + go func() { + _ = eg.Wait() close(ch) - } + }() return ch } @@ -284,12 +283,11 @@ func (b *BackgroundConvLoader) Suspend(ctx context.Context) (canceled bool) { return false } if b.suspendCount == 0 { - b.Debug(ctx, "Suspend: sending on suspendCh") + b.Debug(ctx, "Suspend: waking loop") b.resumeCh = make(chan struct{}) select { - case b.suspendCh <- b.resumeCh: + case b.suspendCh <- struct{}{}: default: - b.Debug(ctx, "Suspend: failed to suspend loop") } } b.suspendCount++ @@ -300,21 +298,20 @@ func (b *BackgroundConvLoader) Resume(ctx context.Context) bool { defer b.Trace(ctx, nil, "Resume")() b.Lock() defer b.Unlock() + if b.suspendCount == 0 { + return false + } + b.suspendCount-- if b.suspendCount > 0 { - b.suspendCount-- - if b.suspendCount == 0 && b.resumeCh != nil { - b.Debug(ctx, "Resume: closing resumeCh") - close(b.resumeCh) - return true - } + return false } - return false + b.Debug(ctx, "Resume: closing resumeCh") + close(b.resumeCh) + return true } -func (b *BackgroundConvLoader) isSuspended() bool { - b.Lock() - defer b.Unlock() - return b.suspendCount > 0 +func (b *BackgroundConvLoader) suspendedLocked() bool { + return b.suspendCount > 0 || suspendInAppState(b.G().MobileAppState.State()) } func (b *BackgroundConvLoader) isRunning() bool { @@ -326,8 +323,20 @@ func (b *BackgroundConvLoader) isRunning() bool { func (b *BackgroundConvLoader) enqueue(ctx context.Context, task clTask) error { b.Lock() defer b.Unlock() + return b.push(ctx, b.queue, task) +} + +// requeue puts a task back on the queue of the run that loaded it. Once that +// run has stopped, nobody reads its queue. +func (b *BackgroundConvLoader) requeue(ctx context.Context, queue *jobQueue, task clTask) { + if err := b.push(ctx, queue, task); err != nil { + b.Debug(ctx, "enqueue error %s", err) + } +} + +func (b *BackgroundConvLoader) push(ctx context.Context, queue *jobQueue, task clTask) error { b.Debug(ctx, "enqueue: adding task: %s", task.job) - queued, err := b.queue.Push(task) + queued, err := queue.Push(task) if err != nil { return err } @@ -337,35 +346,79 @@ func (b *BackgroundConvLoader) enqueue(ctx context.Context, task clTask) error { return nil } -func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}) error { +func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}, queue *jobQueue, + loadCh chan *clTask, +) error { bgctx := context.Background() b.Debug(bgctx, "loop: starting conv loader loop for %s", uid) - - // waitForResume is called on suspend. It will wait for a resume event, and then pause - // for b.resumeWait amount of time. Returns false if the outer loop should shutdown. - waitForResume := func(ch chan struct{}) bool { - b.Debug(bgctx, "waitForResume: suspending loop") - select { - case <-ch: - case <-stopCh: + appState := b.G().MobileAppState + state := appState.State() + + // appStateChanged reads the new app state and reports whether it suspends + // the loop. Nothing else watches the app state, so going to BACKGROUND + // cancels active loads here, at once. + appStateChanged := func() (suspended bool) { + state = appState.State() + if !suspendInAppState(state) { return false } - b.clock.Sleep(libkb.RandomJitter(b.resumeWait)) - b.Debug(bgctx, "waitForResume: resuming loop") + b.Debug(bgctx, "loop: suspending in %v", state) + b.Lock() + b.cancelActiveLoadsLocked() + b.Unlock() return true } - // On mobile fresh start, apply the foreground wait - if b.G().IsMobileAppType() { - b.Debug(bgctx, "loop: delaying startup since on mobile") - b.clock.Sleep(libkb.RandomJitter(b.resumeWait)) + // suspension reports whether the loop is held, with the channel Resume + // closes when a Suspend holds it. + suspension := func() (held bool, resumeCh chan struct{}) { + b.Lock() + defer b.Unlock() + if b.suspendCount > 0 { + return true, b.resumeCh + } + return suspendInAppState(state), nil + } + // waitForResume parks the loop until neither Suspend nor the app state + // holds it, then waits for b.resumeWait with jitter. Returns false if the + // run stopped. + waitForResume := func() bool { + b.Debug(bgctx, "waitForResume: suspending loop") + var resumeDelay <-chan time.Time + for { + held, resumeCh := suspension() + switch { + case held: + resumeDelay = nil + case resumeDelay == nil: + resumeDelay = b.clock.After(libkb.RandomJitter(b.resumeWait)) + } + select { + case <-resumeCh: + case <-b.suspendCh: + case <-resumeDelay: + b.Debug(bgctx, "waitForResume: resuming loop") + return true + case <-appState.NextUpdate(state): + appStateChanged() + case <-stopCh: + return false + } + } + } + // Park if already suspended, and on a mobile fresh start apply the + // foreground wait. + if held, _ := suspension(); held || b.G().IsMobileAppType() { + if !waitForResume() { + return nil + } } // Main loop for { b.Debug(bgctx, "loop: waiting for job") select { - case <-b.queue.Wait(): - task, ok := b.queue.PopFront() + case <-queue.Wait(): + task, ok := queue.PopFront() if !ok { continue } @@ -383,21 +436,32 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}) error // neither have any data on them. select { case <-b.clock.After(duration): - case ch := <-b.suspendCh: + case <-b.suspendCh: b.Debug(bgctx, "loop: pulled queue task, but suspended, so waiting") - if !waitForResume(ch) { + if !waitForResume() { return nil } + case <-appState.NextUpdate(state): + if appStateChanged() && !waitForResume() { + return nil + } + case <-stopCh: + b.Debug(bgctx, "loop: shutting down for %s", uid) + return nil } b.Debug(bgctx, "loop: pulled queued task: %s", task.job) select { - case b.loadCh <- &task: + case loadCh <- &task: default: b.Debug(bgctx, "loop: failed to dispatch load, queue full") } - case ch := <-b.suspendCh: + case <-b.suspendCh: b.Debug(bgctx, "loop: received suspend") - if !waitForResume(ch) { + if !waitForResume() { + return nil + } + case <-appState.NextUpdate(state): + if appStateChanged() && !waitForResume() { return nil } case <-stopCh: @@ -407,31 +471,23 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}) error } } -func (b *BackgroundConvLoader) loadLoop(uid gregor1.UID, stopCh chan struct{}) error { +func (b *BackgroundConvLoader) loadLoop(uid gregor1.UID, stopCh chan struct{}, queue *jobQueue, + loadCh chan *clTask, +) error { bgctx := context.Background() b.Debug(bgctx, "loadLoop: starting for uid: %s", uid) for { select { - case task := <-b.loadCh: - switch { - case !b.isRunning(): + case task := <-loadCh: + if nextTask := b.load(bgctx, stopCh, *task, uid); nextTask != nil { + b.requeue(bgctx, queue, *nextTask) + } + select { + case <-b.clock.After(b.loadWait): + case <-stopCh: b.Debug(bgctx, "loadLoop: shutting down for %s", uid) return nil - case b.isSuspended(): - b.Debug(bgctx, "loadLoop: suspended, re-enqueueing task: %s", task.job) - if err := b.enqueue(bgctx, *task); err != nil { - b.Debug(bgctx, "enqueue error %s", err) - } - default: - b.Debug(bgctx, "loadLoop: running task: %s", task.job) - nextTask := b.load(bgctx, *task, uid) - if nextTask != nil { - if err := b.enqueue(bgctx, *nextTask); err != nil { - b.Debug(bgctx, "enqueue error %s", err) - } - } } - b.clock.Sleep(b.loadWait) case <-stopCh: b.Debug(bgctx, "loadLoop: shutting down for %s", uid) return nil @@ -465,10 +521,28 @@ func (b *BackgroundConvLoader) IsBackgroundActive() bool { return len(b.activeLoads) > 0 } -func (b *BackgroundConvLoader) load(ictx context.Context, task clTask, uid gregor1.UID) *clTask { +// load runs task unless its run has stopped, and returns a task to requeue: +// task itself while suspended, or its retry. +func (b *BackgroundConvLoader) load(ictx context.Context, stopCh chan struct{}, task clTask, + uid gregor1.UID, +) *clTask { + b.Lock() + // Checked under the lock that cancels active loads, so a load either sees + // the stop or the suspension here, or is registered in time to be canceled. + select { + case <-stopCh: + b.Unlock() + b.Debug(ictx, "load: run stopped, dropping task: %s", task.job) + return nil + default: + } + if b.suspendedLocked() { + b.Unlock() + b.Debug(ictx, "load: suspended, re-enqueueing task: %s", task.job) + return &task + } defer b.Trace(ictx, nil, "load: %s", task.job)() defer b.PerfTrace(ictx, nil, "load: %s", task.job)() - b.Lock() var al activeLoad al.Ctx, al.CancelFn = context.WithCancel( globals.ChatCtx(utils.MakeConvLoaderContext(ictx), b.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, diff --git a/go/chat/convloader_appstate_test.go b/go/chat/convloader_appstate_test.go new file mode 100644 index 000000000000..f9de567b20e6 --- /dev/null +++ b/go/chat/convloader_appstate_test.go @@ -0,0 +1,604 @@ +package chat + +import ( + "context" + "math/rand" + "runtime" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/externalstest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/clockwork" + "github.com/stretchr/testify/require" +) + +type appStateCtxFactory struct{} + +func (appStateCtxFactory) NewKeyFinder() types.KeyFinder { return nil } +func (appStateCtxFactory) NewUPAKFinder() types.UPAKFinder { return nil } + +// pullRecorder is the only part of ConversationSource a background load +// reaches; anything else panics on the nil embedded interface. +type pullRecorder struct { + types.ConversationSource + pulls chan chat1.ConversationID +} + +func (p *pullRecorder) Pull(ctx context.Context, convID chat1.ConversationID, uid gregor1.UID, + reason chat1.GetThreadReason, customRi func() chat1.RemoteInterface, query *chat1.GetThreadQuery, + pagination *chat1.Pagination, +) (chat1.ThreadView, error) { + select { + case p.pulls <- convID: + default: + } + return chat1.ThreadView{}, nil +} + +func setupAppStateConvLoader(t *testing.T) (*BackgroundConvLoader, *pullRecorder, libkb.TestContext) { + tc := externalstest.SetupTest(t, "convloader-appstate", 0) + t.Cleanup(tc.Cleanup) + tc.G.ConnectionManager = libkb.NewConnectionManager() + pulls := &pullRecorder{pulls: make(chan chat1.ConversationID, 100)} + g := globals.NewContext(tc.G, &globals.ChatContext{ + CtxFactory: appStateCtxFactory{}, + ConvSource: pulls, + }) + b := NewBackgroundConvLoader(g) + b.resumeWait = time.Millisecond + b.loadWait = time.Millisecond + return b, pulls, tc +} + +func requireConvLoaderStopped(t *testing.T, b *BackgroundConvLoader) { + t.Helper() + select { + case <-b.Stop(context.TODO()): + case <-time.After(10 * time.Second): + require.FailNow(t, "Stop did not finish") + } +} + +var convLoaderTestConvID = chat1.ConversationID([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}) + +func convLoaderTestJob() types.ConvLoaderJob { + return types.NewConvLoaderJob(convLoaderTestConvID, &chat1.Pagination{Num: 1}, + types.ConvLoaderPriorityHigh, types.ConvLoaderGeneric, nil) +} + +// A load checks for a stop and a suspension under the lock that cancels +// active loads, so it never starts after either. +func TestConvLoaderLoadChecksStopAndSuspension(t *testing.T) { + b, pulls, tc := setupAppStateConvLoader(t) + uid := gregor1.UID([]byte{1, 2, 3, 4}) + task := clTask{job: convLoaderTestJob()} + + stopped := make(chan struct{}) + close(stopped) + require.Nil(t, b.load(context.TODO(), stopped, task, uid)) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + next := b.load(context.TODO(), make(chan struct{}), task, uid) + require.NotNil(t, next) + require.Equal(t, task.job.ConvID, next.job.ConvID) + require.Zero(t, next.attempt) + + select { + case <-pulls.pulls: + require.FailNow(t, "loaded after a stop or in BACKGROUND") + default: + } +} + +// Stop does not wait for the loop's delay before dispatching a job. +func TestConvLoaderStopDuringLoadDelay(t *testing.T) { + b, _, _ := setupAppStateConvLoader(t) + clock := clockwork.NewFakeClock() + b.clock = clock + b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + clock.BlockUntil(1) + requireConvLoaderStopped(t, b) +} + +// The loop also watches the app state while it waits out the delay before +// dispatching the next job, with the previous job still loading. +func TestConvLoaderBackgroundCancelsDuringLoadDelay(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + pulls := newCtxPuller(false) + b.G().ConvSource = pulls + clock := clockwork.NewFakeClock() + b.clock = clock + b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireConvLoaderStopped(t, b) + defer close(pulls.release) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + clock.BlockUntil(1) + clock.Advance(bgLoaderInitDelay) + load := requirePull(t, pulls) + + otherConvID := chat1.ConversationID([]byte{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}) + require.NoError(t, b.Queue(context.TODO(), types.NewConvLoaderJob(otherConvID, &chat1.Pagination{Num: 1}, + types.ConvLoaderPriorityHigh, types.ConvLoaderGeneric, nil))) + // the loop has pulled the second job and waits out its delay + clock.BlockUntil(1) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + select { + case <-load.ctx.Done(): + case <-time.After(100 * time.Millisecond): + require.FailNow(t, "active load not canceled on BACKGROUND") + } +} + +// Each run's loop watches the app state: BACKGROUND cancels its active load +// and parks it, and leaving BACKGROUND loads the retry. +func TestConvLoaderAppStateAcrossRuns(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + pulls := newCtxPuller(false) + b.G().ConvSource = pulls + defer close(pulls.release) + uid := gregor1.UID([]byte{1, 2, 3, 4}) + appState := tc.G.MobileAppState + requireCanceled := func(i int, load pullCall) { + t.Helper() + select { + case <-load.ctx.Done(): + case <-time.After(10 * time.Second): + require.FailNow(t, "load not canceled in BACKGROUND", "run %d", i) + } + } + for i := range 3 { + appState.Update(keybase1.MobileAppState_FOREGROUND) + b.Start(context.TODO(), uid) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + load := requirePull(t, pulls) + + appState.Update(keybase1.MobileAppState_BACKGROUND) + requireCanceled(i, load) + + appState.Update(keybase1.MobileAppState_INACTIVE) + load = requirePull(t, pulls) + + appState.Update(keybase1.MobileAppState_BACKGROUND) + requireCanceled(i, load) + requireConvLoaderStopped(t, b) + + // A run started in BACKGROUND loads nothing until the app leaves it. + b.Start(context.TODO(), uid) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + select { + case <-pulls.calls: + require.FailNow(t, "loaded in BACKGROUND", "run %d", i) + case <-time.After(300 * time.Millisecond): + } + appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + load = requirePull(t, pulls) + appState.Update(keybase1.MobileAppState_BACKGROUND) + requireCanceled(i, load) + requireConvLoaderStopped(t, b) + } +} + +func TestConvLoaderBackgroundLaunchStaysSuspended(t *testing.T) { + b, pulls, tc := setupAppStateConvLoader(t) + uid := gregor1.UID([]byte{1, 2, 3, 4}) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + require.False(t, b.Suspend(context.TODO()), "Suspend before Start") + require.False(t, b.Resume(context.TODO())) + b.Start(context.TODO(), uid) + defer requireConvLoaderStopped(t, b) + + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + select { + case <-pulls.pulls: + require.FailNow(t, "loaded in BACKGROUND") + case <-time.After(300 * time.Millisecond): + } + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + select { + case convID := <-pulls.pulls: + require.Equal(t, convLoaderTestConvID, convID) + case <-time.After(10 * time.Second): + require.FailNow(t, "no load after FOREGROUND") + } +} + +// An unbalanced Resume, or a Suspend and Resume pair, must not release the +// app-state suspension. +func TestConvLoaderResumeKeepsAppStateSuspension(t *testing.T) { + b, pulls, tc := setupAppStateConvLoader(t) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireConvLoaderStopped(t, b) + require.False(t, b.Resume(context.TODO())) + b.Suspend(context.TODO()) + require.True(t, b.Resume(context.TODO())) + + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + select { + case <-pulls.pulls: + require.FailNow(t, "loaded in BACKGROUND") + case <-time.After(300 * time.Millisecond): + } + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + select { + case convID := <-pulls.pulls: + require.Equal(t, convLoaderTestConvID, convID) + case <-time.After(10 * time.Second): + require.FailNow(t, "no load after FOREGROUND") + } +} + +// A Suspend's wake-up that the previous run never read doesn't park the next +// run's loop. +func TestConvLoaderStartDropsStaleSuspendWake(t *testing.T) { + b, pulls, _ := setupAppStateConvLoader(t) + b.resumeWait = time.Hour + b.suspendCh <- struct{}{} + b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireConvLoaderStopped(t, b) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + select { + case convID := <-pulls.pulls: + require.Equal(t, convLoaderTestConvID, convID) + case <-time.After(10 * time.Second): + require.FailNow(t, "no load: the stale wake-up parked the loop") + } +} + +// A Stop that comes while a Start waits for the previous run wins: it waits +// for that run too, and the Start does not start a new one. +func TestConvLoaderStopOvertakesWaitingStart(t *testing.T) { + b, _, _ := setupAppStateConvLoader(t) + pulls := newCtxPuller(true) + b.G().ConvSource = pulls + uid := gregor1.UID([]byte{1, 2, 3, 4}) + b.Start(context.TODO(), uid) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + requirePull(t, pulls) + + started := make(chan struct{}) + go func() { + b.Start(context.TODO(), uid) + close(started) + }() + // the waiting Start has ended the previous run + require.Eventually(t, func() bool { return !b.isRunning() }, 10*time.Second, time.Millisecond) + stopped := b.Stop(context.TODO()) + select { + case <-stopped: + require.FailNow(t, "Stop finished while the previous run was still running") + case <-time.After(200 * time.Millisecond): + } + close(pulls.release) + for _, ch := range []chan struct{}{started, stopped} { + select { + case <-ch: + case <-time.After(10 * time.Second): + require.FailNow(t, "Start or Stop did not return") + } + } + require.False(t, b.isRunning(), "an overtaken Start started a run") +} + +// Of two Starts waiting for the previous run, the later one's run is the one +// that starts. +func TestConvLoaderLastWaitingStartWins(t *testing.T) { + b, _, _ := setupAppStateConvLoader(t) + pulls := newCtxPuller(true) + b.G().ConvSource = pulls + baseline := runtime.NumGoroutine() + oldUID := gregor1.UID([]byte{1, 2, 3, 4}) + uids := []gregor1.UID{{5, 6, 7, 8}, {9, 10, 11, 12}} + b.Start(context.TODO(), oldUID) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + requirePull(t, pulls) + + var wg sync.WaitGroup + for i, uid := range uids { + wg.Go(func() { b.Start(context.TODO(), uid) }) + require.Eventually(t, func() bool { + b.Lock() + defer b.Unlock() + return b.gen == uint64(i+2) + }, 10*time.Second, time.Millisecond, "Start %d did not begin waiting", i) + } + close(pulls.release) + wg.Wait() + require.True(t, b.isRunning()) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + require.Equal(t, uids[1], requirePull(t, pulls).uid) + // the overtaken Start left no run of its own behind + requireConvLoaderStopped(t, b) + requireNoGoroutineLeak(t, baseline) +} + +// A suspension that outlives a run parks the next run's loop before it +// takes anything off the queue. +func TestConvLoaderSuspensionCarriesIntoNextRun(t *testing.T) { + uid := gregor1.UID([]byte{1, 2, 3, 4}) + for _, tt := range []struct { + name string + suspend func(*BackgroundConvLoader, libkb.TestContext) + }{ + {"background", func(_ *BackgroundConvLoader, tc libkb.TestContext) { + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + }}, + {"suspend", func(b *BackgroundConvLoader, _ libkb.TestContext) { + require.False(t, b.Suspend(context.TODO())) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + clock := clockwork.NewFakeClock() + b.clock = clock + b.Start(context.TODO(), uid) + tt.suspend(b, tc) + requireConvLoaderStopped(t, b) + b.Start(context.TODO(), uid) + defer requireConvLoaderStopped(t, b) + + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + // A loop that pulls the job waits out its delay on the clock. + blocked := make(chan struct{}) + go func() { + clock.BlockUntil(1) + close(blocked) + }() + defer clock.After(time.Hour) + select { + case <-blocked: + require.FailNow(t, "loop pulled a job while suspended") + case <-time.After(300 * time.Millisecond): + } + b.Lock() + queued := b.queue.queue.Len() + b.Unlock() + require.Equal(t, 1, queued, "queue drained while suspended") + }) + } +} + +// pullBlocker fails the first load of the old user's conversation once +// released, so the old run asks to retry it. +type pullBlocker struct { + types.ConversationSource + oldUID gregor1.UID + started chan struct{} + release chan struct{} + + mu sync.Mutex + uids []gregor1.UID +} + +func (p *pullBlocker) Pull(ctx context.Context, convID chat1.ConversationID, uid gregor1.UID, + reason chat1.GetThreadReason, customRi func() chat1.RemoteInterface, query *chat1.GetThreadQuery, + pagination *chat1.Pagination, +) (chat1.ThreadView, error) { + p.mu.Lock() + p.uids = append(p.uids, uid) + p.mu.Unlock() + if uid.Eq(p.oldUID) { + close(p.started) + <-p.release + return chat1.ThreadView{}, context.Canceled + } + return chat1.ThreadView{}, nil +} + +func TestConvLoaderReplacedRunRetryStaysInItsRun(t *testing.T) { + b, _, _ := setupAppStateConvLoader(t) + oldUID, newUID := gregor1.UID([]byte{1, 2, 3, 4}), gregor1.UID([]byte{5, 6, 7, 8}) + pulls := &pullBlocker{oldUID: oldUID, started: make(chan struct{}), release: make(chan struct{})} + b.G().ConvSource = pulls + b.Start(context.TODO(), oldUID) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + select { + case <-pulls.started: + case <-time.After(10 * time.Second): + require.FailNow(t, "old run did not load") + } + started := make(chan struct{}) + go func() { + b.Start(context.TODO(), newUID) + close(started) + }() + defer requireConvLoaderStopped(t, b) + close(pulls.release) + select { + case <-started: + case <-time.After(10 * time.Second): + require.FailNow(t, "Start did not return") + } + + // past the retry delay and the new run's load delays + time.Sleep(time.Second) + b.Lock() + queued := b.queue.queue.Len() + b.Unlock() + require.Zero(t, queued, "old run's retry reached the new queue") + pulls.mu.Lock() + defer pulls.mu.Unlock() + require.Equal(t, []gregor1.UID{oldUID}, pulls.uids) +} + +func TestConvLoaderAppStateStress(t *testing.T) { + b, pulls, tc := setupAppStateConvLoader(t) + baseline := runtime.NumGoroutine() + uid := gregor1.UID([]byte{1, 2, 3, 4}) + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } + done := make(chan struct{}) + go func() { + defer close(done) + var wg sync.WaitGroup + for w := range 4 { + wg.Go(func() { + rng := rand.New(rand.NewSource(int64(w))) + for range 300 { + tc.G.MobileAppState.Update(states[rng.Intn(len(states))]) + } + }) + } + for w := range 4 { + wg.Go(func() { + rng := rand.New(rand.NewSource(int64(100 + w))) + for range 150 { + switch rng.Intn(4) { + case 0: + b.Start(context.TODO(), uid) + case 1: + <-b.Stop(context.TODO()) + case 2: + _ = b.Queue(context.TODO(), convLoaderTestJob()) + default: + b.Suspend(context.TODO()) + b.Resume(context.TODO()) + } + } + }) + } + wg.Wait() + }() + select { + case <-done: + case <-time.After(60 * time.Second): + require.FailNow(t, "deadlock") + } + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + b.Start(context.TODO(), uid) + // the loader still loads once the churn is over + for len(pulls.pulls) > 0 { + <-pulls.pulls + } + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + select { + case <-pulls.pulls: + case <-time.After(10 * time.Second): + require.FailNow(t, "no load after the churn") + } + requireConvLoaderStopped(t, b) + requireNoGoroutineLeak(t, baseline) +} + +// requireNoGoroutineLeak polls without require.Eventually, whose own +// goroutines would count against the baseline. +func requireNoGoroutineLeak(t *testing.T, baseline int) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "leaked goroutines") +} + +type pullCall struct { + ctx context.Context + uid gregor1.UID +} + +// ctxPuller hands each load to the test and holds it until release closes, +// or until its ctx is canceled unless ignoreCancel is set. +type ctxPuller struct { + types.ConversationSource + calls chan pullCall + release chan struct{} + ignoreCancel bool +} + +func newCtxPuller(ignoreCancel bool) *ctxPuller { + return &ctxPuller{ + calls: make(chan pullCall, 100), + release: make(chan struct{}), + ignoreCancel: ignoreCancel, + } +} + +func (p *ctxPuller) Pull(ctx context.Context, convID chat1.ConversationID, uid gregor1.UID, + reason chat1.GetThreadReason, customRi func() chat1.RemoteInterface, query *chat1.GetThreadQuery, + pagination *chat1.Pagination, +) (chat1.ThreadView, error) { + p.calls <- pullCall{ctx: ctx, uid: uid} + done := ctx.Done() + if p.ignoreCancel { + done = nil + } + select { + case <-p.release: + case <-done: + } + return chat1.ThreadView{}, ctx.Err() +} + +func requirePull(t *testing.T, p *ctxPuller) pullCall { + t.Helper() + select { + case call := <-p.calls: + return call + case <-time.After(10 * time.Second): + require.FailNow(t, "no load") + return pullCall{} + } +} + +func TestConvLoaderStartWaitsForPreviousRun(t *testing.T) { + b, _, _ := setupAppStateConvLoader(t) + pulls := newCtxPuller(true) + b.G().ConvSource = pulls + uid := gregor1.UID([]byte{1, 2, 3, 4}) + b.Start(context.TODO(), uid) + defer requireConvLoaderStopped(t, b) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + load := requirePull(t, pulls) + + started := make(chan struct{}) + go func() { + b.Start(context.TODO(), uid) + close(started) + }() + select { + case <-started: + require.FailNow(t, "Start returned while the previous run's load was still running") + case <-time.After(200 * time.Millisecond): + } + require.Error(t, load.ctx.Err(), "Start did not cancel the previous run's load") + close(pulls.release) + select { + case <-started: + case <-time.After(10 * time.Second): + require.FailNow(t, "Start did not return after the previous run exited") + } + require.True(t, b.isRunning()) +} + +func TestConvLoaderBackgroundCancelsActiveLoadImmediately(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + pulls := newCtxPuller(false) + b.G().ConvSource = pulls + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireConvLoaderStopped(t, b) + defer close(pulls.release) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + load := requirePull(t, pulls) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + select { + case <-load.ctx.Done(): + case <-time.After(100 * time.Millisecond): + require.FailNow(t, "active load not canceled on BACKGROUND") + } +} diff --git a/go/chat/convloader_test.go b/go/chat/convloader_test.go index 46ea15ade033..e152b79e84b5 100644 --- a/go/chat/convloader_test.go +++ b/go/chat/convloader_test.go @@ -134,15 +134,17 @@ func TestConvLoaderAppState(t *testing.T) { defer world.Cleanup() clock := clockwork.NewFakeClock() - appStateCh := make(chan struct{}) - tc.ChatG.ConvLoader.(*BackgroundConvLoader).loadWait = 0 - tc.ChatG.ConvLoader.(*BackgroundConvLoader).clock = clock - tc.ChatG.ConvLoader.(*BackgroundConvLoader).appStateCh = appStateCh + uid := gregor1.UID(tc.G.Env.GetUID().ToBytes()) + // The loops read these, so set them while the loader is stopped. + loader := tc.ChatG.ConvLoader.(*BackgroundConvLoader) + <-loader.Stop(context.TODO()) + loader.loadWait = 0 + loader.clock = clock + loader.Start(context.TODO(), uid) ri := tc.ChatG.ConvSource.(*HybridConversationSource).ri _ = ri slowRi := makeSlowestRemote() failDuration := 2 * time.Second - uid := gregor1.UID(tc.G.Env.GetUID().ToBytes()) // Test that a foreground with no background doesnt do anything tc.ChatG.ConvSource.(*HybridConversationSource).ri = func() chat1.RemoteInterface { return slowRi @@ -160,11 +162,6 @@ func TestConvLoaderAppState(t *testing.T) { require.True(t, tc.Context().ConvLoader.Suspend(context.TODO())) tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) select { - case <-appStateCh: - require.Fail(t, "no app state") - default: - } - select { case <-listener.bgConvLoads: require.Fail(t, "no load yet") default: @@ -199,18 +196,10 @@ func TestConvLoaderAppState(t *testing.T) { require.Fail(t, "no remote call") } tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - select { - case <-appStateCh: - case <-time.After(failDuration): - require.Fail(t, "no app state") - } + // the loop cancels the active load + require.Eventually(t, func() bool { return !loader.IsBackgroundActive() }, failDuration, time.Millisecond) tc.ChatG.ConvSource.(*HybridConversationSource).ri = ri tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - select { - case <-appStateCh: - case <-time.After(failDuration): - require.Fail(t, "no app state") - } // Need to advance clock select { case <-listener.bgConvLoads: diff --git a/go/chat/deliverer.go b/go/chat/deliverer.go index 29de3216a823..bb8db6e3536c 100644 --- a/go/chat/deliverer.go +++ b/go/chat/deliverer.go @@ -355,9 +355,7 @@ func (s *Deliverer) doNotRetryFailure(ctx context.Context, obr chat1.OutboxRecor return 0, err, false case net.Error: s.Debug(ctx, "doNotRetryFailure: generic net error, reconnecting to the server: %s(%T)", berr, berr) - if _, rerr := s.serverConn.Reconnect(ctx); rerr != nil { - s.Debug(ctx, "doNotRetryFailure: failed to reconnect: %s", rerr) - } + s.serverConn.Reconnect(ctx) return chat1.OutboxErrorType_OFFLINE, err, !berr.Temporary() //nolint } if errors.Is(err, ErrChatServerTimeout) || errors.Is(err, ErrDuplicateConnection) || diff --git a/go/chat/globals/globals.go b/go/chat/globals/globals.go index 911f042cd739..a5736f4babaf 100644 --- a/go/chat/globals/globals.go +++ b/go/chat/globals/globals.go @@ -34,6 +34,7 @@ type ChatContext struct { AttachmentUploader types.AttachmentUploader // upload attachments NativeVideoHelper types.NativeVideoHelper // connection to native for doing things with video ShareIntentDonator types.ShareIntentDonator // donate share sheet suggestions (iOS only) + LocationWatcher types.LocationWatcher // native location service for live location (iOS only) StellarLoader types.StellarLoader // stellar payment/request loader StellarSender types.StellarSender // stellar in-chat payment sender StellarPushHandler types.OobmHandler diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 76e24d886e23..2f88edb8e7e4 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "time" @@ -12,6 +13,7 @@ import ( "github.com/keybase/client/go/chat/types" "github.com/keybase/client/go/chat/utils" "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/gregor1" "github.com/keybase/client/go/protocol/keybase1" @@ -28,10 +30,21 @@ type LiveLocationTracker struct { storage *trackStorage updateInterval time.Duration uid gregor1.UID - eg errgroup.Group trackers map[types.LiveLocationKey]*locationTrack lastCoord chat1.Coordinate maxCoords int + // eg runs the trackers started since the last Stop, which replaces it and + // waits on the old one: a tracker can start at any time, even before Start, + // and must not join a group that a Stop is already waiting on. + eg *errgroup.Group + // bgHold keeps the app running while tracking; guarded by the tracker's + // mutex and changed only by releaseHoldIfIdleLocked and + // ensureHoldOnFixLocked. + bgHold *lifecycle.Hold + + nativeWatchMu sync.Mutex + nativeWatchRefs int + fixThrottle fixThrottle // testing only TestingCoordsAddedCh chan struct{} @@ -47,6 +60,7 @@ func NewLiveLocationTracker(g *globals.Context) *LiveLocationTracker { updateInterval: 30 * time.Second, maxCoords: 500, clock: clockwork.NewRealClock(), + eg: new(errgroup.Group), } } @@ -69,8 +83,10 @@ func (l *LiveLocationTracker) Stop(ctx context.Context) chan struct{} { for _, t := range l.trackers { t.Stop() } + eg := l.eg + l.eg = new(errgroup.Group) go func() { - _ = l.eg.Wait() + _ = eg.Wait() close(ch) }() return ch @@ -92,6 +108,34 @@ func (l *LiveLocationTracker) saveLocked(ctx context.Context) { } } +func (l *LiveLocationTracker) removeTrackerLocked(ctx context.Context, t *locationTrack) { + delete(l.trackers, t.Key()) + l.saveLocked(ctx) + l.releaseHoldIfIdleLocked() +} + +// releaseHoldIfIdleLocked ends the hold once nothing is tracked. Every removal +// from the trackers map calls it. +func (l *LiveLocationTracker) releaseHoldIfIdleLocked() { + if len(l.trackers) == 0 && l.bgHold != nil { + l.bgHold.Release() + l.bgHold = nil + } +} + +// ensureHoldOnFixLocked opens a hold for a location fix, since the fix can +// wake a backgrounded app and the hold keeps it up until the update gets out. +// A hold the controller ended -- WillTerminate does, and nothing else -- is +// replaced, so a fix after one still gets the app held up. +func (l *LiveLocationTracker) ensureHoldOnFixLocked() { + if len(l.trackers) == 0 || !l.G().IsMobileAppType() { + return + } + if l.bgHold == nil || l.bgHold.Released() { + l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork() + } +} + func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { trackers, err := l.storage.Restore(ctx) if err != nil { @@ -102,6 +146,10 @@ func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { return } l.Debug(ctx, "restoreLocked: restored %d trackers", len(trackers)) + l.runRestoredLocked(trackers) +} + +func (l *LiveLocationTracker) runRestoredLocked(trackers []*locationTrack) { l.trackers = make(map[types.LiveLocationKey]*locationTrack) for _, t := range trackers { if t.IsStopped() { @@ -113,6 +161,16 @@ func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { return l.tracker(myT) }) } + // The replacement above can drop a hold's only tracker without ever + // running removeTrackerLocked for it, so release directly here rather + // than leaving the app held until some later fix notices. + l.releaseHoldIfIdleLocked() +} + +func (l *LiveLocationTracker) getLastCoord() chat1.Coordinate { + l.Lock() + defer l.Unlock() + return l.lastCoord } func (l *LiveLocationTracker) getChatUI(ctx context.Context) libkb.ChatUI { @@ -185,8 +243,8 @@ func (l *LiveLocationTracker) updateMapUnfurl(ctx context.Context, t *locationTr var coords []chat1.Coordinate trackerCoords := t.GetCoords() if len(trackerCoords) == 0 { - if !l.lastCoord.IsZero() { - coords = []chat1.Coordinate{l.lastCoord} + if lastCoord := l.getLastCoord(); !lastCoord.IsZero() { + coords = []chat1.Coordinate{lastCoord} } else { return errors.New("no coordinates") } @@ -235,58 +293,99 @@ func (l *LiveLocationTracker) updateMapUnfurl(ctx context.Context, t *locationTr return nil } -func (l *LiveLocationTracker) startWatch(ctx context.Context, t *locationTrack) (watchID chat1.LocationWatchID, err error) { +// startWatch starts OS location updates for t and returns the function that +// ends them. +func (l *LiveLocationTracker) startWatch(ctx context.Context, t *locationTrack) (watchID chat1.LocationWatchID, stop func(), err error) { + if w := l.G().LocationWatcher; w != nil { + l.acquireNativeWatch(w) + // The native watcher only checks authorization, it can't prompt. The chat + // UI, when there is one, asks for permission and reports a failure in the + // conversation; with no UI this does nothing. + if _, err := l.getChatUI(ctx).ChatWatchPosition(ctx, t.convID, t.perm); err != nil { + l.Debug(ctx, "startWatch: unable to request location permission: %s", err) + } + return 0, func() { l.releaseNativeWatch(w) }, nil + } + watchID, err = l.startChatUIWatch(ctx, t) + if err != nil { + return 0, nil, err + } + return watchID, func() { + if err := l.getChatUI(ctx).ChatClearWatch(ctx, watchID); err != nil { + l.Debug(ctx, "tracker[%v]: error clearing watch: %+v", watchID, err) + } + }, nil +} + +// acquireNativeWatch and releaseNativeWatch share one native watch among all +// trackers. The watcher is called under the lock so it sees starts and stops +// in order. +func (l *LiveLocationTracker) acquireNativeWatch(w types.LocationWatcher) { + l.nativeWatchMu.Lock() + defer l.nativeWatchMu.Unlock() + l.nativeWatchRefs++ + if l.nativeWatchRefs == 1 { + l.fixThrottle = fixThrottle{} + w.StartWatching() + } +} + +func (l *LiveLocationTracker) releaseNativeWatch(w types.LocationWatcher) { + l.nativeWatchMu.Lock() + defer l.nativeWatchMu.Unlock() + l.nativeWatchRefs-- + if l.nativeWatchRefs == 0 { + w.StopWatching() + } +} + +func (l *LiveLocationTracker) startChatUIWatch(ctx context.Context, t *locationTrack) (watchID chat1.LocationWatchID, err error) { // try this a couple times in case we are starting fresh and the UI isn't ready yet maxWatchAttempts := 20 watchAttempts := 0 for { if watchID, err = l.getChatUI(ctx).ChatWatchPosition(ctx, t.convID, t.perm); err != nil { - l.Debug(ctx, "startWatch: unable to watch position: attempt: %d msg: %s", watchAttempts, err) + l.Debug(ctx, "startChatUIWatch: unable to watch position: attempt: %d msg: %s", watchAttempts, err) if watchAttempts > maxWatchAttempts { return 0, err } } else { break } - maxWatchAttempts++ - time.Sleep(time.Second) + watchAttempts++ + l.clock.Sleep(time.Second) } return watchID, nil } func (l *LiveLocationTracker) tracker(t *locationTrack) error { ctx := context.Background() - // check to see if we are being asked to start a tracker that is already expired - if t.endTime.Before(l.clock.Now()) { + // Every exit removes the tracker, which also ends the background-work hold + // once no tracker remains. + defer func() { l.Lock() defer l.Unlock() - delete(l.trackers, t.Key()) - l.saveLocked(ctx) + l.removeTrackerLocked(ctx, t) + }() + // check to see if we are being asked to start a tracker that is already expired + if t.endTime.Before(l.clock.Now()) { l.Debug(ctx, "tracker: old tracker, not running and clearing") return errors.New("tracker from the past") } // start up the OS watch routine - watchID, err := l.startWatch(ctx, t) + watchID, stopWatch, err := l.startWatch(ctx, t) if err != nil { + l.Debug(ctx, "tracker: unable to start watching, clearing: %s", err) return err } - defer func() { - // drop everything when our live location ends - err := l.getChatUI(ctx).ChatClearWatch(ctx, watchID) - if err != nil { - l.Debug(ctx, "tracker[%v]: error clearing watch: %+v", watchID, err) - } - l.Lock() - defer l.Unlock() - delete(l.trackers, t.Key()) - l.saveLocked(ctx) - }() + // Deferred after the removal, so it runs first: stop watching, then remove. + defer stopWatch() // if this is a live location request, just put whatever the last coord is on the screen, makes it // feel more live - if !l.lastCoord.IsZero() { + if lastCoord := l.getLastCoord(); !lastCoord.IsZero() { l.Debug(ctx, "tracker[%v]: updating with last coord", watchID) - t.updateCh <- l.lastCoord + t.updateCh <- lastCoord } firstUpdate := true shouldUpdate := false @@ -369,19 +468,68 @@ func (l *LiveLocationTracker) StartTracking(ctx context.Context, convID chat1.Co l.eg.Go(func() error { return l.tracker(t) }) } +// backgroundFixDistance is how far, in meters, the device must move before a +// native fix is recorded while the app is not in the foreground. +const backgroundFixDistance = 65 + +// earthRadiusMeters is the mean radius of the Earth. +const earthRadiusMeters = 6371008.8 + +// fixThrottle is what shouldRecordFix knows of the fixes since the native +// watch started. +type fixThrottle struct { + // prev is the latest fix, recorded or not; nil until the first one. + prev *chat1.Coordinate + // pendingDistance is how far the device has moved, fix to fix, since the + // last recorded fix. + pendingDistance float64 +} + +// shouldRecordFix decides whether a native fix gets recorded, and returns the +// throttle to use for the next one. Out of the foreground a fix is recorded +// only once the device has moved backgroundFixDistance since the last one +// recorded. The first fix after the watch starts is recorded right away, so the +// move that relaunched the app gets posted. +func shouldRecordFix(state keybase1.MobileAppState, last fixThrottle, next chat1.Coordinate) (bool, fixThrottle) { + if last.prev != nil { + last.pendingDistance += distanceMeters(*last.prev, next) + } + record := last.prev == nil || state == keybase1.MobileAppState_FOREGROUND || + last.pendingDistance >= backgroundFixDistance + last.prev = &next + if record { + last.pendingDistance = 0 + } + return record, last +} + +// distanceMeters is the great-circle distance between a and b. +func distanceMeters(a, b chat1.Coordinate) float64 { + rad := func(deg float64) float64 { return deg * math.Pi / 180 } + dLat := rad(b.Lat - a.Lat) + dLon := rad(b.Lon - a.Lon) + h := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2) + return 2 * earthRadiusMeters * math.Asin(math.Min(1, math.Sqrt(h))) +} + +// NativeLocationUpdate takes a fix from the native location watcher, which +// reports every fix, and records the ones shouldRecordFix lets through. +func (l *LiveLocationTracker) NativeLocationUpdate(ctx context.Context, coord chat1.Coordinate) { + l.nativeWatchMu.Lock() + record, throttle := shouldRecordFix(l.G().MobileAppState.State(), l.fixThrottle, coord) + l.fixThrottle = throttle + l.nativeWatchMu.Unlock() + if record { + l.LocationUpdate(ctx, coord) + } +} + func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Coordinate) { 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 - }) - } + l.ensureHoldOnFixLocked() if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") return diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go new file mode 100644 index 000000000000..843a5eed5e35 --- /dev/null +++ b/go/chat/maps/livelocation_appstate_test.go @@ -0,0 +1,122 @@ +package maps + +import ( + "context" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/protocol/chat1" + + "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 TestLiveLocationTrackerBackgroundActive(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationTrackerBackgroundActive", 0) + defer tc.Cleanup() + appState := tc.G.MobileAppState + l := NewLiveLocationTracker(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx := context.Background() + coord := func(lat float64) chat1.Coordinate { return chat1.Coordinate{Lat: lat, Lon: 1} } + addTracker := func(msgID chat1.MessageID) *locationTrack { + track := newLocationTrack(chat1.ConversationID("conv"), msgID, time.Now().Add(time.Hour), false, 10, false) + l.Lock() + defer l.Unlock() + l.trackers[track.Key()] = track + return track + } + removeTracker := func(track *locationTrack) { + l.Lock() + defer l.Unlock() + l.removeTrackerLocked(ctx, track) + } + + lc := tc.G.MobileLifecycle + lifecycletest.ToBackground(lc) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) + l.LocationUpdate(ctx, coord(1)) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "no trackers, no hold") + + first := addTracker(1) + second := addTracker(2) + l.LocationUpdate(ctx, coord(2)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + removeTracker(first) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State(), "still tracking") + removeTracker(second) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) + + // A fix in the foreground holds too, so backgrounding keeps the work running. + lc.UIActive() + third := addTracker(3) + l.LocationUpdate(ctx, coord(3)) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) + lifecycletest.ToBackground(lc) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + removeTracker(third) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) +} + +// WillTerminate is the one controller event that ends a live location hold. A +// fix after it opens a new one, rather than counting on the ended hold. +func TestLiveLocationTrackerHoldSurvivesWillTerminate(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationTrackerWillTerminate", 0) + defer tc.Cleanup() + appState := tc.G.MobileAppState + l := NewLiveLocationTracker(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx := context.Background() + coord := func(lat float64) chat1.Coordinate { return chat1.Coordinate{Lat: lat, Lon: 1} } + + track := newLocationTrack(chat1.ConversationID("conv"), 1, time.Now().Add(time.Hour), false, 10, false) + l.Lock() + l.trackers[track.Key()] = track + l.Unlock() + + lc := tc.G.MobileLifecycle + lifecycletest.ToBackground(lc) + l.LocationUpdate(ctx, coord(1)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + + lc.WillTerminate(func() {}) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "WillTerminate left a hold open") + + l.LocationUpdate(ctx, coord(2)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State(), + "a fix after WillTerminate did not open a new hold") +} + +// A Start whose restored trackers are all gone (stopped, or none at all) +// still finds an outstanding hold from before the restore -- runRestoredLocked +// replaces the tracker map wholesale, so it never runs removeTrackerLocked for +// whatever was tracked previously. +func TestRestoredTrackersReleaseHoldWhenEmpty(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationRestoredReleasesHold", 0) + defer tc.Cleanup() + appState := tc.G.MobileAppState + l := NewLiveLocationTracker(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx := context.Background() + + track := newLocationTrack(chat1.ConversationID("conv"), 1, time.Now().Add(time.Hour), false, 10, false) + l.Lock() + l.trackers[track.Key()] = track + l.Unlock() + + lc := tc.G.MobileLifecycle + lifecycletest.ToBackground(lc) + l.LocationUpdate(ctx, chat1.Coordinate{Lat: 1, Lon: 1}) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State(), "the fix opened a hold") + + stopped := newLocationTrack(chat1.ConversationID("conv"), 2, time.Now().Add(time.Hour), false, 10, true) + l.Lock() + l.runRestoredLocked([]*locationTrack{stopped}) + l.Unlock() + + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), + "a restore with nothing live left the old hold open") +} diff --git a/go/chat/maps/livelocation_throttle_test.go b/go/chat/maps/livelocation_throttle_test.go new file mode 100644 index 000000000000..1a5f653b4a48 --- /dev/null +++ b/go/chat/maps/livelocation_throttle_test.go @@ -0,0 +1,119 @@ +package maps + +import ( + "math" + "testing" + + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// north returns c moved due north by meters, which is exact under the +// spherical distance the throttle measures. +func north(c chat1.Coordinate, meters float64) chat1.Coordinate { + c.Lat += meters / earthRadiusMeters * 180 / math.Pi + return c +} + +func TestShouldRecordFix(t *testing.T) { + origin := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194, Accuracy: 10} + at := func(c chat1.Coordinate) *chat1.Coordinate { return &c } + + cases := []struct { + name string + state keybase1.MobileAppState + last fixThrottle + next chat1.Coordinate + record bool + after fixThrottle + }{ + { + name: "first fix since the watch started, in the background", + state: keybase1.MobileAppState_BACKGROUND, + next: origin, + record: true, + after: fixThrottle{prev: at(origin)}, + }, + { + name: "any move in the foreground", + state: keybase1.MobileAppState_FOREGROUND, + last: fixThrottle{prev: at(origin), pendingDistance: 3}, + next: north(origin, 1), + record: true, + after: fixThrottle{prev: at(north(origin, 1))}, + }, + { + name: "short move in the background", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{prev: at(origin)}, + next: north(origin, 10), + record: false, + after: fixThrottle{prev: at(north(origin, 10)), pendingDistance: 10}, + }, + { + name: "short move while background work runs", + state: keybase1.MobileAppState_BACKGROUNDACTIVE, + last: fixThrottle{prev: at(origin)}, + next: north(origin, 10), + record: false, + after: fixThrottle{prev: at(north(origin, 10)), pendingDistance: 10}, + }, + { + name: "short move while on screen but not active", + state: keybase1.MobileAppState_INACTIVE, + last: fixThrottle{prev: at(origin)}, + next: north(origin, 10), + record: false, + after: fixThrottle{prev: at(north(origin, 10)), pendingDistance: 10}, + }, + { + name: "long move in the background", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{prev: at(origin)}, + next: north(origin, 100), + record: true, + after: fixThrottle{prev: at(north(origin, 100))}, + }, + { + name: "unrecorded moves add up to the distance", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{prev: at(origin), pendingDistance: 60}, + next: north(origin, 10), + record: true, + after: fixThrottle{prev: at(north(origin, 10))}, + }, + { + name: "the distance is along the path, not from the last recorded fix", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{prev: at(north(origin, 40)), pendingDistance: 40}, + next: origin, + record: true, + after: fixThrottle{prev: at(origin)}, + }, + { + name: "exactly the distance", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{prev: at(origin), pendingDistance: 65}, + next: origin, + record: true, + after: fixThrottle{prev: at(origin)}, + }, + { + name: "just short of the distance", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{prev: at(origin), pendingDistance: 64.9}, + next: origin, + record: false, + after: fixThrottle{prev: at(origin), pendingDistance: 64.9}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + record, after := shouldRecordFix(c.state, c.last, c.next) + require.Equal(t, c.record, record) + require.Equal(t, c.after.prev, after.prev) + require.InDelta(t, c.after.pendingDistance, after.pendingDistance, 1e-6) + }) + } +} diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go new file mode 100644 index 000000000000..0b0c16aff966 --- /dev/null +++ b/go/chat/maps/livelocation_watch_test.go @@ -0,0 +1,343 @@ +package maps + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/chat/utils" + "github.com/keybase/client/go/kbtest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/clockwork" + "github.com/stretchr/testify/require" +) + +type fakeLocationWatcher struct { + sync.Mutex + calls []string +} + +func (w *fakeLocationWatcher) StartWatching() { w.record("start") } +func (w *fakeLocationWatcher) StopWatching() { w.record("stop") } + +func (w *fakeLocationWatcher) record(call string) { + w.Lock() + defer w.Unlock() + w.calls = append(w.calls, call) +} + +func (w *fakeLocationWatcher) Calls() []string { + w.Lock() + defer w.Unlock() + return append([]string(nil), w.calls...) +} + +type watchCall struct { + convID chat1.ConversationID + perm chat1.UIWatchPositionPerm +} + +type fakeWatchChatUI struct { + utils.NullChatUI + sync.Mutex + watches []watchCall + clears []chat1.LocationWatchID + nextID chat1.LocationWatchID +} + +func (u *fakeWatchChatUI) ChatWatchPosition(_ context.Context, convID chat1.ConversationID, + perm chat1.UIWatchPositionPerm, +) (chat1.LocationWatchID, error) { + u.Lock() + defer u.Unlock() + u.watches = append(u.watches, watchCall{convID: convID, perm: perm}) + u.nextID++ + return u.nextID, nil +} + +func (u *fakeWatchChatUI) ChatClearWatch(_ context.Context, id chat1.LocationWatchID) error { + u.Lock() + defer u.Unlock() + u.clears = append(u.clears, id) + return nil +} + +func (u *fakeWatchChatUI) Watches() []watchCall { + u.Lock() + defer u.Unlock() + return append([]watchCall(nil), u.watches...) +} + +func (u *fakeWatchChatUI) Clears() []chat1.LocationWatchID { + u.Lock() + defer u.Unlock() + return append([]chat1.LocationWatchID(nil), u.clears...) +} + +type nilCtxFactory struct{} + +func (nilCtxFactory) NewKeyFinder() types.KeyFinder { return nil } +func (nilCtxFactory) NewUPAKFinder() types.UPAKFinder { return nil } + +// newWatchTestTracker builds a tracker whose map unfurls fail right away (the +// mock chat helper returns no message), so the tracker loop runs without a +// chat server. chatUI nil means no UI is connected. +func newWatchTestTracker(t *testing.T, tc libkb.TestContext, watcher types.LocationWatcher, + chatUI libkb.ChatUI, +) *LiveLocationTracker { + tc.G.ChatHelper = kbtest.NewMockChatHelper() + tc.G.SetUIRouter(kbtest.NewMockUIRouter(chatUI)) + g := globals.NewContext(tc.G, &globals.ChatContext{ + CtxFactory: nilCtxFactory{}, + LocationWatcher: watcher, + }) + l := NewLiveLocationTracker(g) + l.SetClock(clockwork.NewFakeClock()) + t.Cleanup(func() { + l.StopAllTracking(context.Background()) + select { + case <-l.Stop(context.Background()): + case <-time.After(10 * time.Second): + t.Error("trackers did not stop") + } + }) + return l +} + +var watchTestConvID = chat1.ConversationID("conv") + +func startTestTracker(l *LiveLocationTracker, msgID chat1.MessageID) *locationTrack { + l.StartTracking(context.Background(), watchTestConvID, msgID, l.clock.Now().Add(time.Hour)) + l.Lock() + defer l.Unlock() + return l.trackers[newLocationTrack(watchTestConvID, msgID, time.Time{}, false, 0, false).Key()] +} + +func waitTrackerRemoved(t *testing.T, l *LiveLocationTracker, track *locationTrack) { + require.Eventually(t, func() bool { + l.Lock() + defer l.Unlock() + _, ok := l.trackers[track.Key()] + return !ok + }, 10*time.Second, 5*time.Millisecond) +} + +func TestLiveLocationTrackerNativeWatcher(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerNativeWatcher", 0) + t.Cleanup(tc.Cleanup) + watcher := &fakeLocationWatcher{} + ui := &fakeWatchChatUI{} + l := newWatchTestTracker(t, tc, watcher, ui) + + first := startTestTracker(l, 1) + second := startTestTracker(l, 2) + require.Eventually(t, func() bool { return len(ui.Watches()) == 2 }, 10*time.Second, 5*time.Millisecond) + require.Equal(t, []string{"start"}, watcher.Calls(), "one native watch for both trackers") + for _, w := range ui.Watches() { + require.Equal(t, watchCall{convID: watchTestConvID, perm: chat1.UIWatchPositionPerm_ALWAYS}, w, + "the UI is still asked for permission") + } + + first.Stop() + waitTrackerRemoved(t, l, first) + require.Equal(t, []string{"start"}, watcher.Calls(), "still tracking") + + second.Stop() + waitTrackerRemoved(t, l, second) + require.Equal(t, []string{"start", "stop"}, watcher.Calls()) + require.Empty(t, ui.Clears(), "the UI never watched, so it never clears") + + third := startTestTracker(l, 3) + require.Eventually(t, func() bool { return len(watcher.Calls()) == 3 }, 10*time.Second, 5*time.Millisecond) + require.Equal(t, []string{"start", "stop", "start"}, watcher.Calls()) + third.Stop() + waitTrackerRemoved(t, l, third) + require.Equal(t, []string{"start", "stop", "start", "stop"}, watcher.Calls()) +} + +func TestLiveLocationTrackerChatUIWatch(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerChatUIWatch", 0) + t.Cleanup(tc.Cleanup) + ui := &fakeWatchChatUI{} + l := newWatchTestTracker(t, tc, nil, ui) + + first := startTestTracker(l, 1) + second := startTestTracker(l, 2) + require.Eventually(t, func() bool { return len(ui.Watches()) == 2 }, 10*time.Second, 5*time.Millisecond) + + first.Stop() + waitTrackerRemoved(t, l, first) + require.Len(t, ui.Clears(), 1) + second.Stop() + waitTrackerRemoved(t, l, second) + require.ElementsMatch(t, []chat1.LocationWatchID{1, 2}, ui.Clears()) +} + +func TestLiveLocationTrackerRestoreStartsNativeWatch(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerRestoreStartsNativeWatch", 0) + t.Cleanup(tc.Cleanup) + watcher := &fakeLocationWatcher{} + l := newWatchTestTracker(t, tc, watcher, nil) + + endTime := l.clock.Now().Add(time.Hour) + live := newLocationTrack(watchTestConvID, 1, endTime, false, 10, false) + other := newLocationTrack(watchTestConvID, 2, endTime, false, 10, false) + stopped := newLocationTrack(watchTestConvID, 3, endTime, false, 10, true) + l.Lock() + l.runRestoredLocked([]*locationTrack{live, other, stopped}) + l.Unlock() + + require.Eventually(t, func() bool { return len(watcher.Calls()) == 1 }, 10*time.Second, 5*time.Millisecond) + require.Equal(t, []string{"start"}, watcher.Calls()) + require.True(t, l.ActivelyTracking(context.Background())) + + live.Stop() + other.Stop() + waitTrackerRemoved(t, l, live) + waitTrackerRemoved(t, l, other) + require.Equal(t, []string{"start", "stop"}, watcher.Calls()) +} + +func TestLiveLocationTrackerNativeWatchStopsWhenTrackerEnds(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerNativeWatchStopsWhenTrackerEnds", 0) + t.Cleanup(tc.Cleanup) + watcher := &fakeLocationWatcher{} + l := newWatchTestTracker(t, tc, watcher, nil) + clock := l.clock.(clockwork.FakeClock) + + track := startTestTracker(l, 1) + require.Eventually(t, func() bool { return len(watcher.Calls()) == 1 }, 10*time.Second, 5*time.Millisecond) + clock.BlockUntil(2) + clock.Advance(2 * time.Hour) + waitTrackerRemoved(t, l, track) + require.Equal(t, []string{"start", "stop"}, watcher.Calls()) +} + +type failingWatchChatUI struct { + utils.NullChatUI + attempts atomic.Int32 +} + +func (u *failingWatchChatUI) ChatWatchPosition(context.Context, chat1.ConversationID, + chat1.UIWatchPositionPerm, +) (chat1.LocationWatchID, error) { + u.attempts.Add(1) + return 0, errors.New("no UI yet") +} + +func TestLiveLocationTrackerChatUIWatchGivesUp(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerChatUIWatchGivesUp", 0) + t.Cleanup(tc.Cleanup) + ui := &failingWatchChatUI{} + l := newWatchTestTracker(t, tc, nil, ui) + clock := l.clock.(clockwork.FakeClock) + + done := make(chan error, 1) + track := newLocationTrack(watchTestConvID, 1, clock.Now().Add(time.Hour), false, 10, false) + go func() { + _, err := l.startChatUIWatch(context.Background(), track) + done <- err + }() + // One try plus 21 retries, a second apart. + const maxAttempts = 22 + for n := int32(1); ; n++ { + require.Eventually(t, func() bool { return ui.attempts.Load() >= n }, 10*time.Second, time.Millisecond) + if n == maxAttempts { + break + } + clock.BlockUntil(1) + clock.Advance(time.Second) + } + select { + case err := <-done: + require.Error(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "still retrying", "after %d attempts", ui.attempts.Load()) + } + require.EqualValues(t, maxAttempts, ui.attempts.Load()) +} + +// A tracker whose watch never starts ends like any other: it leaves no tracker +// and no background-work hold, so the app can still reach BACKGROUND. +func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationTrackerFailedWatchLeavesNoHold", 0) + t.Cleanup(tc.Cleanup) + ui := &failingWatchChatUI{} + l := newWatchTestTracker(t, tc, nil, ui) + clock := l.clock.(clockwork.FakeClock) + appState := tc.G.MobileAppState + + track := startTestTracker(l, 1) + require.NotNil(t, track) + // A fix while the watch is still retrying holds the app up. + require.Eventually(t, func() bool { return ui.attempts.Load() >= 1 }, 10*time.Second, time.Millisecond) + l.LocationUpdate(context.Background(), chat1.Coordinate{Lat: 1, Lon: 1}) + lifecycletest.ToBackground(tc.G.MobileLifecycle) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + + for ui.attempts.Load() < 22 { + // Read the count while the retry is parked on the clock: Advance + // releases it, so a count read afterward can already include it. + clock.BlockUntil(1) + n := ui.attempts.Load() + clock.Advance(time.Second) + require.Eventually(t, func() bool { return ui.attempts.Load() > n }, 10*time.Second, time.Millisecond) + } + waitTrackerRemoved(t, l, track) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) + + // A later fix finds no tracker to hold the app up for. + tc.G.MobileLifecycle.UIActive() + l.LocationUpdate(context.Background(), chat1.Coordinate{Lat: 2, Lon: 2}) + lifecycletest.ToBackground(tc.G.MobileLifecycle) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) +} + +// gatedClearChatUI holds the first watch's clear until gate closes. +type gatedClearChatUI struct { + fakeWatchChatUI + clearing chan struct{} + gate chan struct{} +} + +func (u *gatedClearChatUI) ChatClearWatch(ctx context.Context, id chat1.LocationWatchID) error { + if id == 1 { + close(u.clearing) + <-u.gate + } + return u.fakeWatchChatUI.ChatClearWatch(ctx, id) +} + +// Stop waits for the trackers it stops, not for one started after it. +func TestLiveLocationTrackerStopWaitsOnlyForItsTrackers(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerStopWaitsOnlyForItsTrackers", 0) + t.Cleanup(tc.Cleanup) + ui := &gatedClearChatUI{clearing: make(chan struct{}), gate: make(chan struct{})} + l := newWatchTestTracker(t, tc, nil, ui) + + require.NotNil(t, startTestTracker(l, 1)) + require.Eventually(t, func() bool { return len(ui.Watches()) == 1 }, 10*time.Second, 5*time.Millisecond) + stopped := l.Stop(context.Background()) + select { + case <-ui.clearing: + case <-time.After(10 * time.Second): + require.FailNow(t, "the stopped tracker did not exit") + } + // The stopped tracker is still exiting when the next one starts. + require.NotNil(t, startTestTracker(l, 2)) + close(ui.gate) + select { + case <-stopped: + case <-time.After(10 * time.Second): + require.FailNow(t, "Stop waited for a tracker started after it") + } +} diff --git a/go/chat/search/indexer.go b/go/chat/search/indexer.go index ac373804c636..5b3908a5e3ac 100644 --- a/go/chat/search/indexer.go +++ b/go/chat/search/indexer.go @@ -81,6 +81,11 @@ type Indexer struct { consumeCh chan chat1.ConversationID reindexCh chan chat1.ConversationID syncLoopCh, cancelSyncCh, pokeSyncCh chan struct{} + // selectiveSync, if set, runs in place of SelectiveSync. Tests only. + selectiveSync func(ctx context.Context) error + // beforeSyncStateCheck and afterSyncStart, if set, run in attemptSync + // around its app-state check and sync start. Tests only. + beforeSyncStateCheck, afterSyncStart func() } var _ types.Indexer = (*Indexer)(nil) @@ -243,7 +248,7 @@ func (idx *Indexer) SyncLoop(stopCh chan struct{}) error { ticker := libkb.NewBgTicker(idx.syncInterval) after := time.After(idx.startSyncDelay) - appState := keybase1.MobileAppState_FOREGROUND + appState := idx.G().MobileAppState.State() netState := keybase1.MobileNetworkState_WIFI var cancelFn context.CancelFunc var l sync.Mutex @@ -260,6 +265,20 @@ func (idx *Indexer) SyncLoop(stopCh chan struct{}) error { if netState.IsLimited() { return } + if idx.beforeSyncStateCheck != nil { + idx.beforeSyncStateCheck() + } + if state := idx.G().MobileAppState.State(); state != keybase1.MobileAppState_FOREGROUND { + idx.Debug(ctx, "not running SelectiveSync in %v", state) + return + } + // The loop may not have woken for the change into FOREGROUND yet. Wait + // on changes from FOREGROUND from here on, so leaving it after this + // read wakes the loop, which cancels the sync. + appState = keybase1.MobileAppState_FOREGROUND + if idx.afterSyncStart != nil { + defer idx.afterSyncStart() + } l.Lock() defer l.Unlock() if cancelFn != nil { @@ -267,9 +286,13 @@ func (idx *Indexer) SyncLoop(stopCh chan struct{}) error { return } ctx, cancelFn = context.WithCancel(ctx) + selectiveSync := idx.SelectiveSync + if idx.selectiveSync != nil { + selectiveSync = idx.selectiveSync + } syncAttemptWG.Go(func() { idx.Debug(ctx, "running SelectiveSync") - if err := idx.SelectiveSync(ctx); err != nil { + if err := selectiveSync(ctx); err != nil { idx.Debug(ctx, "unable to complete SelectiveSync: %v", err) if idx.syncLoopCh != nil { select { diff --git a/go/chat/search/indexer_appstate_test.go b/go/chat/search/indexer_appstate_test.go new file mode 100644 index 000000000000..9fb5d76ba652 --- /dev/null +++ b/go/chat/search/indexer_appstate_test.go @@ -0,0 +1,236 @@ +package search + +import ( + "context" + "math/rand" + "runtime" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/externalstest" + "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" +) + +// syncRecorder stands in for SelectiveSync: each sync runs until canceled. +type syncRecorder struct { + mu sync.Mutex + starts int + active int +} + +func (s *syncRecorder) sync(ctx context.Context) error { + s.mu.Lock() + s.starts++ + s.active++ + s.mu.Unlock() + <-ctx.Done() + s.mu.Lock() + s.active-- + s.mu.Unlock() + return ctx.Err() +} + +func (s *syncRecorder) counts() (starts, active int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.starts, s.active +} + +type syncLoopTest struct { + t *testing.T + tc libkb.TestContext + idx *Indexer + syncs *syncRecorder + stopCh chan struct{} + loopDone chan error +} + +// The loop's ticker is left at an hour: a BgTicker cannot tick faster than its +// 5s resume wait. The start delay and pokes reach the same attemptSync. +func newAppStateSyncLoop(t *testing.T, state keybase1.MobileAppState) *syncLoopTest { + tc := externalstest.SetupTest(t, "indexer-appstate", 0) + t.Cleanup(tc.Cleanup) + tc.G.MobileAppState.Update(state) + g := globals.NewContext(tc.G, &globals.ChatContext{CtxFactory: stubCtxFactory{}}) + idx := NewIndexer(g) + idx.SetStartSyncDelay(0) + idx.syncInterval = time.Hour + s := &syncLoopTest{ + t: t, + tc: tc, + idx: idx, + syncs: &syncRecorder{}, + stopCh: make(chan struct{}), + loopDone: make(chan error, 1), + } + idx.selectiveSync = s.syncs.sync + return s +} + +func startAppStateSyncLoop(t *testing.T, state keybase1.MobileAppState) *syncLoopTest { + s := newAppStateSyncLoop(t, state) + s.start() + return s +} + +func (s *syncLoopTest) start() { + go func() { s.loopDone <- s.idx.SyncLoop(s.stopCh) }() +} + +func (s *syncLoopTest) stop() { + close(s.stopCh) + select { + case err := <-s.loopDone: + require.NoError(s.t, err) + case <-time.After(10 * time.Second): + require.FailNow(s.t, "SyncLoop did not stop") + } +} + +// poke sends a poke and returns once the loop has finished handling it: the +// loop takes one message at a time, so taking a second poke means it is done +// with the first. +func (s *syncLoopTest) poke() { + for range 2 { + s.idx.PokeSync(context.Background()) + require.Eventually(s.t, func() bool { return len(s.idx.pokeSyncCh) == 0 }, + 10*time.Second, time.Millisecond, "poke not taken") + } +} + +func (s *syncLoopTest) requireActive(active int, msg string) { + s.t.Helper() + require.Eventually(s.t, func() bool { + _, got := s.syncs.counts() + return got == active + }, 10*time.Second, time.Millisecond, msg) +} + +func TestSyncLoopDoesNotSyncOutsideForeground(t *testing.T) { + for _, state := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } { + t.Run(state.String(), func(t *testing.T) { + s := startAppStateSyncLoop(t, state) + defer s.stop() + for range 5 { + s.poke() + } + time.Sleep(100 * time.Millisecond) + starts, _ := s.syncs.counts() + require.Zero(t, starts, "synced in %v", state) + + s.tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + s.poke() + s.requireActive(1, "no sync after FOREGROUND") + }) + } +} + +func TestSyncLoopBackgroundCancelsSync(t *testing.T) { + s := startAppStateSyncLoop(t, keybase1.MobileAppState_FOREGROUND) + defer s.stop() + s.poke() + s.requireActive(1, "no sync in FOREGROUND") + s.tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + s.requireActive(0, "sync not canceled by BACKGROUND") + s.poke() + time.Sleep(50 * time.Millisecond) + starts, active := s.syncs.counts() + require.Equal(t, 1, starts) + require.Zero(t, active) +} + +// The loop can start a sync on a poke before it wakes for the change into +// FOREGROUND. A BACKGROUND that lands before it returns to its select must +// still cancel that sync. +func TestSyncLoopBackgroundAfterUnobservedForeground(t *testing.T) { + s := newAppStateSyncLoop(t, keybase1.MobileAppState_BACKGROUND) + var beforeOnce, afterOnce sync.Once + s.idx.beforeSyncStateCheck = func() { + beforeOnce.Do(func() { s.tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) }) + } + s.idx.afterSyncStart = func() { + afterOnce.Do(func() { + for { + if _, active := s.syncs.counts(); active == 1 { + break + } + time.Sleep(time.Millisecond) + } + s.tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + }) + } + s.start() + defer s.stop() + s.poke() + s.requireActive(0, "sync kept running in BACKGROUND") + starts, _ := s.syncs.counts() + require.Equal(t, 1, starts) +} + +func TestSyncLoopScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + s := startAppStateSyncLoop(t, keybase1.MobileAppState_FOREGROUND) + defer s.stop() + lifecycletest.Play(t, s.tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + if step.Want == keybase1.MobileAppState_FOREGROUND { + s.poke() + s.requireActive(1, "no sync in FOREGROUND") + return + } + s.requireActive(0, "sync running outside FOREGROUND") + before, _ := s.syncs.counts() + s.poke() + if starts, _ := s.syncs.counts(); starts != before { + t.Fatalf("step %d %v: sync started in %v", i, step.Do, step.Want) + } + }) + }) + } +} + +func TestSyncLoopAppStateStress(t *testing.T) { + s := newAppStateSyncLoop(t, keybase1.MobileAppState_FOREGROUND) + baseline := runtime.NumGoroutine() + s.start() + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } + var wg sync.WaitGroup + for w := range 4 { + wg.Go(func() { + rng := rand.New(rand.NewSource(int64(w))) + for range 500 { + s.tc.G.MobileAppState.Update(states[rng.Intn(len(states))]) + if rng.Intn(4) == 0 { + s.idx.PokeSync(context.Background()) + } + } + }) + } + wg.Wait() + s.tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + s.requireActive(0, "sync running in BACKGROUND") + s.tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + s.poke() + s.requireActive(1, "no sync in FOREGROUND") + s.stop() + s.requireActive(0, "sync outlived the loop") + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "leaked goroutines") +} diff --git a/go/chat/server.go b/go/chat/server.go index 9e3f9ac1c931..7b9c1863eb7e 100644 --- a/go/chat/server.go +++ b/go/chat/server.go @@ -133,11 +133,7 @@ func (h *Server) handleOfflineError(ctx context.Context, err error, case OfflineErrorKindOfflineReconnect: // Reconnect Gregor if we think we are offline (and told to reconnect) h.Debug(ctx, "handleOfflineError: reconnecting to gregor") - if _, err := h.serverConn.Reconnect(ctx); err != nil { - h.Debug(ctx, "handleOfflineError: error reconnecting: %s", err) - } else { - h.Debug(ctx, "handleOfflineError: success reconnecting") - } + h.serverConn.Reconnect(ctx) default: // Nothing to do for other errors. } diff --git a/go/chat/server_test.go b/go/chat/server_test.go index 4094c15b2efe..4235d62b66a4 100644 --- a/go/chat/server_test.go +++ b/go/chat/server_test.go @@ -106,9 +106,7 @@ func (g *gregorTestConnection) GetClient() chat1.RemoteInterface { return chat1.RemoteClient{Cli: g.cli} } -func (g *gregorTestConnection) Reconnect(ctx context.Context) (bool, error) { - return false, nil -} +func (g *gregorTestConnection) Reconnect(ctx context.Context) {} func (g *gregorTestConnection) OnConnect(ctx context.Context, _ *rpc.Connection, cli rpc.GenericClient, srv *rpc.Server, diff --git a/go/chat/sync.go b/go/chat/sync.go index 880486eab197..4439a14d9c4e 100644 --- a/go/chat/sync.go +++ b/go/chat/sync.go @@ -177,6 +177,13 @@ func (s *Syncer) Connected(ctx context.Context, cli chat1.RemoteInterface, uid g ctx = globals.CtxAddLogTags(ctx, s.G()) defer s.Trace(ctx, &err, "Connected")() s.Lock() + // The caller cancels ctx when the connection it was made for shuts + // down, before it calls Disconnected, so a Connected that sees the cancel + // here must not mark the syncer connected after that Disconnected. + if err := ctx.Err(); err != nil { + s.Unlock() + return err + } s.isConnected = true // Let the Offlinables know that we are back online for _, o := range s.offlinables { diff --git a/go/chat/sync_test.go b/go/chat/sync_test.go index 0475c81d28ad..4ac09af5cef6 100644 --- a/go/chat/sync_test.go +++ b/go/chat/sync_test.go @@ -9,6 +9,7 @@ import ( "github.com/keybase/client/go/chat/storage" "github.com/keybase/client/go/chat/types" "github.com/keybase/client/go/chat/utils" + "github.com/keybase/client/go/externalstest" "github.com/keybase/client/go/kbtest" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/protocol/chat1" @@ -438,6 +439,20 @@ func TestSyncerMembersTypeChanged(t *testing.T) { } } +// Connected with a ctx its connection's Shutdown has already cancelled must +// not mark the syncer connected: the Disconnected that follows the cancel may +// already have run. +func TestSyncerConnectedAfterCancelIsIgnored(t *testing.T) { + tc := externalstest.SetupTest(t, "syncer-connected-cancel", 0) + defer tc.Cleanup() + syncer := NewSyncer(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := syncer.Connected(ctx, nil, gregor1.UID(make([]byte, 16)), &chat1.SyncChatRes{}) + require.False(t, syncer.IsConnected(context.Background())) + require.ErrorIs(t, err, context.Canceled) +} + func TestSyncerAppState(t *testing.T) { ctx, world, ri2, _, sender, list := setupTest(t, 1) defer world.Cleanup() diff --git a/go/chat/types/interfaces.go b/go/chat/types/interfaces.go index 5212f879fab5..afc9ec123bdf 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) @@ -469,6 +464,15 @@ type ShareIntentDonator interface { DeleteDonation(conversationID string) } +// LocationWatcher runs the OS location service natively (iOS), so live +// location keeps working without the UI. Fixes come back through +// LiveLocationTracker.NativeLocationUpdate. When nil, the chat UI watches +// position. +type LocationWatcher interface { + StartWatching() + StopWatching() +} + type StellarLoader interface { LoadPayment(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, senderUsername string, paymentID stellar1.PaymentID) *chat1.UIPaymentInfo LoadRequest(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, senderUsername string, requestID stellar1.KeybaseRequestID) *chat1.UIRequestInfo @@ -580,6 +584,7 @@ type LiveLocationTracker interface { GetCurrentPosition(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID) StartTracking(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, endTime time.Time) LocationUpdate(ctx context.Context, coord chat1.Coordinate) + NativeLocationUpdate(ctx context.Context, coord chat1.Coordinate) GetCoordinates(ctx context.Context, key LiveLocationKey) []chat1.Coordinate GetEndTime(ctx context.Context, key LiveLocationKey) *time.Time ActivelyTracking(ctx context.Context) bool @@ -697,7 +702,8 @@ type ( ) type ServerConnection interface { - Reconnect(context.Context) (bool, error) + // Reconnect reconnects to the server without waiting for it. + Reconnect(context.Context) GetClient() chat1.RemoteInterface } diff --git a/go/chat/unfurl/scraper_test.go b/go/chat/unfurl/scraper_test.go index 6a077e9b2700..f97b8e823fd2 100644 --- a/go/chat/unfurl/scraper_test.go +++ b/go/chat/unfurl/scraper_test.go @@ -387,6 +387,10 @@ func (t *testingLiveLocationTracker) LocationUpdate(ctx context.Context, coord c t.coords = append(t.coords, coord) } +func (t *testingLiveLocationTracker) NativeLocationUpdate(ctx context.Context, coord chat1.Coordinate) { + t.LocationUpdate(ctx, coord) +} + func (t *testingLiveLocationTracker) GetCoordinates(ctx context.Context, key types.LiveLocationKey) []chat1.Coordinate { return t.coords } diff --git a/go/engine/bootstrap.go b/go/engine/bootstrap.go index d6ede82d3da2..abe355d630e2 100644 --- a/go/engine/bootstrap.go +++ b/go/engine/bootstrap.go @@ -62,28 +62,41 @@ func (e *Bootstrap) lookupFullname(m libkb.MetaContext, uv keybase1.UserVersion) e.status.Fullname = pkg.FullName.FullName } +// SessionState reads the session fields that are available with nothing to wait +// on: the active device. Bootstrap fills the same fields plus the slower derived +// ones, so the two cannot drift. The returned UserVersion is the active device's, +// empty when logged out. +func SessionState(m libkb.MetaContext) (res keybase1.ClientSession, uv keybase1.UserVersion) { + // if any Login engine worked previously, then ActiveDevice will + // be valid; the only way for it to be valid is to be logged in + // (and provisioned) + res.LoggedIn = m.G().ActiveDevice.Valid() + if !res.LoggedIn { + return res, uv + } + + uv, res.DeviceID, res.DeviceName, _, _ = m.G().ActiveDevice.AllFields() + res.Uid = uv.Uid + res.Username = m.G().ActiveDevice.Username(m).String() + return res, uv +} + // Run starts the engine. func (e *Bootstrap) Run(m libkb.MetaContext) (err error) { defer m.Trace("Bootstrap.Run", &err)() - e.status.Registered = e.signedUp(m) - - // if any Login engine worked previously, then ActiveDevice will - // be valid: - validActiveDevice := m.G().ActiveDevice.Valid() + session, uv := SessionState(m) + e.status.Registered = signedUp(m) + e.status.LoggedIn = session.LoggedIn + e.status.Uid = session.Uid + e.status.Username = session.Username + e.status.DeviceID = session.DeviceID + e.status.DeviceName = session.DeviceName - // the only way for ActiveDevice to be valid is to be logged in - // (and provisioned) - e.status.LoggedIn = validActiveDevice if !e.status.LoggedIn { m.Debug("Bootstrap: not logged in") return nil } m.Debug("Bootstrap: logged in (valid active device)") - - var uv keybase1.UserVersion - uv, e.status.DeviceID, e.status.DeviceName, _, _ = e.G().ActiveDevice.AllFields() - e.status.Uid = uv.Uid - e.status.Username = e.G().ActiveDevice.Username(m).String() m.Debug("Bootstrap status: uid=%s, username=%s, deviceID=%s, deviceName=%s", e.status.Uid, e.status.Username, e.status.DeviceID, e.status.DeviceName) if chatHelper := e.G().ChatHelper; chatHelper != nil { @@ -96,7 +109,7 @@ func (e *Bootstrap) Run(m libkb.MetaContext) (err error) { } // signedUp is true if there's a uid in config.json. -func (e *Bootstrap) signedUp(m libkb.MetaContext) bool { +func signedUp(m libkb.MetaContext) bool { cr := m.G().Env.GetConfig() if cr == nil { return false diff --git a/go/ephemeral/keygen_loop_test.go b/go/ephemeral/keygen_loop_test.go new file mode 100644 index 000000000000..a457f4d354ca --- /dev/null +++ b/go/ephemeral/keygen_loop_test.go @@ -0,0 +1,93 @@ +package ephemeral + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +func TestKeygenLoopSeedsFromState(t *testing.T) { + tc := libkb.SetupTest(t, "ephemeral", 2) + defer tc.Cleanup() + mctx := libkb.NewMetaContextForTest(tc) + appState := tc.G.MobileAppState + appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + + var runs atomic.Int32 + waiting := make(chan keybase1.MobileAppState, 10) + stopCh := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + (&EKLib{}).keygenLoop(mctx, stopCh, nil, + func() time.Duration { return 0 }, + func() { runs.Add(1) }, + func(state keybase1.MobileAppState) { waiting <- state }) + }() + + next := func(want keybase1.MobileAppState) { + t.Helper() + select { + case got := <-waiting: + require.Equal(t, want, got) + case <-time.After(10 * time.Second): + t.Fatal("keygen loop did not wait") + } + } + + // A background-active launch is not a transition into BACKGROUNDACTIVE. + next(keybase1.MobileAppState_BACKGROUNDACTIVE) + require.Zero(t, runs.Load()) + + appState.Update(keybase1.MobileAppState_FOREGROUND) + next(keybase1.MobileAppState_FOREGROUND) + require.Zero(t, runs.Load()) + + appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + next(keybase1.MobileAppState_BACKGROUNDACTIVE) + require.EqualValues(t, 1, runs.Load()) + + close(stopCh) + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("keygen loop did not stop") + } +} + +// Keygen runs when work wakes the app in the background and when the app +// leaves BACKGROUND, but not when the UI merely stops being active. +func TestKeygenOnTransition(t *testing.T) { + const ( + fg = keybase1.MobileAppState_FOREGROUND + bg = keybase1.MobileAppState_BACKGROUND + ina = keybase1.MobileAppState_INACTIVE + bga = keybase1.MobileAppState_BACKGROUNDACTIVE + ) + cases := []struct { + prev, state keybase1.MobileAppState + run bool + }{ + {bg, bga, true}, + {fg, bga, true}, + {ina, bga, true}, + {bg, ina, true}, + // NextUpdate collapses willEnterForeground's INACTIVE and + // didBecomeActive's FOREGROUND when they land together. + {bg, fg, true}, + {ina, fg, false}, + {fg, ina, false}, + {bga, ina, false}, + {bga, fg, false}, + {ina, bg, false}, + {bga, bg, false}, + {fg, bg, false}, + } + for _, tc := range cases { + require.Equal(t, tc.run, keygenOnTransition(tc.prev, tc.state), "%v -> %v", tc.prev, tc.state) + } +} diff --git a/go/ephemeral/lib.go b/go/ephemeral/lib.go index e17ac4b2696d..66ec64c9cb4e 100644 --- a/go/ephemeral/lib.go +++ b/go/ephemeral/lib.go @@ -118,35 +118,52 @@ func (e *EKLib) backgroundKeygen(mctx libkb.MetaContext, stopCh <-chan struct{}) runIfNeeded(true /* force */) ticker := libkb.NewBgTicker(keygenInterval) - state := keybase1.MobileAppState_FOREGROUND - // Run every hour but also check if enough wall clock time has elapsed when - // we are in a BACKGROUNDACTIVE state. + defer ticker.Stop() + e.keygenLoop(mctx, stopCh, ticker.C, func() time.Duration { return libkb.RandomJitter(time.Second) }, + func() { runIfNeeded(false /* force */) }, nil) +} + +// keygenLoop runs run on every tick, and also when the app enters +// BACKGROUNDACTIVE or leaves BACKGROUND, after a jittered pause so it doesn't +// stampede for resources with other background tasks (libkb.BgTicker handles +// this internally for ticks). waiting, if set, is told the state before each +// wait. +func (e *EKLib) keygenLoop(mctx libkb.MetaContext, stopCh <-chan struct{}, tick <-chan time.Time, + jitter func() time.Duration, run func(), waiting func(keybase1.MobileAppState), +) { + state := mctx.G().MobileAppState.State() for { + if waiting != nil { + waiting(state) + } select { - case <-ticker.C: - runIfNeeded(false /* force */) + case <-tick: + run() case <-mctx.G().MobileAppState.NextUpdate(state): + prev := state state = mctx.G().MobileAppState.State() - if state == keybase1.MobileAppState_BACKGROUNDACTIVE { - // Before running we pause briefly so we don't stampede for - // resources with other background tasks. libkb.BgTicker - // handles this internally, so we only need to throttle on - // MobileAppState change. + if keygenOnTransition(prev, state) { select { - case <-time.After(libkb.RandomJitter(time.Second)): - runIfNeeded(false /* force */) + case <-time.After(jitter()): + run() case <-stopCh: - ticker.Stop() return } } case <-stopCh: - ticker.Stop() return } } } +// keygenOnTransition: work woke the app in the background, or the app left +// BACKGROUND. NextUpdate collapses changes, so a return to the foreground can +// arrive as BACKGROUND to FOREGROUND without the INACTIVE in between. +func keygenOnTransition(prev, state keybase1.MobileAppState) bool { + return state == keybase1.MobileAppState_BACKGROUNDACTIVE || + (prev == keybase1.MobileAppState_BACKGROUND && state != keybase1.MobileAppState_BACKGROUND) +} + func (e *EKLib) SetClock(clock clockwork.Clock) { e.clock = clock } diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index fb711fb8233a..35470bbb2d2b 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -8,10 +8,10 @@ import ( "context" "crypto/rand" "encoding/base64" - "errors" "io" "net/http" "path" + "runtime" "strings" "sync" "time" @@ -24,6 +24,7 @@ import ( "github.com/keybase/client/go/kbfs/libmime" "github.com/keybase/client/go/kbfs/tlf" "github.com/keybase/client/go/kbhttp" + "github.com/keybase/client/go/kbhttp/manager" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/logger" "github.com/keybase/client/go/protocol/keybase1" @@ -33,11 +34,9 @@ const fsCacheSize = 64 // Server is a local HTTP server for serving KBFS content over HTTP. type Server struct { - config libkbfs.Config - logger logger.Logger - vlog *libkb.VDebugLog - appStateUpdater env.AppStateUpdater - cancel func() + config libkbfs.Config + logger logger.Logger + vlog *libkb.VDebugLog tokenLock sync.RWMutex token string @@ -45,8 +44,7 @@ type Server struct { fs *lru.Cache - serverLock sync.RWMutex - server *kbhttp.Srv + server *manager.Srv } const ( @@ -221,67 +219,15 @@ const ( requestPathRoot = "/files/" ) -func (s *Server) restart() (err error) { - s.serverLock.Lock() - defer s.serverLock.Unlock() - if s.server != nil { - s.server.Stop() - err = s.server.Start() - } - if s.server == nil || - // If pinned port is in use, just pick a new one like we never had a - // server before. - errors.Is(err, kbhttp.ErrPinnedPortInUse) { - s.server = kbhttp.NewSrv(s.logger, - kbhttp.NewRandomPortRangeListenerSource(portStart, portEnd)) - err = s.server.Start() - } - if err != nil { - return err - } - // Have to start this first to populate the ServeMux object. - s.server.Handle(requestPathRoot, - http.StripPrefix(requestPathRoot, http.HandlerFunc(s.serve))) - return nil -} - -func (s *Server) monitorAppState(ctx context.Context) { - state := keybase1.MobileAppState_FOREGROUND - for { - select { - case <-ctx.Done(): - return - case <-s.appStateUpdater.NextAppStateUpdate(state): - state = s.appStateUpdater.AppState() - // Due to the way NextUpdate is designed, it's possible we miss an - // update if processing the last update takes too long. So it's - // possible to get consecutive FOREGROUND updates even if there are - // other states in-between. Since libkb/appstate.go already - // deduplicates, it'll never actually send consecutive identical - // states to us. In addition, apart from FOREGROUND/BACKGROUND, - // there are other possible states too, and potentially more in the - // future. So, we just restart the server under FOREGROUND instead - // of trying to listen on all state updates. - if state != keybase1.MobileAppState_FOREGROUND { - continue - } - if err := s.restart(); err != nil { - s.logger.Error("(Re)starting server failed: %v", err) - } - } - } -} - // New creates and starts a new server. func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( s *Server, err error, ) { logger := config.MakeLogger("HTTP") s = &Server{ - appStateUpdater: appStateUpdater, - config: config, - logger: logger, - vlog: config.MakeVLogger(logger), + config: config, + logger: logger, + vlog: config.MakeVLogger(logger), } s.fs, err = lru.NewWithEvict(fsCacheSize, func(_ any, value any) { if e, ok := value.(obsoleteTrackingFS); ok && e.unsubscribe != nil { @@ -291,30 +237,33 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( if err != nil { return nil, err } - if err = s.restart(); err != nil { + // A failed first start is fatal here: the retry rides on app state changes, + // and on desktop -- which runs this server too -- the app state never moves. + s.server, err = manager.New("kbfsHTTP", logger, appStateUpdater.AppState, appStateUpdater.NextAppStateUpdate, + func() kbhttp.ListenerSource { + return kbhttp.NewRandomPortRangeListenerSource(portStart, portEnd) + }, runtime.GOOS != "android", func(context.Context, keybase1.HttpSrvInfo) {}) + if err != nil { + s.server.Shutdown() return nil, err } - ctx, cancel := context.WithCancel(context.Background()) - go s.monitorAppState(ctx) - s.cancel = cancel + // The token is checked in serve. No one has the address before New + // returns, so registering after the first start answers no request with a 404. + s.server.HandleFunc(strings.TrimPrefix(requestPathRoot, "/"), manager.SrvTokenModeUnchecked, + http.StripPrefix(requestPathRoot, http.HandlerFunc(s.serve)).ServeHTTP) libmime.Patch(additionalMimeTypes) return s, nil } // Address returns the address that the server is listening on. func (s *Server) Address() (string, error) { - s.serverLock.RLock() - defer s.serverLock.RUnlock() return s.server.Addr() } // Shutdown shuts down the server. func (s *Server) Shutdown() { - s.serverLock.Lock() - defer s.serverLock.Unlock() - s.server.Stop() + s.server.Shutdown() // Purge the LRU so its evict callback runs and unsubscribes any // folder-branch observers still held by cached entries. s.fs.Purge() - s.cancel() } diff --git a/go/kbfs/libkbfs/app_state_test.go b/go/kbfs/libkbfs/app_state_test.go new file mode 100644 index 000000000000..376d5de3b409 --- /dev/null +++ b/go/kbfs/libkbfs/app_state_test.go @@ -0,0 +1,259 @@ +// Copyright 2026 Keybase Inc. All rights reserved. +// Use of this source code is governed by a BSD +// license that can be found in the LICENSE file. + +package libkbfs + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/logger" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// fakeAppState is a settable env.AppStateUpdater. appWaits and netWaits +// receive the state passed to every NextAppStateUpdate and +// NextNetworkStateUpdate call, while they have room. +type fakeAppState struct { + lock sync.Mutex + appState keybase1.MobileAppState + netState keybase1.MobileNetworkState + appChanged chan struct{} + netChanged chan struct{} + appWaits chan keybase1.MobileAppState + netWaits chan keybase1.MobileNetworkState +} + +func newFakeAppState( + appState keybase1.MobileAppState, netState keybase1.MobileNetworkState, +) *fakeAppState { + return &fakeAppState{ + appState: appState, + netState: netState, + appChanged: make(chan struct{}), + netChanged: make(chan struct{}), + appWaits: make(chan keybase1.MobileAppState, 1000), + netWaits: make(chan keybase1.MobileNetworkState, 1000), + } +} + +var closedAppStateCh = func() chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch +}() + +func (f *fakeAppState) NextAppStateUpdate( + lastState keybase1.MobileAppState, +) <-chan struct{} { + f.lock.Lock() + defer f.lock.Unlock() + select { + case f.appWaits <- lastState: + default: + } + if lastState != f.appState { + return closedAppStateCh + } + return f.appChanged +} + +func (f *fakeAppState) NextNetworkStateUpdate( + lastState keybase1.MobileNetworkState, +) <-chan struct{} { + f.lock.Lock() + defer f.lock.Unlock() + select { + case f.netWaits <- lastState: + default: + } + if lastState != f.netState { + return closedAppStateCh + } + return f.netChanged +} + +func (f *fakeAppState) AppState() keybase1.MobileAppState { + f.lock.Lock() + defer f.lock.Unlock() + return f.appState +} + +func (f *fakeAppState) NetworkState() keybase1.MobileNetworkState { + f.lock.Lock() + defer f.lock.Unlock() + return f.netState +} + +// setAppState changes the app state and waits until someone waits for the +// next change from it. +func (f *fakeAppState) setAppState(t *testing.T, state keybase1.MobileAppState) { + t.Helper() + f.drain() + f.setAppStateNoWait(state) + waitFor(t, f.appWaits, state) +} + +// setNetworkState changes the network state and waits until someone waits +// for the next change from it. +func (f *fakeAppState) setNetworkState(t *testing.T, state keybase1.MobileNetworkState) { + t.Helper() + f.drain() + f.setNetworkStateNoWait(state) + waitFor(t, f.netWaits, state) +} + +func (f *fakeAppState) drain() { + for { + select { + case <-f.appWaits: + case <-f.netWaits: + default: + return + } + } +} + +func waitFor[T comparable](t *testing.T, waits <-chan T, want T) { + t.Helper() + timeout := time.After(10 * time.Second) + for { + select { + case got := <-waits: + if got == want { + return + } + case <-timeout: + t.Fatalf("nothing waited for a change from %v", want) + } + } +} + +func (f *fakeAppState) setAppStateNoWait(state keybase1.MobileAppState) { + f.lock.Lock() + defer f.lock.Unlock() + if f.appState != state { + f.appState = state + close(f.appChanged) + f.appChanged = make(chan struct{}) + } +} + +func (f *fakeAppState) setNetworkStateNoWait(state keybase1.MobileNetworkState) { + f.lock.Lock() + defer f.lock.Unlock() + if f.netState != state { + f.netState = state + close(f.netChanged) + f.netChanged = make(chan struct{}) + } +} + +type fbmNoTimedQRConfig struct { + Config +} + +func (c fbmNoTimedQRConfig) Mode() InitMode { + return modeTestWithNoTimedQR{modeTest{NewInitModeFromType(InitDefault)}} +} + +// The folder block manager's app-state waits end on shutdown while the app +// is backgrounded. +func TestFolderBlockManagerPausedLoopsExitOnShutdown(t *testing.T) { + loops := map[string]func(fbm *folderBlockManager){ + "reclaimQuota": (*folderBlockManager).reclaimQuotaInBackground, + "cleanDiskCaches": (*folderBlockManager).cleanDiskCachesInBackground, + } + for name, loop := range loops { + t.Run(name, func(t *testing.T) { + appState := newFakeAppState( + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileNetworkState_WIFI) + fbm := &folderBlockManager{ + appStateUpdater: appState, + config: fbmNoTimedQRConfig{}, + log: logger.NewTestLogger(t), + shutdownChan: make(chan struct{}), + forceReclamationChan: make(chan struct{}, 1), + latestMergedChan: make(chan struct{}, 1), + } + done := make(chan struct{}) + go func() { + defer close(done) + loop(fbm) + }() + + waitFor(t, appState.appWaits, keybase1.MobileAppState_BACKGROUND) + fbm.shutdown() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("paused loop did not exit on shutdown") + } + }) + } +} + +// requirePaused checks the prefetcher's pause after the state changes that +// setAppState/setNetworkState waited for. +func requirePaused(t *testing.T, q *blockRetrievalQueue, want bool, msg string) { + t.Helper() + paused, _ := q.Prefetcher().(*blockPrefetcher).getPaused() + require.Equal(t, want, paused, msg) +} + +// Neither pause reason ends the other's pause. +func TestPrefetcherPauseReasonsDoNotUndoEachOther(t *testing.T) { + for _, appFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("appFirst=%t", appFirst), func(t *testing.T) { + bg := newFakeBlockGetter(false) + config := newTestBlockRetrievalConfig(t, bg, nil) + appState := newFakeAppState( + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileNetworkState_WIFI) + q := newBlockRetrievalQueue(1, 1, 0, config, appState) + require.NotNil(t, q) + prefetchSyncCh := make(chan struct{}) + defer shutdownPrefetcherTest(t, q, prefetchSyncCh) + <-q.TogglePrefetcher(true, prefetchSyncCh, nil) + // The first iteration reads the network state; the second waits + // for a change from it. + notifySyncCh(t, prefetchSyncCh) + notifySyncCh(t, prefetchSyncCh) + waitFor(t, appState.netWaits, keybase1.MobileNetworkState_WIFI) + requirePaused(t, q, false, "paused in the foreground on wifi") + + if appFirst { + appState.setAppState(t, keybase1.MobileAppState_BACKGROUND) + requirePaused(t, q, true, "not paused in the background") + appState.setNetworkState(t, keybase1.MobileNetworkState_CELLULAR) + requirePaused(t, q, true, "not paused in the background on cellular") + } else { + appState.setNetworkState(t, keybase1.MobileNetworkState_CELLULAR) + requirePaused(t, q, true, "not paused on cellular") + appState.setAppState(t, keybase1.MobileAppState_BACKGROUND) + requirePaused(t, q, true, "not paused in the background on cellular") + } + + appState.setAppState(t, keybase1.MobileAppState_INACTIVE) + requirePaused(t, q, true, "an app-state change undid the cellular pause") + appState.setAppState(t, keybase1.MobileAppState_FOREGROUND) + requirePaused(t, q, true, "foregrounding undid the cellular pause") + + appState.setAppState(t, keybase1.MobileAppState_BACKGROUND) + requirePaused(t, q, true, "not paused in the background on cellular") + appState.setNetworkState(t, keybase1.MobileNetworkState_WIFI) + requirePaused(t, q, true, "leaving cellular undid the background pause") + + appState.setAppStateNoWait(keybase1.MobileAppState_FOREGROUND) + require.Eventually(t, func() bool { + paused, _ := q.Prefetcher().(*blockPrefetcher).getPaused() + return !paused + }, 10*time.Second, time.Millisecond, "still paused in the foreground on wifi") + }) + } +} diff --git a/go/kbfs/libkbfs/folder_block_manager.go b/go/kbfs/libkbfs/folder_block_manager.go index 54206e62f5d6..bf3ff00db068 100644 --- a/go/kbfs/libkbfs/folder_block_manager.go +++ b/go/kbfs/libkbfs/folder_block_manager.go @@ -1284,6 +1284,21 @@ func isPermanentQRError(err error) bool { } } +// WaitForeground blocks until u reports the app state as FOREGROUND, and +// reports false if stop closes first. +func WaitForeground(u env.AppStateUpdater, stop <-chan struct{}) bool { + state := u.AppState() + for state != keybase1.MobileAppState_FOREGROUND { + select { + case <-u.NextAppStateUpdate(state): + case <-stop: + return false + } + state = u.AppState() + } + return true +} + func (fbm *folderBlockManager) reclaimQuotaInBackground() { autoQR := true timer := time.NewTimer(fbm.config.Mode().QuotaReclamationPeriod()) @@ -1314,15 +1329,15 @@ func (fbm *folderBlockManager) reclaimQuotaInBackground() { case <-fbm.shutdownChan: return case <-fbm.appStateUpdater.NextAppStateUpdate(state): - state = fbm.appStateUpdater.AppState() - for state != keybase1.MobileAppState_FOREGROUND { + if s := fbm.appStateUpdater.AppState(); s != keybase1.MobileAppState_FOREGROUND { fbm.log.CDebugf(context.Background(), - "Pausing QR while not foregrounded: state=%s", state) - <-fbm.appStateUpdater.NextAppStateUpdate(state) - state = fbm.appStateUpdater.AppState() + "Pausing QR while not foregrounded: state=%s", s) + if !WaitForeground(fbm.appStateUpdater, fbm.shutdownChan) { + return + } + fbm.log.CDebugf( + context.Background(), "Resuming QR while foregrounded") } - fbm.log.CDebugf( - context.Background(), "Resuming QR while foregrounded") continue case <-timerChan: fbm.reclamationGroup.Add(1) @@ -1589,16 +1604,15 @@ func (fbm *folderBlockManager) cleanDiskCachesInBackground() { case <-fbm.shutdownChan: return case <-fbm.appStateUpdater.NextAppStateUpdate(state): - state = fbm.appStateUpdater.AppState() - for state != keybase1.MobileAppState_FOREGROUND { + if s := fbm.appStateUpdater.AppState(); s != keybase1.MobileAppState_FOREGROUND { fbm.log.CDebugf(context.Background(), - "Pausing sync-cache cleaning while not foregrounded: "+ - "state=%s", state) - <-fbm.appStateUpdater.NextAppStateUpdate(state) - state = fbm.appStateUpdater.AppState() + "Pausing sync-cache cleaning while not foregrounded: state=%s", s) + if !WaitForeground(fbm.appStateUpdater, fbm.shutdownChan) { + return + } + fbm.log.CDebugf(context.Background(), + "Resuming sync-cache cleaning while foregrounded") } - fbm.log.CDebugf(context.Background(), - "Resuming sync-cache cleaning while foregrounded") continue } diff --git a/go/kbfs/libkbfs/prefetcher.go b/go/kbfs/libkbfs/prefetcher.go index c18b7c95f71a..ec17020b85f8 100644 --- a/go/kbfs/libkbfs/prefetcher.go +++ b/go/kbfs/libkbfs/prefetcher.go @@ -1339,30 +1339,6 @@ func (p *blockPrefetcher) getPaused() (paused bool, ch <-chan struct{}) { return p.paused, p.pausedCh } -func (p *blockPrefetcher) handleAppStateChange( - appState *keybase1.MobileAppState, -) { - defer func() { - p.setPaused(false) - }() - - // Pause the prefetcher when backgrounded. - for *appState != keybase1.MobileAppState_FOREGROUND { - p.setPaused(true) - p.log.CDebugf( - context.TODO(), "Pausing prefetcher while backgrounded") - select { - case <-p.appStateUpdater.NextAppStateUpdate(*appState): - *appState = p.appStateUpdater.AppState() - case req := <-p.prefetchStatusCh.Out(): - p.handleStatusRequest(req.(*prefetchStatusRequest)) - continue - case <-p.almostDoneCh: - return - } - } -} - type prefetcherSubscriber struct { ch chan<- struct{} clientID SubscriptionManagerClientID @@ -1392,44 +1368,51 @@ func (ps prefetcherSubscriber) OnNonPathChange( } } -func (p *blockPrefetcher) handleNetStateChange( - netState *keybase1.MobileNetworkState, subCh <-chan struct{}, -) { - for *netState != keybase1.MobileNetworkState_CELLULAR { - return +func (p *blockPrefetcher) syncOnCellular() bool { + // Default to not syncing while on a cell network. + db := p.config.GetSettingsDB() + if db == nil { + return false } + s, err := db.Settings(context.TODO()) + return err == nil && s.SyncOnCellular +} - defer func() { - p.setPaused(false) - }() - - for *netState == keybase1.MobileNetworkState_CELLULAR { - // Default to not syncing while on a cell network. - syncOnCellular := false - db := p.config.GetSettingsDB() - if db != nil { - s, err := db.Settings(context.TODO()) - if err == nil { - syncOnCellular = s.SyncOnCellular - } - } - - if syncOnCellular { - // Can ignore this network change. - break +// waitWhilePaused pauses the prefetcher while the app is not in the +// foreground, or while on a cell network without syncing on cellular, and +// returns once neither holds or the prefetcher is shutting down. It watches +// both states whichever one paused it, so the end of one reason never +// unpauses while the other still holds. +func (p *blockPrefetcher) waitWhilePaused( + appState *keybase1.MobileAppState, netState *keybase1.MobileNetworkState, + subCh <-chan struct{}, +) { + defer p.setPaused(false) + for { + appPaused := *appState != keybase1.MobileAppState_FOREGROUND + netPaused := *netState == keybase1.MobileNetworkState_CELLULAR && + !p.syncOnCellular() + if !appPaused && !netPaused { + return } - p.setPaused(true) - p.log.CDebugf( - context.TODO(), "Pausing prefetcher on cell network") + if appPaused { + p.log.CDebugf( + context.TODO(), "Pausing prefetcher while backgrounded") + } + if netPaused { + p.log.CDebugf( + context.TODO(), "Pausing prefetcher on cell network") + } select { + case <-p.appStateUpdater.NextAppStateUpdate(*appState): + *appState = p.appStateUpdater.AppState() case <-p.appStateUpdater.NextNetworkStateUpdate(*netState): *netState = p.appStateUpdater.NetworkState() case <-subCh: p.log.CDebugf(context.TODO(), "Settings changed") case req := <-p.prefetchStatusCh.Out(): p.handleStatusRequest(req.(*prefetchStatusRequest)) - continue case <-p.almostDoneCh: return } @@ -1547,10 +1530,10 @@ func (p *blockPrefetcher) run( <-ch case <-p.appStateUpdater.NextAppStateUpdate(appState): appState = p.appStateUpdater.AppState() - p.handleAppStateChange(&appState) + p.waitWhilePaused(&appState, &netState, subCh) case <-p.appStateUpdater.NextNetworkStateUpdate(netState): netState = p.appStateUpdater.NetworkState() - p.handleNetStateChange(&netState, subCh) + p.waitWhilePaused(&appState, &netState, subCh) case <-subCh: // Settings have changed, so recheck the network state. netState = keybase1.MobileNetworkState_NONE diff --git a/go/kbfs/search/indexer.go b/go/kbfs/search/indexer.go index d2b6a054a390..0ef94a097385 100644 --- a/go/kbfs/search/indexer.go +++ b/go/kbfs/search/indexer.go @@ -1386,6 +1386,17 @@ func (i *Indexer) loop(ctx context.Context) { ctx, "Couldn't register for synced TLF updates: %+v", err) } + // stopped closes when either ctx or i.shutdownCh ends the loop, so the + // foreground wait below can watch both through one channel. + stopped := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + case <-i.shutdownCh: + } + close(stopped) + }() + outerLoop: for { err := i.loadIndex(ctx) @@ -1402,18 +1413,21 @@ outerLoop: i.log.CDebugf(ctx, "User changed") continue outerLoop case <-kbCtx.NextAppStateUpdate(state): - state = kbCtx.AppState() // TODO(HOTPOT-1494): once we are doing actual // indexing in a separate goroutine, pause/unpause it // via a channel send from here. - for state != keybase1.MobileAppState_FOREGROUND { + if s := kbCtx.AppState(); s != keybase1.MobileAppState_FOREGROUND { i.log.CDebugf(ctx, - "Pausing indexing while not foregrounded: state=%s", - state) - <-kbCtx.NextAppStateUpdate(state) - state = kbCtx.AppState() + "Pausing indexing while not foregrounded: state=%s", s) + if !libkbfs.WaitForeground(kbCtx, stopped) { + if ctx.Err() == nil { + i.cancelLoop() + } + return + } + i.log.CDebugf(ctx, "Resuming indexing while foregrounded") } - i.log.CDebugf(ctx, "Resuming indexing while foregrounded") + state = keybase1.MobileAppState_FOREGROUND continue case m := <-i.tlfCh: ctx := i.makeContext(ctx) diff --git a/go/kbfs/search/indexer_app_state_test.go b/go/kbfs/search/indexer_app_state_test.go new file mode 100644 index 000000000000..34931df18f23 --- /dev/null +++ b/go/kbfs/search/indexer_app_state_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Keybase Inc. All rights reserved. +// Use of this source code is governed by a BSD +// license that can be found in the LICENSE file. + +package search + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/keybase/client/go/kbfs/env" + "github.com/keybase/client/go/kbfs/idutil" + "github.com/keybase/client/go/kbfs/libcontext" + "github.com/keybase/client/go/kbfs/libkbfs" + "github.com/keybase/client/go/logger" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// backgroundKbCtx reports a BACKGROUND app state that never changes, and +// sends every state it is asked to wait on to waits. +type backgroundKbCtx struct { + env.Context + waits chan keybase1.MobileAppState +} + +func (c backgroundKbCtx) NextAppStateUpdate( + lastState keybase1.MobileAppState, +) <-chan struct{} { + select { + case c.waits <- lastState: + default: + } + if lastState != keybase1.MobileAppState_BACKGROUND { + ch := make(chan struct{}) + close(ch) + return ch + } + return nil +} + +func (c backgroundKbCtx) AppState() keybase1.MobileAppState { + return keybase1.MobileAppState_BACKGROUND +} + +type backgroundConfig struct { + libkbfs.Config + kbCtx backgroundKbCtx +} + +func (c backgroundConfig) KbContext() libkbfs.Context { + return c.kbCtx +} + +func TestIndexerPausedLoopExitsOnShutdown(t *testing.T) { + ctx := libcontext.BackgroundContextWithCancellationDelayer() + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + config := libkbfs.MakeTestConfigOrBust(t, "user1") + defer libkbfs.CheckConfigAndShutdown(ctx, t, config) + + bgConfig := backgroundConfig{ + Config: config, + kbCtx: backgroundKbCtx{ + Context: config.KbContext(), + waits: make(chan keybase1.MobileAppState, 100), + }, + } + noIndex := func( + context.Context, libkbfs.Config, idutil.SessionInfo, logger.Logger, + ) (context.Context, libkbfs.Config, func(context.Context) error, error) { + return nil, nil, nil, errors.New("no index in this test") + } + i, err := newIndexerWithConfigInit( + bgConfig, noIndex, testKVStoreName("TestIndexerPausedLoopExitsOnShutdown")) + require.NoError(t, err) + + timeout := time.After(30 * time.Second) + for paused := false; !paused; { + select { + case state := <-bgConfig.kbCtx.waits: + paused = state == keybase1.MobileAppState_BACKGROUND + case <-timeout: + t.Fatal("indexer loop did not pause") + } + } + + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 10*time.Second) + defer shutdownCancel() + require.NoError(t, i.Shutdown(shutdownCtx), "paused indexer loop did not exit on shutdown") +} diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 50b14ae21425..5a6b529e16bc 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -8,9 +8,11 @@ import ( "net/http" "runtime" "sync" + "sync/atomic" "github.com/keybase/client/go/kbhttp" "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/logger" "github.com/keybase/client/go/protocol/keybase1" ) @@ -26,116 +28,237 @@ type srvEndpoint struct { serve func(w http.ResponseWriter, req *http.Request) } +type handlerRequest struct { + endpoint string + desc srvEndpoint + done chan struct{} +} + +// Srv runs a local HTTP server. One goroutine, run, owns it: only run starts +// and stops it, reacting to app state changes, handler registrations and +// shutdown. type Srv struct { - libkb.Contextified + name string // prefixes every log line, so each server's lines are told apart + log logger.Logger + // appState reads the current app state and nextAppState waits for the next + // change, as libkb.MobileAppState and kbfs's env.AppStateUpdater spell them. + appState func() keybase1.MobileAppState + nextAppState func(lastState keybase1.MobileAppState) <-chan struct{} + // token is set once and kept across restarts, so URLs handed out before a restart keep working. + token string + listenerSource func() kbhttp.ListenerSource + stopInBackground bool // false on Android, where the server stays up in every state + // notify runs on run, so it must not call HandleFunc. It runs only when the + // bound address changes, the first bind included. + notify func(context.Context, keybase1.HttpSrvInfo) + + // status is the last address the server bound, kept while it is stopped so + // URLs built then point where it comes back; empty only until the first + // bind. Readers never wait on run. + status atomic.Pointer[keybase1.HttpSrvInfo] + handlers chan handlerRequest + shutdownOnce sync.Once + shutdownCh chan struct{} + done chan struct{} + // Owned by run. httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint - token string - startMu sync.Mutex + state keybase1.MobileAppState } +// NewSrv runs the service's HTTP server until the service shuts down. It reads +// g.NotifyRouter once, now, to announce every address it binds. func NewSrv(g *libkb.GlobalContext) *Srv { - h := &Srv{ - Contextified: libkb.NewContextified(g), - endpoints: make(map[string]srvEndpoint), - } - h.initHTTPSrv() - h.startHTTPSrv() - g.PushShutdownHook(func(mctx libkb.MetaContext) error { - h.httpSrv.Stop() + listenerSource := func() kbhttp.ListenerSource { + return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) + } + notifyRouter := g.NotifyRouter + // A failed start is logged, and the next app state change tries again. + r, _ := New("Srv", g.GetLog(), g.MobileAppState.State, g.MobileAppState.NextUpdate, listenerSource, + runtime.GOOS != "android", func(ctx context.Context, info keybase1.HttpSrvInfo) { + // e2e tests match this line; only this server logs it. + g.GetLog().CDebugf(ctx, "Srv: start: addr: %s token: %s", info.Address, TokenPrefix(info.Token)) + notifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + }) + g.PushShutdownHook(func(libkb.MetaContext) error { + r.Shutdown() return nil }) - go h.monitorAppState() - return h + return r +} + +// New returns a server that has acted on the current app state, with the error +// of that first start, if any. The server runs until Shutdown either way, and +// the next app state change tries again -- but only where the app state moves, +// which is mobile, so a caller on desktop decides for itself whether a failed +// first start is fatal. +func New(name string, log logger.Logger, appState func() keybase1.MobileAppState, + nextAppState func(lastState keybase1.MobileAppState) <-chan struct{}, + listenerSource func() kbhttp.ListenerSource, stopInBackground bool, + notify func(context.Context, keybase1.HttpSrvInfo), +) (*Srv, error) { + token, _ := libkb.RandHexString("", 32) + r := &Srv{ + name: name, + log: log, + appState: appState, + nextAppState: nextAppState, + token: token, + listenerSource: listenerSource, + stopInBackground: stopInBackground, + notify: notify, + handlers: make(chan handlerRequest), + shutdownCh: make(chan struct{}), + done: make(chan struct{}), + endpoints: make(map[string]srvEndpoint), + } + // Publish an empty status before run can be observed, so readers never dereference nil. + r.status.Store(&keybase1.HttpSrvInfo{}) + r.httpSrv = kbhttp.NewSrv(r.log, r.listenerSource()) + ready := make(chan error) + go r.run(ready) + return r, <-ready } func (r *Srv) debug(ctx context.Context, msg string, args ...any) { - r.G().Log.CDebugf(ctx, "Srv: %s", fmt.Sprintf(msg, args...)) + r.log.CDebugf(ctx, "%s: %s", r.name, fmt.Sprintf(msg, args...)) +} + +// TokenPrefix shortens a token for logging. +func TokenPrefix(token string) string { + if len(token) > 8 { + return token[:8] + "..." + } + return token } -func (r *Srv) initHTTPSrv() { - startPort := r.G().GetEnv().GetAttachmentHTTPStartPort() - r.httpSrv = kbhttp.NewSrv(r.G().GetLog(), kbhttp.NewRandomPortRangeListenerSource(startPort, 18000)) +func (r *Srv) wantUp(state keybase1.MobileAppState) bool { + return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } -func (r *Srv) startHTTPSrv() { - r.startMu.Lock() - defer r.startMu.Unlock() +// run owns the server. ready takes the first start's error, once run has acted +// on the app state it started in and published the result. +func (r *Srv) run(ready chan<- error) { + defer close(r.done) ctx := context.Background() - token, _ := libkb.RandHexString("", 32) - maxTries := 2 - success := false - for range maxTries { - if err := r.httpSrv.Start(); err != nil { - if errors.Is(err, kbhttp.ErrPinnedPortInUse) { - // If we hit this, just try again and get a different port. - // The advantage is that backing in and out of the thread will restore attachments, - // whereas if we do nothing you need to bkg/foreground. - r.debug(ctx, "startHTTPSrv: pinned port taken error, re-initializing and trying again") - r.initHTTPSrv() - continue + r.state = r.appState() + r.debug(ctx, "run: starting up in %v", r.state) + ready <- r.reconcile(ctx) + for { + select { + case <-r.nextAppState(r.state): + prev := r.state + r.state = r.appState() + if r.leavingBackground(prev) { + r.debug(ctx, "run: rebinding on %v -> %v", prev, r.state) + r.httpSrv.Stop() + } + _ = r.reconcile(ctx) + case req := <-r.handlers: + r.endpoints[req.endpoint] = req.desc + // A stopped server has no mux; start registers every endpoint. + if r.httpSrv.Active() { + r.httpSrv.HandleFunc("/"+req.endpoint, r.checkToken(req.desc.tokenMode, req.desc.serve)) } - r.debug(ctx, "startHTTPSrv: failed to start HTTP server: %s", err) - break + close(req.done) + case <-r.shutdownCh: + <-r.httpSrv.Stop() + return } - success = true - break } - if !success { - r.debug(ctx, "startHTTPSrv: exhausted attempts to start HTTP server, giving up") - return +} + +// leavingBackground reports a move from BACKGROUND or BACKGROUNDACTIVE to +// FOREGROUND or INACTIVE where the server stops in the background. The OS can +// reclaim a suspended app's listening socket without the app reaching +// BACKGROUND, leaving a server that looks up but never accepts, so the server +// is rebound on the way back rather than trusted. +func (r *Srv) leavingBackground(prev keybase1.MobileAppState) bool { + if !r.stopInBackground { + return false + } + switch prev { + case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE: + default: + return false + } + return r.state == keybase1.MobileAppState_FOREGROUND || r.state == keybase1.MobileAppState_INACTIVE +} + +// reconcile tears the server down only in BACKGROUND, and only where +// stopInBackground. INACTIVE (Control Center, system alerts, the app +// switcher) keeps it up, and every other state starts it if it isn't serving. +func (r *Srv) reconcile(ctx context.Context) error { + if !r.wantUp(r.state) { + r.httpSrv.Stop() + return nil + } + return r.start(ctx) +} + +func (r *Srv) start(ctx context.Context) error { + if r.httpSrv.Active() { + return nil + } + err := r.httpSrv.StartWithHandlers(r.registerEndpoints) + if errors.Is(err, kbhttp.ErrPinnedPortInUse) { + // Try again on a different port. Backing in and out of a thread then restores + // attachments; doing nothing would need a background/foreground. + r.debug(ctx, "start: pinned port taken, trying a new one") + r.httpSrv = kbhttp.NewSrv(r.log, r.listenerSource()) + err = r.httpSrv.StartWithHandlers(r.registerEndpoints) } - for endpoint, serveDesc := range r.endpoints { - r.HandleFunc(endpoint, serveDesc.tokenMode, serveDesc.serve) + if err != nil { + r.log.CWarningf(ctx, "%s: start: failed to start HTTP server: %s", r.name, err) + return err } addr, err := r.httpSrv.Addr() if err != nil { - r.debug(ctx, "startHTTPSrv: failed to get address after start?: %s", err) - } else { - r.debug(ctx, "startHTTPSrv: start success: addr: %s", addr) - } - r.token = token - tokenPrefix := r.token - if len(tokenPrefix) > 8 { - tokenPrefix = tokenPrefix[:8] + "..." - } - r.debug(ctx, "startHTTPSrv: addr: %s token: %s", addr, tokenPrefix) - r.G().NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, keybase1.HttpSrvInfo{ - Address: addr, - Token: r.token, - }) + return err + } + if addr == r.status.Load().Address { + return nil + } + info := keybase1.HttpSrvInfo{Address: addr, Token: r.token} + // Publish before notifying, so a listener reading Info gets the address it is told about. + r.status.Store(&info) + r.notify(ctx, info) + return nil } -func (r *Srv) monitorAppState() { - ctx := context.Background() - r.debug(ctx, "monitorAppState: starting up") - state := keybase1.MobileAppState_FOREGROUND - // We don't need this on Android - if runtime.GOOS == "android" { - return - } - for { - <-r.G().MobileAppState.NextUpdate(state) - state = r.G().MobileAppState.State() - switch state { - case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE: - r.startHTTPSrv() - case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_INACTIVE: - r.httpSrv.Stop() - } +func (r *Srv) registerEndpoints(mux *http.ServeMux) { + for endpoint, desc := range r.endpoints { + mux.HandleFunc("/"+endpoint, r.checkToken(desc.tokenMode, desc.serve)) } } +// Shutdown stops the server for good and waits for run to exit. +func (r *Srv) Shutdown() { + r.shutdownOnce.Do(func() { close(r.shutdownCh) }) + <-r.done +} + func (r *Srv) HandleFunc(endpoint string, tokenMode SrvTokenMode, serve func(w http.ResponseWriter, req *http.Request), ) { - r.httpSrv.HandleFunc("/"+endpoint, func(w http.ResponseWriter, req *http.Request) { + req := handlerRequest{endpoint: endpoint, desc: srvEndpoint{tokenMode: tokenMode, serve: serve}, done: make(chan struct{})} + select { + case r.handlers <- req: + <-req.done + case <-r.done: + } +} + +func (r *Srv) checkToken(tokenMode SrvTokenMode, + serve func(w http.ResponseWriter, req *http.Request), +) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { switch tokenMode { case SrvTokenModeDefault: if !hmac.Equal([]byte(req.URL.Query().Get("token")), []byte(r.token)) { r.debug(context.Background(), "HandleFunc: token failed: %s != %s", - req.URL.Query().Get("token"), r.token) + TokenPrefix(req.URL.Query().Get("token")), TokenPrefix(r.token)) w.WriteHeader(http.StatusForbidden) return } @@ -143,25 +266,23 @@ func (r *Srv) HandleFunc(endpoint string, tokenMode SrvTokenMode, // serve needs to authenticate on its own } serve(w, req) - }) - r.endpoints[endpoint] = srvEndpoint{ - tokenMode: tokenMode, - serve: serve, } } -func (r *Srv) Active() bool { - return r.httpSrv.Active() -} - func (r *Srv) Addr() (string, error) { - r.startMu.Lock() - defer r.startMu.Unlock() - return r.httpSrv.Addr() + info, err := r.Info() + return info.Address, err } -func (r *Srv) Token() string { - r.startMu.Lock() - defer r.startMu.Unlock() - return r.token +func (r *Srv) Token() string { return r.token } + +// Info returns the address and token together, for handing both to a client. +// While the server is stopped it returns where it last bound; it errors only +// if the server has never bound. +func (r *Srv) Info() (keybase1.HttpSrvInfo, error) { + info := *r.status.Load() + if info.Address == "" { + return keybase1.HttpSrvInfo{}, errors.New("server has never bound") + } + return info, nil } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go new file mode 100644 index 000000000000..05e8f7baf6b9 --- /dev/null +++ b/go/kbhttp/manager/manager_test.go @@ -0,0 +1,807 @@ +package manager + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/kbhttp" + "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" +) + +// listeners hands out pinned random-port listener sources, as NewSrv does, +// counts the listeners made, and remembers the last one so a test can kill it +// underneath the server. +type listeners struct { + sync.Mutex + calls int + last net.Listener +} + +type trackedSource struct { + l *listeners + src kbhttp.ListenerSource +} + +func (s trackedSource) GetListener() (net.Listener, string, error) { + listener, address, err := s.src.GetListener() + s.l.Lock() + defer s.l.Unlock() + s.l.calls++ + if err == nil { + s.l.last = listener + } + return listener, address, err +} + +func (l *listeners) source() kbhttp.ListenerSource { + return trackedSource{l: l, src: kbhttp.NewRandomPortRangeListenerSource(20000, 60000)} +} + +func (l *listeners) Calls() int { + l.Lock() + defer l.Unlock() + return l.calls +} + +func (l *listeners) kill(t *testing.T) { + l.Lock() + defer l.Unlock() + require.NoError(t, l.last.Close()) +} + +var client = &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{DisableKeepAlives: true}, +} + +// appState records the wait run asks for once per turn, after acting on the +// state it read. +type appState struct { + *libkb.MobileAppState + mu sync.Mutex + wait <-chan struct{} +} + +func (a *appState) NextUpdate(last keybase1.MobileAppState) <-chan struct{} { + wait := a.MobileAppState.NextUpdate(last) + a.mu.Lock() + defer a.mu.Unlock() + a.wait = wait + return wait +} + +// apps holds each test server's app state, since Srv now takes its two +// functions rather than an interface it could be read back off. +var apps sync.Map + +func app(srv *Srv) *appState { + a, ok := apps.Load(srv) + if !ok { + // Not t.Fatal: some callers are on worker goroutines. + panic("no appState registered for this Srv") + } + return a.(*appState) +} + +// serving reports whether anything answers HTTP at the address srv hands out. +func serving(srv *Srv) bool { + info, err := srv.Info() + if err != nil { + return false + } + status, _ := fetch(info) + return status != 0 +} + +func setup(t *testing.T, state keybase1.MobileAppState, stopInBackground bool) (*Srv, *listeners) { + return setupWithNotify(t, state, stopInBackground, func(context.Context, keybase1.HttpSrvInfo) {}) +} + +func setupWithNotify(t *testing.T, state keybase1.MobileAppState, stopInBackground bool, + notify func(context.Context, keybase1.HttpSrvInfo), +) (*Srv, *listeners) { + tc := libkb.SetupTest(t, "kbhttp", 2) + t.Cleanup(tc.Cleanup) + tc.G.MobileAppState.Update(state) + l := &listeners{} + as := &appState{MobileAppState: tc.G.MobileAppState} + srv, err := New("Srv", tc.G.Log, as.State, as.NextUpdate, l.source, stopInBackground, notify) + require.NoError(t, err) + apps.Store(srv, as) + t.Cleanup(func() { apps.Delete(srv) }) + t.Cleanup(srv.Shutdown) + // New returns having acted on the launch state; HandleFunc below would wait for run anyway. + require.Equal(t, srv.wantUp(state), serving(srv), "launch state not applied when New returned") + srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { + fmt.Fprint(w, "ok") + }) + return srv, l +} + +func fetch(info keybase1.HttpSrvInfo) (int, error) { + return fetchPath(info, "test") +} + +// fetchPath returns the HTTP status, or 0 with an error when no response came +// back. +func fetchPath(info keybase1.HttpSrvInfo, endpoint string) (int, error) { + resp, err := client.Get(fmt.Sprintf("http://%s/%s?token=%s", info.Address, endpoint, info.Token)) + if err != nil { + return 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + if resp.StatusCode != http.StatusOK || string(body) != "ok" { + return resp.StatusCode, fmt.Errorf("status %d body %q", resp.StatusCode, body) + } + return resp.StatusCode, nil +} + +// waitLoop waits until run has acted on the current app state and waits for +// its next change. Handler requests are synchronous, so no event a caller made +// is still pending. +func waitLoop(t *testing.T, srv *Srv) { + t.Helper() + require.Eventually(t, func() bool { + a := app(srv) + a.mu.Lock() + wait := a.wait + a.mu.Unlock() + if wait == nil { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "run did not catch up") +} + +func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { + t.Helper() + info, err := srv.Info() + require.NoError(t, err) + _, err = fetch(info) + require.NoError(t, err) + return info +} + +func requireStopped(t *testing.T, srv *Srv) { + t.Helper() + require.False(t, serving(srv), "server still serving") +} + +func requireNeverBound(t *testing.T, srv *Srv) { + t.Helper() + _, err := srv.Info() + require.Error(t, err) +} + +// leavesBackground is a move the server rebinds on where it stops in the +// background. +func leavesBackground(from, to keybase1.MobileAppState) bool { + return (from == keybase1.MobileAppState_BACKGROUND || from == keybase1.MobileAppState_BACKGROUNDACTIVE) && + (to == keybase1.MobileAppState_FOREGROUND || to == keybase1.MobileAppState_INACTIVE) +} + +// A stopped server keeps handing out where it last bound, so URLs built while +// it is down point where it comes back. +func TestInfoKeepsLastAddressWhileStopped(t *testing.T) { + srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + first := requireServing(t, srv) + + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + waitLoop(t, srv) + requireStopped(t, srv) + info, err := srv.Info() + require.NoError(t, err) + require.Equal(t, first, info) + addr, err := srv.Addr() + require.NoError(t, err) + require.Equal(t, first.Address, addr) + + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + waitLoop(t, srv) + require.Equal(t, first, requireServing(t, srv)) +} + +func TestNotifyOnlyOnAddressChange(t *testing.T) { + var mu sync.Mutex + var notified []keybase1.HttpSrvInfo + srv, _ := setupWithNotify(t, keybase1.MobileAppState_FOREGROUND, true, func(_ context.Context, info keybase1.HttpSrvInfo) { + mu.Lock() + defer mu.Unlock() + notified = append(notified, info) + }) + sent := func() []keybase1.HttpSrvInfo { + mu.Lock() + defer mu.Unlock() + return append([]keybase1.HttpSrvInfo(nil), notified...) + } + waitLoop(t, srv) + first := requireServing(t, srv) + require.Equal(t, []keybase1.HttpSrvInfo{first}, sent(), "first bind not announced once") + + // Every way back up on the pinned port binds the same address. + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_FOREGROUND, + } { + app(srv).Update(next) + waitLoop(t, srv) + } + require.Equal(t, first, requireServing(t, srv)) + require.Equal(t, []keybase1.HttpSrvInfo{first}, sent(), "same address announced again") + + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + waitLoop(t, srv) + squatter, err := net.Listen("tcp", first.Address) + require.NoError(t, err) + defer squatter.Close() + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + waitLoop(t, srv) + again := requireServing(t, srv) + require.NotEqual(t, first.Address, again.Address) + require.Equal(t, []keybase1.HttpSrvInfo{first, again}, sent(), "new address not announced once") +} + +// Leaving BACKGROUNDACTIVE stops and starts the server on its pinned port, so a +// listener the OS reclaimed while the app was suspended comes back. +func TestRebindOnLeavingBackground(t *testing.T) { + for _, to := range []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + } { + t.Run(to.String(), func(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + first := requireServing(t, srv) + + app(srv).Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + waitLoop(t, srv) + require.Equal(t, first, requireServing(t, srv)) + require.Equal(t, 1, l.Calls(), "BACKGROUNDACTIVE restarted the server") + + l.kill(t) + requireStopped(t, srv) + app(srv).Update(to) + waitLoop(t, srv) + require.Equal(t, 2, l.Calls(), "leaving BACKGROUNDACTIVE did not rebind") + require.Equal(t, first, requireServing(t, srv)) + + // Moving between up states is not leaving the background. + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_FOREGROUND, + } { + app(srv).Update(next) + waitLoop(t, srv) + } + require.Equal(t, 2, l.Calls(), "server rebound without leaving the background") + }) + } +} + +func TestNothingStartsAfterShutdown(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + requireServing(t, srv) + srv.Shutdown() + requireStopped(t, srv) + calls := l.Calls() + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + registered := make(chan struct{}) + go func() { + srv.HandleFunc("late", SrvTokenModeDefault, func(http.ResponseWriter, *http.Request) {}) + close(registered) + }() + select { + case <-registered: + case <-time.After(10 * time.Second): + require.Fail(t, "HandleFunc hung after Shutdown") + } + require.Never(t, func() bool { return serving(srv) || l.Calls() != calls }, 200*time.Millisecond, 10*time.Millisecond) +} + +// notify must see the address it announces, so a client reading Info right away gets it. +func TestInfoUpdateAnnouncesAPublishedAddress(t *testing.T) { + var srv *Srv + seen := make(chan error, 10) + srv, _ = setupWithNotify(t, keybase1.MobileAppState_BACKGROUND, true, func(_ context.Context, info keybase1.HttpSrvInfo) { + got, err := srv.Info() + if err == nil && got != info { + err = fmt.Errorf("Info %v while announcing %v", got, info) + } + seen <- err + }) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + select { + case err := <-seen: + require.NoError(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "no HTTPSrvInfoUpdate") + } +} + +func TestHandlerAddedWhileServingAnswers(t *testing.T) { + srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + srv.HandleFunc("late", SrvTokenModeDefault, func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "ok") }) + info, err := srv.Info() + require.NoError(t, err) + resp, err := client.Get(fmt.Sprintf("http://%s/late?token=%s", info.Address, info.Token)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestInactiveKeepsServingBackgroundStops(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + first := requireServing(t, srv) + require.Equal(t, 1, l.Calls()) + + app(srv).Update(keybase1.MobileAppState_INACTIVE) + waitLoop(t, srv) + require.Equal(t, first, requireServing(t, srv)) + require.Equal(t, 1, l.Calls(), "INACTIVE restarted the server") + + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + waitLoop(t, srv) + requireStopped(t, srv) + _, err := fetch(first) + require.Error(t, err) + + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + waitLoop(t, srv) + again := requireServing(t, srv) + // Usually 2; another process may take the pinned port while stopped. + require.GreaterOrEqual(t, l.Calls(), 2) + require.Equal(t, first.Token, again.Token, "token changed across a restart") + require.Equal(t, first.Token, srv.Token()) + _, err = fetch(keybase1.HttpSrvInfo{Address: again.Address, Token: first.Token}) + require.NoError(t, err) +} + +func TestBackgroundLaunchStartsOnlyWhenLeavingBackground(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_BACKGROUND, true) + require.Equal(t, 0, l.Calls(), "server started during a background launch") + requireNeverBound(t, srv) + waitLoop(t, srv) + require.Equal(t, 0, l.Calls()) + + app(srv).Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + waitLoop(t, srv) + requireServing(t, srv) +} + +var allStates = []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_BACKGROUND, +} + +func TestUpUnlessBackground(t *testing.T) { + for _, initial := range allStates { + t.Run(initial.String(), func(t *testing.T) { + srv, _ := setup(t, initial, true) + for range 2 { + for _, next := range allStates { + app(srv).Update(next) + waitLoop(t, srv) + if next == keybase1.MobileAppState_BACKGROUND { + requireStopped(t, srv) + } else { + requireServing(t, srv) + } + } + } + }) + } +} + +// An INACTIVE blip neither restarts the server nor breaks a request in flight, +// and neither does a BACKGROUNDACTIVE one where the server stays up in the +// background. +func TestBlipKeepsRequestInFlight(t *testing.T) { + for _, c := range []struct { + blip keybase1.MobileAppState + stopInBackground bool + }{ + {keybase1.MobileAppState_INACTIVE, true}, + {keybase1.MobileAppState_INACTIVE, false}, + {keybase1.MobileAppState_BACKGROUNDACTIVE, false}, + } { + blip := c.blip + t.Run(fmt.Sprintf("%v-stopInBackground=%v", blip, c.stopInBackground), func(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, c.stopInBackground) + entered, hold := make(chan struct{}), make(chan struct{}) + srv.HandleFunc("hold", SrvTokenModeDefault, func(w http.ResponseWriter, _ *http.Request) { + close(entered) + <-hold + fmt.Fprint(w, "ok") + }) + waitLoop(t, srv) + info := requireServing(t, srv) + res := make(chan error, 1) + go func() { + _, err := fetchPath(info, "hold") + res <- err + }() + select { + case <-entered: + case <-time.After(10 * time.Second): + require.Fail(t, "request did not arrive") + } + for _, state := range []keybase1.MobileAppState{blip, keybase1.MobileAppState_FOREGROUND} { + app(srv).Update(state) + waitLoop(t, srv) + } + close(hold) + require.NoError(t, <-res, "in-flight request broke across %v", blip) + require.Equal(t, info, requireServing(t, srv)) + require.Equal(t, 1, l.Calls(), "server restarted across %v", blip) + }) + } +} + +// Without stopping in the background (Android), the server serves in every +// state and is never rebound. +func TestNotStoppingInBackgroundStaysUp(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_BACKGROUND, false) + waitLoop(t, srv) + requireServing(t, srv) + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + } { + app(srv).Update(next) + waitLoop(t, srv) + requireServing(t, srv) + } + require.Equal(t, 1, l.Calls(), "server restarted") +} + +// brokenSource makes no listener while it is broken. +type brokenSource struct { + broken *atomic.Bool + src kbhttp.ListenerSource +} + +func (s brokenSource) GetListener() (net.Listener, string, error) { + if s.broken.Load() { + return nil, "", errors.New("no listener") + } + return s.src.GetListener() +} + +// New reports a failed first start, which kbfs treats as fatal since desktop +// has no app state change to retry on. The server is left running, and where +// the app state does move it comes up at the next change. +func TestNewReportsFirstStartErrorAndRetries(t *testing.T) { + tc := libkb.SetupTest(t, "kbhttp", 2) + defer tc.Cleanup() + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + broken := &atomic.Bool{} + broken.Store(true) + srv, err := New("Srv", tc.G.Log, tc.G.MobileAppState.State, tc.G.MobileAppState.NextUpdate, + func() kbhttp.ListenerSource { + return brokenSource{broken: broken, src: kbhttp.NewRandomPortRangeListenerSource(20000, 60000)} + }, true, func(context.Context, keybase1.HttpSrvInfo) {}) + require.Error(t, err) + t.Cleanup(srv.Shutdown) + requireNeverBound(t, srv) + + broken.Store(false) + tc.G.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + require.Eventually(t, func() bool { return serving(srv) }, 10*time.Second, time.Millisecond, + "server did not start on the next app state change") +} + +func TestScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + stopInBackground := sc.Platform == lifecycletest.IOS + srv, l := setup(t, lifecycletest.InitialState, stopInBackground) + lifecycletest.Play(t, app(srv).MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + waitLoop(t, srv) + if !srv.wantUp(step.Want) { + if serving(srv) { + t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) + } + return + } + info, err := srv.Info() + require.NoError(t, err) + if _, err := fetch(info); err != nil { + t.Fatalf("step %d %v: server down in %v: %v", i, step.Do, step.Want, err) + } + // Leave the server dead before a step that leaves the + // background, whose rebind must bring it back. + if stopInBackground && i+1 < len(sc.Steps) && leavesBackground(step.Want, sc.Steps[i+1].Want) { + l.kill(t) + } + }) + }) + } +} + +func TestPinnedPortTakenPicksNewAddress(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + first := requireServing(t, srv) + + // Readers race run replacing the server, for the race detector. + stop := make(chan struct{}) + var readers sync.WaitGroup + for _, read := range []func(){ + func() { _, _ = srv.Addr() }, + func() { _, _ = srv.Info() }, + } { + readers.Add(1) + go func() { + defer readers.Done() + for { + select { + case <-stop: + return + default: + } + read() + runtime.Gosched() + } + }() + } + + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + waitLoop(t, srv) + requireStopped(t, srv) + squatter, err := net.Listen("tcp", first.Address) + require.NoError(t, err) + defer squatter.Close() + + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + waitLoop(t, srv) + close(stop) + readers.Wait() + + again := requireServing(t, srv) + require.NotEqual(t, first.Address, again.Address) + require.Equal(t, first.Token, again.Token) + require.Equal(t, 3, l.Calls()) +} + +// requestWorker fetches until stop closes. A request racing a restart may +// fail to connect, but any response it gets must be a good one. +func requestWorker(srv *Srv, stale keybase1.HttpSrvInfo, stop chan struct{}, ok *atomic.Int64, bad chan error) { + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + info := stale + if i%2 == 0 { + var err error + if info, err = srv.Info(); err != nil { + runtime.Gosched() + continue + } + } + status, err := fetch(info) + switch { + case err == nil: + ok.Add(1) + case status != 0: + select { + case bad <- err: + default: + } + } + } +} + +func TestConcurrentRequestsDuringRestart(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) + first := requireServing(t, srv) + + stop := make(chan struct{}) + bad := make(chan error, 1) + var ok atomic.Int64 + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + requestWorker(srv, first, stop, &ok, bad) + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for i := range 100 { + srv.HandleFunc(fmt.Sprintf("extra%d", i), SrvTokenModeUnchecked, func(http.ResponseWriter, *http.Request) {}) + } + }() + + for range 50 { + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + waitLoop(t, srv) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + waitLoop(t, srv) + time.Sleep(time.Millisecond) + } + close(stop) + wg.Wait() + + select { + case err := <-bad: + t.Fatalf("bad response during restarts: %v", err) + default: + } + require.Positive(t, ok.Load()) + require.GreaterOrEqual(t, l.Calls(), 51) + require.Equal(t, first.Token, requireServing(t, srv).Token) +} + +// Transitions, listener deaths, handler registrations and requests racing +// each other leave a working server and no goroutines after Shutdown. +func TestStressTransitionsAndRequests(t *testing.T) { + tc := libkb.SetupTest(t, "kbhttp", 1) + defer tc.Cleanup() + baseline := runtime.NumGoroutine() + + l := &listeners{} + as := &appState{MobileAppState: tc.G.MobileAppState} + srv, err := New("Srv", tc.G.Log, as.State, as.NextUpdate, l.source, true, + func(context.Context, keybase1.HttpSrvInfo) {}) + require.NoError(t, err) + apps.Store(srv, as) + t.Cleanup(func() { apps.Delete(srv) }) + srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { + fmt.Fprint(w, "ok") + }) + waitLoop(t, srv) + first := requireServing(t, srv) + token := first.Token + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } + + stop := make(chan struct{}) + bad := make(chan error, 1) + var ok atomic.Int64 + var workers, writers sync.WaitGroup + for range 4 { + workers.Add(1) + go func() { + defer workers.Done() + requestWorker(srv, first, stop, &ok, bad) + }() + } + workers.Add(1) + go func() { + defer workers.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + if i < 200 { + srv.HandleFunc(fmt.Sprintf("extra%d", i), SrvTokenModeUnchecked, func(http.ResponseWriter, *http.Request) {}) + } + _, _ = srv.Addr() + if info, err := srv.Info(); err == nil && info.Token != token { + select { + case bad <- fmt.Errorf("token changed to %s", info.Token): + default: + } + } + runtime.Gosched() + } + }() + workers.Add(1) + go func() { + defer workers.Done() + for { + select { + case <-stop: + return + case <-time.After(5 * time.Millisecond): + } + l.Lock() + if l.last != nil { + _ = l.last.Close() + } + l.Unlock() + } + }() + for w := range 4 { + writers.Add(1) + go func() { + defer writers.Done() + rng := rand.New(rand.NewSource(int64(w))) + for range 300 { + app(srv).Update(states[rng.Intn(len(states))]) + if rng.Intn(4) == 0 { + time.Sleep(time.Duration(rng.Intn(200)) * time.Microsecond) + } + } + }() + } + + done := make(chan struct{}) + go func() { + writers.Wait() + close(stop) + workers.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(60 * time.Second): + t.Fatal("deadlock: transitions and requests did not finish") + } + select { + case err := <-bad: + t.Fatalf("bad response during transitions: %v", err) + default: + } + + // Leaving BACKGROUND rebinds whatever listener the killer left dead. + for _, state := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_FOREGROUND, + } { + app(srv).Update(state) + waitLoop(t, srv) + } + require.Equal(t, token, requireServing(t, srv).Token) + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + waitLoop(t, srv) + requireStopped(t, srv) + t.Logf("%d good responses, %d listeners", ok.Load(), l.Calls()) + + srv.Shutdown() + + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline+5 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline+5, "leaked goroutines") +} diff --git a/go/kbhttp/srv.go b/go/kbhttp/srv.go index 5e4b61236e1b..9c3b765e12e0 100644 --- a/go/kbhttp/srv.go +++ b/go/kbhttp/srv.go @@ -161,6 +161,13 @@ func NewSrv(log logger.Logger, listenerSource ListenerSource) *Srv { // Start starts listening on the server's listener source. func (h *Srv) Start() (err error) { + return h.StartWithHandlers(nil) +} + +// StartWithHandlers starts listening like Start, but first lets register add +// handlers to the new ServeMux, so no request can reach the server before +// they exist. +func (h *Srv) StartWithHandlers(register func(mux *http.ServeMux)) (err error) { h.Lock() defer h.Unlock() if h.server != nil { @@ -174,6 +181,9 @@ func (h *Srv) Start() (err error) { h.log.Debug("kbhttp.Srv: failed to get a listener: %s", err) return err } + if register != nil { + register(h.ServeMux) + } h.server = &http.Server{ Addr: address, Handler: h.ServeMux, diff --git a/go/kbhttp/srv_test.go b/go/kbhttp/srv_test.go index e25ff18a782a..4292d6da0f0c 100644 --- a/go/kbhttp/srv_test.go +++ b/go/kbhttp/srv_test.go @@ -6,7 +6,9 @@ package kbhttp import ( "fmt" "io" + "net" "net/http" + "sync" "testing" "github.com/keybase/client/go/logger" @@ -37,3 +39,65 @@ func TestSrv(t *testing.T) { test(NewPortRangeListenerSource(7000, 8000)) test(NewRandomPortRangeListenerSource(7000, 8000)) } + +type capturingListenerSource struct { + sync.Mutex + listener net.Listener +} + +func (c *capturingListenerSource) GetListener() (net.Listener, string, error) { + listener, address, err := NewAutoPortListenerSource().GetListener() + c.Lock() + defer c.Unlock() + c.listener = listener + return listener, address, err +} + +func (c *capturingListenerSource) kill() { + c.Lock() + defer c.Unlock() + _ = c.listener.Close() +} + +// A server whose listener died underneath it still counts as running, so the +// manager rebinds it with Stop and then Start. +func TestSrvStopStartAfterListenerDies(t *testing.T) { + source := &capturingListenerSource{} + srv := NewSrv(logger.NewTestLogger(t), source) + client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} + get := func() error { + addr, err := srv.Addr() + if err != nil { + return err + } + resp, err := client.Get(fmt.Sprintf("http://%s/test", addr)) + if err != nil { + return err + } + defer resp.Body.Close() + out, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if string(out) != "success" { + return fmt.Errorf("unexpected body %q", out) + } + return nil + } + register := func(mux *http.ServeMux) { + mux.HandleFunc("/test", func(resp http.ResponseWriter, req *http.Request) { + fmt.Fprintf(resp, "success") + }) + } + + require.NoError(t, srv.StartWithHandlers(register)) + require.NoError(t, get()) + + source.kill() + require.Error(t, get()) + <-srv.Stop() + require.NoError(t, srv.StartWithHandlers(register)) + require.NoError(t, get()) + <-srv.Stop() + require.False(t, srv.Active()) +} diff --git a/go/kbtest/chat.go b/go/kbtest/chat.go index 734c915d9b02..d743bc691d69 100644 --- a/go/kbtest/chat.go +++ b/go/kbtest/chat.go @@ -398,9 +398,7 @@ func (m ChatRemoteMockServerConnection) GetClient() chat1.RemoteInterface { return m.mock } -func (m ChatRemoteMockServerConnection) Reconnect(ctx context.Context) (bool, error) { - return false, nil -} +func (m ChatRemoteMockServerConnection) Reconnect(ctx context.Context) {} type ChatRemoteMock struct { world *ChatMockWorld diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index c278bce2fd79..ab5d886a38ac 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,57 @@ 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, still under the lock, so the notification is + // queued in the same critical section that wrote the state. Update has one + // writer, lifecycle.Controller.applyLocked under Controller.mu, so each + // connection's queue holds this writer's notifications in the order it wrote + // them, and the last one a client gets carries the latest state (see + // connSender). Cheap to hold: queueing never blocks, and every send happens + // on the connections' sender goroutines. Nothing it touches reads app state, + // so it cannot re-enter this lock. + 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 +371,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..136dc9d2b458 --- /dev/null +++ b/go/libkb/appstate_test.go @@ -0,0 +1,138 @@ +// 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()) +} + +func appStateChanges(t *testing.T, rec *NotifyRecorder) []keybase1.MobileAppState { + t.Helper() + var ret []keybase1.MobileAppState + for _, m := range rec.Messages() { + if m.Method != "keybase.1.NotifyApp.mobileAppStateChanged" { + continue + } + var arg keybase1.MobileAppStateChangedArg + require.NoError(t, m.Decode(&arg)) + ret = append(ret, arg.State) + } + return ret +} + +// Clients are told from the one place the value changes, so no writer can add a +// path that moves the state without announcing it. +func TestMobileAppStateAnnouncesOnlyOnChange(t *testing.T) { + tc := SetupTest(t, "MobileAppStateAnnounce", 0) + defer tc.Cleanup() + tc.G.SetService() + a := NewMobileAppState(tc.G) + rec := NewNotifyRecorder(tc.G, keybase1.NotificationChannels{App: true}) + defer rec.Close() + + require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + require.True(t, a.Update(keybase1.MobileAppState_FOREGROUND)) + rec.Flush() + require.Equal(t, []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_FOREGROUND, + }, appStateChanges(t, rec), "one notification per change, none for a same-value update") +} + +// The notification is queued in the same critical section that wrote the +// state, so two concurrent Updates queue in the order they wrote rather than in +// whatever order they reached the router. Checked white-box: holding the lock +// across updateLocked is the only way to observe "has it been queued yet", and +// the answer must be yes before the lock is released. +func TestMobileAppStateQueuesUnderTheLock(t *testing.T) { + tc := SetupTest(t, "MobileAppStateQueue", 0) + defer tc.Cleanup() + tc.G.SetService() + a := NewMobileAppState(tc.G) + rec := NewNotifyRecorder(tc.G, keybase1.NotificationChannels{App: true}) + defer rec.Close() + + a.Lock() + changed := a.updateLocked(keybase1.MobileAppState_BACKGROUND) + // nothing queued reads app state (there is no clientState reader here), so + // flushing under the lock cannot deadlock + rec.Flush() + queued := appStateChanges(t, rec) + a.Unlock() + + require.True(t, changed) + require.Equal(t, []keybase1.MobileAppState{keybase1.MobileAppState_BACKGROUND}, queued, + "the change was queued before the lock that wrote it was released") +} diff --git a/go/libkb/bgticker.go b/go/libkb/bgticker.go index 48d7b4d6eb65..d8eda492f1e5 100644 --- a/go/libkb/bgticker.go +++ b/go/libkb/bgticker.go @@ -1,6 +1,7 @@ package libkb import ( + "sync" "time" ) @@ -11,6 +12,8 @@ type BgTicker struct { c chan time.Time ticker *time.Ticker resumeWait time.Duration + done chan struct{} + stopOnce sync.Once } // This ticker wrap's Go's time.Ticker to wait a given time.Duration before @@ -30,18 +33,38 @@ func NewBgTickerWithWait(duration time.Duration, wait time.Duration) *BgTicker { c: c, ticker: time.NewTicker(duration - wait), resumeWait: wait, + done: make(chan struct{}), } go t.tick() return t } +// tick ends on Stop: a stopped time.Ticker never closes its channel, and +// nobody may be left to read C. func (t *BgTicker) tick() { - for c := range t.ticker.C { - time.Sleep(RandomJitter(t.resumeWait)) - t.c <- c + for { + var c time.Time + select { + case c = <-t.ticker.C: + case <-t.done: + return + } + wait := time.NewTimer(RandomJitter(t.resumeWait)) + select { + case <-wait.C: + case <-t.done: + wait.Stop() + return + } + select { + case t.c <- c: + case <-t.done: + return + } } } func (t *BgTicker) Stop() { t.ticker.Stop() + t.stopOnce.Do(func() { close(t.done) }) } diff --git a/go/libkb/bgticker_test.go b/go/libkb/bgticker_test.go index 8cecf7f8c6d1..dd5d2e7dd9e7 100644 --- a/go/libkb/bgticker_test.go +++ b/go/libkb/bgticker_test.go @@ -1,6 +1,7 @@ package libkb import ( + "runtime" "testing" "time" @@ -27,3 +28,33 @@ func TestBgTicker(t *testing.T) { } } } + +// Stop ends the tick goroutine whether it waits for a tick, waits out the +// resume wait, or is blocked handing a tick to a reader that went away. +func TestBgTickerStopEndsGoroutine(t *testing.T) { + baseline := runtime.NumGoroutine() + var tickers []*BgTicker + for i := range 30 { + switch i % 3 { + case 0: + tickers = append(tickers, NewBgTickerWithWait(time.Hour, time.Millisecond)) + case 1: + tickers = append(tickers, NewBgTickerWithWait(time.Hour+time.Millisecond, time.Hour)) + default: + ticker := NewBgTickerWithWait(2*time.Millisecond, time.Millisecond) + // fill C, so the next tick blocks on the send + <-ticker.C + tickers = append(tickers, ticker) + } + } + time.Sleep(50 * time.Millisecond) + for _, ticker := range tickers { + ticker.Stop() + ticker.Stop() + } + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "leaked tick goroutines") +} diff --git a/go/libkb/connmgr.go b/go/libkb/connmgr.go index 01cc2854e92a..5340e61e8d9d 100644 --- a/go/libkb/connmgr.go +++ b/go/libkb/connmgr.go @@ -22,11 +22,6 @@ type ConnectionID int // true to keep going and false to stop. type ApplyFn func(i ConnectionID, xp rpc.Transporter) bool -// ApplyDetailsFn can be applied to every connection. It is called with the -// RPC transporter, and also the connectionID. It should return a bool -// true to keep going and false to stop. -type ApplyDetailsFn func(i ConnectionID, xp rpc.Transporter, details *keybase1.ClientDetails) bool - // LabelCb is a callback to be run when a client connects and labels itself. type LabelCb func(typ keybase1.ClientType) @@ -44,23 +39,14 @@ type ConnectionManager struct { labelCbs []LabelCb } -// AddConnection adds a new connection to the table of Connection object, with a -// related closeListener. We'll listen for a close on that channel, and when one occurs, -// we'll remove the connection from the pool. -func (c *ConnectionManager) AddConnection(xp rpc.Transporter, closeListener chan error) ConnectionID { +// AddConnection adds a new connection to the table of Connection objects. +// NotifyRouter.AddConnection removes it when the connection closes. +func (c *ConnectionManager) AddConnection(xp rpc.Transporter) ConnectionID { c.Lock() + defer c.Unlock() c.nxt++ // increment first, since 0 is reserved id := c.nxt c.lookup[id] = &rpcConnection{transporter: xp} - c.Unlock() - - if closeListener != nil { - go func() { - <-closeListener - c.removeConnection(id) - }() - } - return id } @@ -183,24 +169,6 @@ func (c *ConnectionManager) ApplyAll(f ApplyFn) { } } -// ApplyAllDetails applies the given function f to all connections in the table. -// If you're going to do something blocking, please do it in a GoRoutine, -// since we're holding the lock for all connections as we do this. -func (c *ConnectionManager) ApplyAllDetails(f ApplyDetailsFn) { - c.Lock() - defer c.Unlock() - for k, v := range c.lookup { - status := v.details - var details *keybase1.ClientDetails - if status != nil { - details = &status.Details - } - if !f(k, v.transporter, details) { - break - } - } -} - // NewConnectionManager makes a new ConnectionManager. func NewConnectionManager() *ConnectionManager { return &ConnectionManager{ diff --git a/go/libkb/context.go b/go/libkb/context.go index 2e9fb26a1a12..b119c021e1dd 100644 --- a/go/libkb/context.go +++ b/go/libkb/context.go @@ -366,7 +366,7 @@ func (m MetaContext) SwitchUserNewConfig(u keybase1.UID, n NormalizedUsername, s func (m MetaContext) switchUserNewConfig(u keybase1.UID, n NormalizedUsername, salt []byte, d keybase1.DeviceID, ad *ActiveDevice) error { g := m.G() - defer g.switchUserMu.Acquire(m, "switchUserNewConfig")() + defer g.lockSwitchUser(m, ad != nil, "switchUserNewConfig")() cw := g.Env.GetConfigWriter() if cw == nil { return NoConfigWriterError{} @@ -398,7 +398,7 @@ func (m MetaContext) SwitchUserNewConfigActiveDevice(uv keybase1.UserVersion, n // etc). It does this in a critical section, holding switchUserMu. func (m MetaContext) SwitchUserNukeConfig(n NormalizedUsername) error { g := m.G() - defer g.switchUserMu.Acquire(m, "SwitchUserNukeConfig")() + defer g.lockSwitchUser(m, false, "SwitchUserNukeConfig")() cw := g.Env.GetConfigWriter() cr := g.Env.GetConfig() if cw == nil { @@ -435,7 +435,7 @@ func (m MetaContext) SwitchUserToActiveDevice(n NormalizedUsername, ad *ActiveDe if !n.IsValid() { return NewBadUsernameError(n.String()) } - defer g.switchUserMu.Acquire(m, "SwitchUserToActiveDevice %v", n)() + defer g.lockSwitchUser(m, false, "SwitchUserToActiveDevice %v", n)() cw := g.Env.GetConfigWriter() if cw == nil { return NoConfigWriterError{} @@ -459,7 +459,7 @@ func (m MetaContext) SwitchUserToActiveDevice(n NormalizedUsername, ad *ActiveDe func (m MetaContext) SwitchUserDeprovisionNukeConfig(username NormalizedUsername) error { g := m.G() - defer g.switchUserMu.Acquire(m, "SwitchUserDeprovisionNukeConfig %v", username)() + defer g.lockSwitchUser(m, false, "SwitchUserDeprovisionNukeConfig %v", username)() cw := g.Env.GetConfigWriter() if cw == nil { @@ -481,7 +481,7 @@ func (m MetaContext) SwitchUserToActiveOneshotDevice(uv keybase1.UserVersion, nu defer m.Trace("MetaContext#SwitchUserToActiveOneshotDevice", &err)() g := m.G() - defer g.switchUserMu.Acquire(m, "SwitchUserToActiveOneshotDevice")() + defer g.lockSwitchUser(m, true, "SwitchUserToActiveOneshotDevice")() cw := g.Env.GetConfigWriter() if cw == nil { return NoConfigWriterError{} @@ -504,7 +504,7 @@ func (m MetaContext) SwitchUserToActiveOneshotDevice(uv keybase1.UserVersion, nu func (m MetaContext) SwitchUserLoggedOut() (err error) { defer m.Trace("MetaContext#SwitchUserLoggedOut", &err)() g := m.G() - defer g.switchUserMu.Acquire(m, "SwitchUserLoggedOut")() + defer g.lockSwitchUser(m, false, "SwitchUserLoggedOut")() cw := g.Env.GetConfigWriter() if cw == nil { return NoConfigWriterError{} @@ -530,7 +530,7 @@ func (m MetaContext) SetActiveDevice(uv keybase1.UserVersion, deviceID keybase1. sigKey, encKey GenericKey, deviceName string, keychainMode KeychainMode, ) error { g := m.G() - defer g.switchUserMu.Acquire(m, "SetActiveDevice")() + defer g.lockSwitchUser(m, false, "SetActiveDevice")() if !g.Env.GetUID().Equal(uv.Uid) { return NewUIDMismatchError("UID switched out from underneath provisioning process") } @@ -539,13 +539,13 @@ func (m MetaContext) SetActiveDevice(uv keybase1.UserVersion, deviceID keybase1. func (m MetaContext) SetSigningKey(uv keybase1.UserVersion, deviceID keybase1.DeviceID, sigKey GenericKey, deviceName string) error { g := m.G() - defer g.switchUserMu.Acquire(m, "SetSigningKey")() + defer g.lockSwitchUser(m, false, "SetSigningKey")() return g.ActiveDevice.setSigningKey(g, uv, deviceID, sigKey, deviceName) } func (m MetaContext) SetEncryptionKey(uv keybase1.UserVersion, deviceID keybase1.DeviceID, encKey GenericKey) error { g := m.G() - defer g.switchUserMu.Acquire(m, "SetEncryptionKey")() + defer g.lockSwitchUser(m, false, "SetEncryptionKey")() return g.ActiveDevice.setEncryptionKey(uv, deviceID, encKey) } diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 68e8f6c9233a..28b125767b1e 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,8 @@ 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 + PendingPushTap *PendingPushTap // Holds the route a tapped notification resolved to until a client takes it 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 @@ -167,7 +170,8 @@ type GlobalContext struct { // It is threadsafe to call methods on ActiveDevice which will always be non-nil. // But don't access its members directly. If you're going to be changing out the - // user (and resetting the ActiveDevice), then you should hold the switchUserMu + // user (and resetting the ActiveDevice), then you should hold the switchUserMu, + // through lockSwitchUser switchUserMu *VerboseLock ActiveDevice *ActiveDevice switchedUsers map[NormalizedUsername]bool // bookkeep users who have been switched over (and are still in secret store) @@ -305,6 +309,11 @@ 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.PendingPushTap = NewPendingPushTap(g) g.DesktopAppState = NewDesktopAppState(g) g.RPCCanceler = NewRPCCanceler() g.IdentifyDispatch = NewIdentifyDispatch() @@ -352,10 +361,42 @@ func (g *GlobalContext) SetAvatarLoader(a AvatarLoaderSource) { g.avatarLoader = a } +type sessionIdentity struct { + valid bool + uid keybase1.UID + deviceID keybase1.DeviceID +} + +func (g *GlobalContext) sessionIdentity() sessionIdentity { + return sessionIdentity{valid: g.ActiveDevice.Valid(), uid: g.ActiveDevice.UID(), deviceID: g.ActiveDevice.DeviceID()} +} + +// lockSwitchUser takes switchUserMu, which every session write (the active +// device, the config's current user) is made under. A release that changed the +// session queues a clientState to connected clients, after unlocking. +// +// promotion marks the write a provisioning, signup or oneshot flow makes before +// it completes: if it leaves a valid session it queues nothing, so clients do +// not see the login early -- the flow completes with SendLogin, which queues +// one. A promotion that leaves no valid session is a clear and queues one like +// any other. See connSender for why that is enough. +func (g *GlobalContext) lockSwitchUser(mctx MetaContext, promotion bool, reasonFormat string, args ...any) (release func()) { + unlock := g.switchUserMu.Acquire(mctx, reasonFormat, args...) + before := g.sessionIdentity() + return func() { + after := g.sessionIdentity() + unlock() + earlyLogin := promotion && after.valid + if after != before && !earlyLogin { + g.NotifyRouter.AnnounceClientState(mctx.Ctx()) + } + } +} + // simulateServiceRestart simulates what happens when a service restarts for the // purposes of testing. func (g *GlobalContext) simulateServiceRestart() { - defer g.switchUserMu.Acquire(NewMetaContext(context.TODO(), g), "simulateServiceRestart")() + defer g.lockSwitchUser(NewMetaContext(context.TODO(), g), false, "simulateServiceRestart")() _ = g.ActiveDevice.Clear() } @@ -836,6 +877,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/leveldb.go b/go/libkb/leveldb.go index 7a048acce3ed..b5b777061d95 100644 --- a/go/libkb/leveldb.go +++ b/go/libkb/leveldb.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "github.com/syndtr/goleveldb/leveldb" errors "github.com/syndtr/goleveldb/leveldb/errors" @@ -118,10 +119,17 @@ type LevelDb struct { // rather than the DB itself. More specifically, close does Lock(), while // other DB operations does RLock(). sync.RWMutex - db *leveldb.DB + // db is an atomic.Pointer rather than a plain field guarded by RLock/Lock + // because the lazy open's assignment runs under the read lock (shared), + // so a plain field would race against other readers that don't go + // through dbOpenerOnce, such as openedDb(). + db atomic.Pointer[leveldb.DB] dbOpenerOnce *sync.Once cleaner *levelDbCleaner + // flushHook, if set, runs after each memtable rotation. Tests only. + flushHook func() + filename string Contextified } @@ -163,13 +171,14 @@ func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) l.dbOpenerOnce.Do(func() { l.G().Log.Debug("+ LevelDb.open") fn := l.GetFilename() - l.G().Log.Debug("| Opening LevelDB for local cache: %v %s", l, fn) + l.G().Log.Debug("| Opening LevelDB for local cache: %s", fn) l.G().Log.Debug("| Opening LevelDB options: %+v", l.Opts()) - l.db, err = leveldb.OpenFile(fn, l.Opts()) + db, openErr := leveldb.OpenFile(fn, l.Opts()) + err = openErr if _, ok := err.(*errors.ErrCorrupted); ok { l.G().Log.Debug("| LevelDb was corrupted; attempting recovery (%v)", err) var recoveryError error - l.db, recoveryError = leveldb.RecoverFile(fn, nil) + db, recoveryError = leveldb.RecoverFile(fn, nil) if recoveryError != nil { l.G().Log.Debug("| Recovery failed: %v", recoveryError) } else { @@ -179,8 +188,9 @@ func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) } } l.G().Log.Debug("- LevelDb.open -> %s", ErrToOk(err)) - if l.db != nil { - l.cleaner.setDb(l.db) + l.db.Store(db) + if db != nil { + l.cleaner.start(db) } }) @@ -188,7 +198,7 @@ func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) return err } - if l.db == nil { + if l.db.Load() == nil { // This means DB is already closed. We are preventing lazy-opening after // closing, so just return error here. return LevelDBOpenClosedError{} @@ -214,7 +224,7 @@ func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) // we should at least try instead of auto returning LevelDBOpenClosederror. if err != nil { l.Lock() - if l.db == nil { + if l.db.Load() == nil { l.G().Log.Debug("LevelDb: doWhileOpenAndNukeIfCorrupted: resetting sync one: %s", err) l.dbOpenerOnce = new(sync.Once) } @@ -230,42 +240,54 @@ func (l *LevelDb) ForceOpen() error { return l.doWhileOpenAndNukeIfCorrupted(func() error { return nil }) } -// levelDbFlushSentinelKey lives in the "pm" table so the db cleaner ignores -// it. Written before CompactRange so the memtable contains at least one key -// and isMemOverlaps returns true for the full-range compaction. -var levelDbFlushSentinelKey = []byte(levelDbTablePerm + ":ff:flush-sentinel") - -// Flush writes the current memtable to disk and rotates the journal. An +// Flush writes the current memtable to disk and starts an empty journal. An // unclean process kill (routine on iOS) with a non-empty journal forces a // journal replay on the next open, or worse a whole-DB recovery if the // journal tail is corrupt — both of which block startup. Flushing while -// entering the background leaves a near-empty journal so the next cold start +// entering the background leaves an empty journal so the next cold start // opens fast. No-op if the DB is not currently open; does not trigger a lazy // open. +// +// Concurrent calls serialize on goleveldb's own write lock rather than on +// anything of ours: OpenTransaction blocks until any transaction ahead of it +// commits or discards, and by the time it unblocks it rotates whatever +// memtable is current, so a call that lands after another one already +// covers any write made before it arrived. func (l *LevelDb) Flush() (err error) { + return l.flushMemtable() +} + +// openedDb returns the DB without triggering a lazy open, or nil if it isn't +// open. Callers must hold the read lock. +func (l *LevelDb) openedDb() *leveldb.DB { + return l.db.Load() +} + +func (l *LevelDb) flushMemtable() (err error) { defer convertNoSpaceError(&err) l.RLock() defer l.RUnlock() - if l.db == nil { + db := l.openedDb() + if db == nil { return nil } - // Write the sentinel so the memtable is non-empty; then compact the full - // key space (util.Range{} with nil Start/Limit) so isMemOverlaps always - // returns true regardless of what other keys are live. A narrow range - // keyed only on the sentinel could miss the memtable flush if a concurrent - // write rotated the memtable between the Put and CompactRange. - if err = l.db.Put(levelDbFlushSentinelKey, nil, nil); err != nil { + // Opening a transaction rotates a non-empty memtable and waits until it + // is written to a table, without compacting any tables. The + // transaction itself is not needed. + tr, err := db.OpenTransaction() + if err != nil { return err } - if err = l.db.CompactRange(util.Range{}); err != nil { - return err + tr.Discard() + if l.flushHook != nil { + l.flushHook() } - return l.db.Delete(levelDbFlushSentinelKey, nil) + return nil } func (l *LevelDb) Stats() (stats string) { if err := l.doWhileOpenAndNukeIfCorrupted(func() (err error) { - stats, err = l.db.GetProperty("leveldb.stats") + stats, err = l.db.Load().GetProperty("leveldb.stats") stats = fmt.Sprintf("%s\n%s", stats, l.cleaner.Status()) return err }); err != nil { @@ -277,7 +299,7 @@ func (l *LevelDb) Stats() (stats string) { func (l *LevelDb) CompactionStats() (memActive, tableActive bool, err error) { var dbStats leveldb.DBStats if err := l.doWhileOpenAndNukeIfCorrupted(func() (err error) { - return l.db.Stats(&dbStats) + return l.db.Load().Stats(&dbStats) }); err != nil { return false, false, err } @@ -299,10 +321,10 @@ func (l *LevelDb) Close() error { func (l *LevelDb) closeLocked() error { var err error - if l.db != nil { + if db := l.db.Load(); db != nil { l.G().Log.Debug("Closing LevelDB local cache: %s", l.GetFilename()) - err = l.db.Close() - l.db = nil + err = db.Close() + l.db.Store(nil) // In case we just nuked DB and reset the dbOpenerOnce, this makes sure it // doesn't open the DB again. @@ -376,13 +398,13 @@ func (l *LevelDb) nukeIfCorrupt(err error) bool { func (l *LevelDb) Put(id DbKey, aliases []DbKey, value []byte) error { return l.doWhileOpenAndNukeIfCorrupted(func() error { - return levelDbPut(l.db, l.cleaner, id, aliases, value) + return levelDbPut(l.db.Load(), l.cleaner, id, aliases, value) }) } func (l *LevelDb) Get(id DbKey) (val []byte, found bool, err error) { err = l.doWhileOpenAndNukeIfCorrupted(func() error { - val, found, err = levelDbGet(l.db, l.cleaner, id) + val, found, err = levelDbGet(l.db.Load(), l.cleaner, id) return err }) return val, found, err @@ -390,7 +412,7 @@ func (l *LevelDb) Get(id DbKey) (val []byte, found bool, err error) { func (l *LevelDb) Lookup(id DbKey) (val []byte, found bool, err error) { err = l.doWhileOpenAndNukeIfCorrupted(func() error { - val, found, err = levelDbLookup(l.db, l.cleaner, id) + val, found, err = levelDbLookup(l.db.Load(), l.cleaner, id) return err }) return val, found, err @@ -398,7 +420,7 @@ func (l *LevelDb) Lookup(id DbKey) (val []byte, found bool, err error) { func (l *LevelDb) Delete(id DbKey) error { return l.doWhileOpenAndNukeIfCorrupted(func() error { - return levelDbDelete(l.db, l.cleaner, id) + return levelDbDelete(l.db.Load(), l.cleaner, id) }) } @@ -407,7 +429,13 @@ func (l *LevelDb) OpenTransaction() (LocalDbTransaction, error) { ltr LevelDbTransaction err error ) - if ltr.tr, err = l.db.OpenTransaction(); err != nil { + l.RLock() + db := l.openedDb() + l.RUnlock() + if db == nil { + return LevelDbTransaction{}, LevelDBOpenClosedError{} + } + if ltr.tr, err = db.OpenTransaction(); err != nil { return LevelDbTransaction{}, err } ltr.cleaner = l.cleaner @@ -419,7 +447,7 @@ func (l *LevelDb) KeysWithPrefixes(prefixes ...[]byte) (DBKeySet, error) { err := l.doWhileOpenAndNukeIfCorrupted(func() error { opts := &opt.ReadOptions{DontFillCache: true} for _, prefix := range prefixes { - iter := l.db.NewIterator(util.BytesPrefix(prefix), opts) + iter := l.db.Load().NewIterator(util.BytesPrefix(prefix), opts) for iter.Next() { _, dbKey, err := DbKeyParse(string(iter.Key())) if err != nil { diff --git a/go/libkb/leveldb_cleaner.go b/go/libkb/leveldb_cleaner.go index 891329c23049..c0989313241f 100644 --- a/go/libkb/leveldb_cleaner.go +++ b/go/libkb/leveldb_cleaner.go @@ -60,52 +60,42 @@ type levelDbCleaner struct { MetaContextified sync.Mutex - running bool - lastKey []byte - lastRun time.Time - dbName string - config DbCleanerConfig - cache *lru.Cache - cacheMu sync.Mutex // protects the pointer to the cache - isMobile bool - db *leveldb.DB - stopCh chan struct{} - cancelCh chan struct{} + running bool + lastKey []byte + lastRun time.Time + dbName string + config DbCleanerConfig + cache *lru.Cache + cacheMu sync.Mutex // protects the pointer to the cache + db *leveldb.DB + stopCh chan struct{} isShutdown bool } func newLevelDbCleaner(mctx MetaContext, dbName string) *levelDbCleaner { config := DefaultDesktopDbCleanerConfig - isMobile := mctx.G().IsMobileAppType() - if isMobile { + if mctx.G().IsMobileAppType() { config = DefaultMobileDbCleanerConfig } - return newLevelDbCleanerWithConfig(mctx, dbName, config, isMobile) + return newLevelDbCleanerWithConfig(mctx, dbName, config) } -func newLevelDbCleanerWithConfig(mctx MetaContext, dbName string, config DbCleanerConfig, isMobile bool) *levelDbCleaner { +func newLevelDbCleanerWithConfig(mctx MetaContext, dbName string, config DbCleanerConfig) *levelDbCleaner { cache, err := lru.New(config.CacheCapacity) if err != nil { panic(err) } mctx = mctx.WithLogTag("DBCLN") - c := &levelDbCleaner{ + return &levelDbCleaner{ MetaContextified: NewMetaContextified(mctx), // Start the run shortly after starting but not immediately - lastRun: mctx.G().GetClock().Now().Add(-(config.CleanInterval - config.CleanInterval/10)), - dbName: dbName, - config: config, - cache: cache, - isMobile: isMobile, - stopCh: make(chan struct{}), - cancelCh: make(chan struct{}), + lastRun: mctx.G().GetClock().Now().Add(-(config.CleanInterval - config.CleanInterval/10)), + dbName: dbName, + config: config, + cache: cache, + stopCh: make(chan struct{}), } - if isMobile { - stopCh := c.stopCh - go c.monitorAppState(stopCh) - } - return c } func (c *levelDbCleaner) getCache() *lru.Cache { @@ -129,27 +119,18 @@ func (c *levelDbCleaner) Stop() { } } -func (c *levelDbCleaner) monitorAppState(stopCh chan struct{}) { - c.log("monitorAppState") - state := keybase1.MobileAppState_FOREGROUND - for { - select { - case <-c.G().MobileAppState.NextUpdate(state): - state = c.G().MobileAppState.State() - switch state { - case keybase1.MobileAppState_BACKGROUNDACTIVE: - default: - c.log("monitorAppState: attempting cancel, state: %v", state) - c.Lock() - if c.cancelCh != nil { - close(c.cancelCh) - c.cancelCh = make(chan struct{}) - } - c.Unlock() - } - case <-stopCh: - c.log("monitorAppState: stop") - return +// start attaches the cleaner to a newly opened db, undoing a previous +// Stop/Shutdown from closing it. +func (c *levelDbCleaner) start(db *leveldb.DB) { + c.Lock() + defer c.Unlock() + c.db = db + c.cacheMu.Lock() + defer c.cacheMu.Unlock() + if c.isShutdown { + if cache, err := lru.New(c.config.CacheCapacity); err == nil { + c.cache = cache + c.isShutdown = false } } } @@ -158,12 +139,6 @@ func (c *levelDbCleaner) log(format string, args ...any) { c.M().Debug(fmt.Sprintf("levelDbCleaner(%s): %s", c.dbName, format), args...) } -func (c *levelDbCleaner) setDb(db *leveldb.DB) { - c.Lock() - defer c.Unlock() - c.db = db -} - func (c *levelDbCleaner) cacheKey(key []byte) string { return string(key) } @@ -215,7 +190,16 @@ func (c *levelDbCleaner) clean(force bool) (err error) { c.running = true key := c.lastKey stopCh := c.stopCh - cancelCh := c.cancelCh + // Sample the app state in the same critical section as running=true, so + // a transition that lands between here and the batch loop (during + // getDbSize, logging, etc.) is not missed: any caller who observes + // running via c.Lock() only does so after this sample is already taken. + // A clean gives way to the foreground: it keeps running only while the + // app state stays BACKGROUNDACTIVE (or never changes at all, as on + // desktop). NextUpdate collapses intermediate transitions, so a wake + // re-reads the current state rather than assuming what it changed to. + state := c.G().MobileAppState.State() + appCh := c.G().MobileAppState.NextUpdate(state) c.Unlock() defer c.M().Trace(fmt.Sprintf("levelDbCleaner(%s) clean, config: %v", c.dbName, c.config), &err)() @@ -242,12 +226,16 @@ func (c *levelDbCleaner) clean(force bool) (err error) { var totalNumPurged, numPurged int for i := range 100 { select { - case <-cancelCh: - c.log("aborting clean, %d runs, canceled", i) - return nil case <-stopCh: c.log("aborting clean %d runs, stopped", i) return nil + case <-appCh: + state = c.G().MobileAppState.State() + if state != keybase1.MobileAppState_BACKGROUNDACTIVE { + c.log("aborting clean, %d runs, left BACKGROUNDACTIVE for %v", i, state) + return nil + } + appCh = c.G().MobileAppState.NextUpdate(state) default: } diff --git a/go/libkb/leveldb_cleaner_test.go b/go/libkb/leveldb_cleaner_test.go new file mode 100644 index 000000000000..1d6b38d3298e --- /dev/null +++ b/go/libkb/leveldb_cleaner_test.go @@ -0,0 +1,217 @@ +package libkb + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + keybase1 "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// newMobileCleanerDb makes a LevelDb whose cleaner behaves as on mobile. The +// db is not opened. +func newMobileCleanerDb(t *testing.T, tc *TestContext, config DbCleanerConfig) *LevelDb { + dir := t.TempDir() + db := NewLevelDb(tc.G, func() string { return filepath.Join(dir, "test.leveldb") }) + db.cleaner = newLevelDbCleanerWithConfig(NewMetaContextTODO(tc.G), "test", config) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func testCleanerConfig() DbCleanerConfig { + config := DefaultMobileDbCleanerConfig + config.CacheCapacity = 10 + return config +} + +var cleanerStates = []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, +} + +// waitCleanerRunning waits until a started clean has taken the running flag. +// clean() samples the app state in the same critical section as setting +// running, so a caller who observes running via this has already lost any +// race against that sample. +func waitCleanerRunning(t *testing.T, c *levelDbCleaner) { + t.Helper() + require.Eventually(t, func() bool { + c.Lock() + defer c.Unlock() + return c.running + }, 10*time.Second, time.Millisecond, "clean did not start running") +} + +// putKeys writes numKeys keys under db and returns the last one. +func putKeys(t *testing.T, db *LevelDb, numKeys int) DbKey { + t.Helper() + var last DbKey + for i := range numKeys { + last = DbKey{Key: fmt.Sprintf("k%05d", i), Typ: 0} + require.NoError(t, db.Put(last, nil, []byte{1})) + } + return last +} + +// A clean in progress stops before finishing when the app leaves +// BACKGROUNDACTIVE for any other state. +func TestCleanerStopsWhenLeavingBackgroundActive(t *testing.T) { + for _, next := range cleanerStates { + if next == keybase1.MobileAppState_BACKGROUNDACTIVE { + continue + } + t.Run(next.String(), func(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-stop", 0) + defer tc.Cleanup() + config := testCleanerConfig() + config.SleepInterval = 100 * time.Millisecond + db := newMobileCleanerDb(t, &tc, config) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + + const numKeys = 3500 + lastKey := putKeys(t, db, numKeys) + db.cleaner.clearCache() + + done := make(chan error, 1) + go func() { done <- db.cleaner.clean(true /* force */) }() + waitCleanerRunning(t, db.cleaner) + + tc.G.MobileAppState.Update(next) + require.NoError(t, <-done) + + _, found, err := db.Get(lastKey) + require.NoError(t, err) + require.True(t, found, "a clean canceled by leaving BACKGROUNDACTIVE should not reach the last key") + }) + } +} + +// A clean that starts outside BACKGROUNDACTIVE is also interrupted by a +// transition to a different non-BACKGROUNDACTIVE state: cancellation depends +// on the landing state, not on where the clean started. +func TestCleanerStopsOnTransitionBetweenNonBackgroundActiveStates(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-stop-fg", 0) + defer tc.Cleanup() + config := testCleanerConfig() + config.SleepInterval = 100 * time.Millisecond + db := newMobileCleanerDb(t, &tc, config) + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + + const numKeys = 3500 + lastKey := putKeys(t, db, numKeys) + db.cleaner.clearCache() + + done := make(chan error, 1) + go func() { done <- db.cleaner.clean(true /* force */) }() + waitCleanerRunning(t, db.cleaner) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + require.NoError(t, <-done) + + _, found, err := db.Get(lastKey) + require.NoError(t, err) + require.True(t, found, "a clean canceled by a transition between non-BACKGROUNDACTIVE states should not reach the last key") +} + +// A clean keeps running, with no early return, for as long as the app state +// stays BACKGROUNDACTIVE, including across an unrelated update that collapses +// to a no-op (NextUpdate only fires on a real change). +func TestCleanerContinuesWhileBackgroundActive(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-continue", 0) + defer tc.Cleanup() + config := testCleanerConfig() + config.SleepInterval = 100 * time.Millisecond + db := newMobileCleanerDb(t, &tc, config) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + + const numKeys = 3500 + lastKey := putKeys(t, db, numKeys) + db.cleaner.clearCache() + + done := make(chan error, 1) + go func() { done <- db.cleaner.clean(true /* force */) }() + waitCleanerRunning(t, db.cleaner) + + // A same-value update: no real transition, so it must not interrupt the + // clean. + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + require.NoError(t, <-done) + + _, found, err := db.Get(lastKey) + require.NoError(t, err) + require.False(t, found, "a clean that never left BACKGROUNDACTIVE should run to completion") +} + +// A clean that starts outside BACKGROUNDACTIVE and then transitions into it +// keeps running: the wake re-arms rather than treating the change itself as +// a cancellation. +func TestCleanerRearmsIntoBackgroundActive(t *testing.T) { + for _, start := range []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + } { + t.Run(start.String(), func(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-rearm", 0) + defer tc.Cleanup() + config := testCleanerConfig() + config.SleepInterval = 100 * time.Millisecond + db := newMobileCleanerDb(t, &tc, config) + tc.G.MobileAppState.Update(start) + + const numKeys = 3500 + lastKey := putKeys(t, db, numKeys) + db.cleaner.clearCache() + + done := make(chan error, 1) + go func() { done <- db.cleaner.clean(true /* force */) }() + waitCleanerRunning(t, db.cleaner) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + require.NoError(t, <-done) + + _, found, err := db.Get(lastKey) + require.NoError(t, err) + require.False(t, found, "a clean that transitions into BACKGROUNDACTIVE should run to completion") + }) + } +} + +// A cleaner still cleans after its db is reopened (Nuke, or Close + +// ForceOpen): start() must reset isShutdown so a reopened cleaner's cache +// isn't stuck discarding everything. +func TestCleanerCleansAfterReopen(t *testing.T) { + for _, name := range []string{"nuke", "close"} { + t.Run(name, func(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-reopen", 0) + defer tc.Cleanup() + db := newMobileCleanerDb(t, &tc, testCleanerConfig()) + require.NoError(t, db.ForceOpen()) + + switch name { + case "nuke": + _, err := db.Nuke() + require.NoError(t, err) + require.NoError(t, db.ForceOpen()) + case "close": + require.NoError(t, db.Close()) + // The first use after Close fails and rearms the lazy open. + require.Error(t, db.ForceOpen()) + require.NoError(t, db.ForceOpen()) + } + + key := DbKey{Key: "reopen-key", Typ: 0} + require.NoError(t, db.Put(key, nil, []byte{1})) + db.cleaner.clearCache() + require.NoError(t, db.cleaner.clean(true /* force */)) + + _, found, err := db.Get(key) + require.NoError(t, err) + require.False(t, found, "clean after %s left the key", name) + }) + } +} diff --git a/go/libkb/leveldb_test.go b/go/libkb/leveldb_test.go index e40565abfa6f..11697f93d90e 100644 --- a/go/libkb/leveldb_test.go +++ b/go/libkb/leveldb_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/syndtr/goleveldb/leveldb" ) @@ -69,6 +70,34 @@ func doSomeIO() error { return os.WriteFile(filepath.Join(dir, "some-io"), []byte("O_O"), 0o600) } +func levelDbStats(t *testing.T, db *LevelDb) (stats leveldb.DBStats) { + require.NoError(t, db.doWhileOpenAndNukeIfCorrupted(func() error { + return db.db.Load().Stats(&stats) + })) + return stats +} + +func levelDbTableCount(t *testing.T, db *LevelDb) (count int) { + for _, n := range levelDbStats(t, db).LevelTablesCounts { + count += n + } + return count +} + +// levelDbJournalSize returns the size of the journal (*.log) files, which +// hold writes not yet flushed to a table. +func levelDbJournalSize(t *testing.T, db *LevelDb) (size int64) { + journals, err := filepath.Glob(filepath.Join(db.GetFilename(), "*.log")) + require.NoError(t, err) + require.NotEmpty(t, journals) + for _, j := range journals { + fi, err := os.Stat(j) + require.NoError(t, err) + size += fi.Size() + } + return size +} + func testLevelDbPut(db *LevelDb) (key DbKey, err error) { key = DbKey{Key: "test-key", Typ: 0} v := []byte{1, 2, 3, 4} @@ -123,23 +152,181 @@ func TestLevelDb(t *testing.T) { key, err := testLevelDbPut(db) require.NoError(t, err) + require.Zero(t, levelDbTableCount(t, db), "the put should still be in the memtable") require.NoError(t, db.Flush()) + require.NotZero(t, levelDbTableCount(t, db), "flush should write the memtable to a table") require.NoError(t, db.Flush()) - // Data survives the flush and the sentinel is cleaned up. + // Data survives the flush. val, found, err := db.Get(key) require.NoError(t, err) require.True(t, found) require.Equal(t, []byte{1, 2, 3, 4}, val) - _, err = db.db.Get(levelDbFlushSentinelKey, nil) - require.Equal(t, leveldb.ErrNotFound, err) // Writes still work after a flush. _, err = testLevelDbPut(db) require.NoError(t, err) }, }, + { + name: "flush-memtable-only", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-flush-memtable-only", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + require.NoError(t, db.ForceOpen()) + + putAcrossPrefixes := func(round int) { + for _, prefix := range []string{"aa", "kv", "lo", "pm", "zz"} { + for i := 0; i < 20; i++ { + key := []byte(fmt.Sprintf("%s:%d:%d", prefix, round, i)) + require.NoError(t, db.db.Load().Put(key, bytes.Repeat([]byte{byte(i)}, 100), nil)) + } + } + } + // Existing tables spanning the whole key space, so a table + // compaction of the flushed memtable would have inputs. + for round := 0; round < 2; round++ { + putAcrossPrefixes(round) + tr, err := db.db.Load().OpenTransaction() + require.NoError(t, err) + tr.Discard() + } + putAcrossPrefixes(2) + require.NotZero(t, levelDbJournalSize(t, db)) + before := levelDbStats(t, db).LevelTablesCounts + beforeTotal := levelDbTableCount(t, db) + + require.NoError(t, db.Flush()) + + after := levelDbStats(t, db).LevelTablesCounts + for level, n := range before { + require.GreaterOrEqual(t, after[level], n, "no table should be compacted away (level %d)", level) + } + require.Equal(t, beforeTotal+1, levelDbTableCount(t, db), "flush should add exactly one table") + require.Zero(t, levelDbJournalSize(t, db), "the flushed memtable's journal should be gone") + val, err := db.db.Load().Get([]byte("zz:2:19"), nil) + require.NoError(t, err) + require.Equal(t, bytes.Repeat([]byte{19}, 100), val) + }, + }, + { + // A write and a Flush call that a flush's own hook makes reentrantly + // must still be flushed before the outer call returns: the hook runs + // after the transaction is discarded, so goleveldb's write lock is + // already free and the nested call is a plain second flush. + name: "flush-reentrant", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-flush-reentrant", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + _, err = testLevelDbPut(db) + require.NoError(t, err) + + rotations := 0 + db.flushHook = func() { + rotations++ + if rotations == 1 { + require.NoError(t, db.db.Load().Put([]byte("kv:late"), []byte{1}, nil)) + require.NoError(t, db.Flush()) + } + } + require.NoError(t, db.Flush()) + require.Equal(t, 2, rotations) + require.Zero(t, levelDbJournalSize(t, db)) + }, + }, + { + // 8 goroutines call Flush with writes interleaved: every call + // returns nil, and every writer's last write is durable and + // readable once all goroutines finish. + name: "flush-concurrent", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-flush-concurrent", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + require.NoError(t, db.ForceOpen()) + + const writers, iterations = 8, 25 + var wg sync.WaitGroup + for w := 0; w < writers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < iterations; i++ { + key := DbKey{Key: fmt.Sprintf("%d-%d", w, i), Typ: 0} + assert.NoError(t, db.Put(key, nil, []byte{byte(i)})) + assert.NoError(t, db.Flush()) + } + }(w) + } + wg.Wait() + + require.Zero(t, levelDbJournalSize(t, db), "the last writes must be flushed") + for w := 0; w < writers; w++ { + _, found, err := db.Get(DbKey{Key: fmt.Sprintf("%d-%d", w, iterations-1), Typ: 0}) + require.NoError(t, err) + require.True(t, found) + } + }, + }, + { + name: "open-transaction-after-close", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-transaction-closed", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + + require.NoError(t, db.ForceOpen()) + require.NoError(t, db.Close()) + _, err = db.OpenTransaction() + require.ErrorAs(t, err, &LevelDBOpenClosedError{}) + }, + }, + { + name: "concurrent-open", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-concurrent-open", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + + // Under -race, this catches the lazy open assigning db.db while + // Flush reads it. + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(2) + go func() { + defer wg.Done() + _, _, err := db.Get(DbKey{Key: "test-key", Typ: 0}) + assert.NoError(t, err) + }() + go func() { + defer wg.Done() + assert.NoError(t, db.Flush()) + }() + } + wg.Wait() + + // A lazy open racing a Nuke reopens rather than reporting closed. + for i := 0; i < 8; i++ { + wg.Add(2) + go func() { + defer wg.Done() + key := DbKey{Key: "test-key", Typ: 0} + assert.NoError(t, db.Put(key, nil, []byte{1})) + _, _, err := db.Get(key) + assert.NoError(t, err) + }() + go func() { + defer wg.Done() + _, err := db.Nuke() + assert.NoError(t, err) + }() + } + wg.Wait() + }, + }, { name: "cleaner", testBody: func(t *testing.T) { tc := SetupTest(t, "LevelDb-cleaner", 0) 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") + } + }) + } +} diff --git a/go/libkb/logout.go b/go/libkb/logout.go index 5c28274431a4..94b48da0c7eb 100644 --- a/go/libkb/logout.go +++ b/go/libkb/logout.go @@ -30,7 +30,7 @@ func (mctx MetaContext) LogoutUsernameWithOptions(username NormalizedUsername, o defer mctx.Trace(fmt.Sprintf("MetaContext#LogoutWithOptions(%#v)", options), &err)() g := mctx.G() - defer g.switchUserMu.Acquire(mctx, "Logout")() + defer g.lockSwitchUser(mctx, false, "Logout")() mctx.Debug("MetaContext#logoutWithSecretKill: after switchUserMu acquisition (username: %s, options: %#v)", username, options) diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index fcf789e563b2..ebc114645405 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -5,7 +5,6 @@ package libkb import ( "context" - "fmt" "sync" "time" @@ -318,9 +317,11 @@ type NotifyListenerID string type NotifyRouter struct { sync.Mutex Contextified - cm *ConnectionManager - state map[ConnectionID]keybase1.NotificationChannels - listeners map[NotifyListenerID]NotifyListener + cm *ConnectionManager + state map[ConnectionID]keybase1.NotificationChannels + senders map[ConnectionID]*connSender + listeners map[NotifyListenerID]NotifyListener + readClientState func(context.Context) keybase1.ClientState } // NewNotifyRouter makes a new notification router; we should only @@ -330,10 +331,110 @@ func NewNotifyRouter(g *GlobalContext) *NotifyRouter { Contextified: NewContextified(g), cm: g.ConnectionManager, state: make(map[ConnectionID]keybase1.NotificationChannels), + senders: make(map[ConnectionID]*connSender), listeners: make(map[NotifyListenerID]NotifyListener), } } +// connSender sends one connection's client-state stream: loggedIn, loggedOut, +// HTTPSrvInfoUpdate, mobileAppStateChanged, clientState and +// pushTapRouteAvailable. Every other notification keeps its own goroutine. +// +// One goroutine per connection drains an unbounded FIFO, so queueing never +// blocks, and the rpc library writes one goroutine's Notify calls in the order +// they are made (each hands its frame to a single writer over an unbuffered +// channel). loggedIn is a call rather than a notification, and callInOrder +// gives it the same place in line without waiting for a reply. A connection +// therefore receives its jobs in the order they were queued. +// +// A clientState job carries no state. It reads the session, the http server +// address and the app state when it is dequeued, on this goroutine and outside +// the router's lock: MobileAppState calls the router with its own lock held, +// so the router must never read app state under its lock. +// +// Why a client can apply everything in arrival order, with no versions: for +// every field, the last message that carries it to a connection subscribed to +// that field's notification carries the latest value. +// - The app state and the http address each have one writer, which queues +// its notification after the write and before it writes the next value: +// the app state under lifecycle's Controller.mu and then MobileAppState's +// lock, the address on kbhttp's run goroutine. Say the last message is a +// notification. A later write would queue its own notification behind it, +// so there was none, and it carries the latest value. Say instead it is a +// clientState. A write after that clientState read the field would have +// queued a notification behind it, so there was none either. +// - The session: every change to it -- valid or not, which user, which +// device -- queues a clientState once switchUserMu is released after the +// write (GlobalContext.lockSwitchUser), and clientState jobs read the +// session when dequeued. So for every connection the last clientState +// carries the latest session, whatever order the loggedIn/loggedOut events +// arrive in -- those carry no session state a client may apply. The only +// writes that queue nothing are the promotions a provisioning, signup or +// oneshot flow makes before it completes, so that a client does not see a +// login early; the flow completes with SendLogin, which queues one after +// it. What this leaves: a flow that fails and leaves the promoted session +// in place without clearing it is not pushed until the next clientState, +// and a clientState dequeued partway through such a flow reads its +// promoted session. SendLogin and HandleLogout queue one too, and so does +// the startup login attempt settling, which turns a null session into a +// real one. +// - Registration: SetChannels sets the filter and queues the first +// clientState under the router's lock, which announce takes to pick its +// recipients. A change announced after that is queued behind the +// clientState, which may already hold it, and repeating it is harmless +// because applying a value replaces the old one. A change announced before +// that was written before the clientState was even queued, so the +// clientState holds it or something newer. +type connSender struct { + xp rpc.Transporter + mu sync.Mutex + jobs []func(rpc.Transporter) + wake chan struct{} + stop chan struct{} +} + +func newConnSender(xp rpc.Transporter) *connSender { + s := &connSender{ + xp: xp, + wake: make(chan struct{}, 1), + stop: make(chan struct{}), + } + go s.run() + return s +} + +func (s *connSender) enqueue(job func(rpc.Transporter)) { + s.mu.Lock() + s.jobs = append(s.jobs, job) + s.mu.Unlock() + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *connSender) run() { + for { + select { + case <-s.stop: + return + case <-s.wake: + } + s.mu.Lock() + jobs := s.jobs + s.jobs = nil + s.mu.Unlock() + for _, job := range jobs { + select { + case <-s.stop: + return + default: + } + job(s.xp) + } + } +} + func (n *NotifyRouter) AddListener(listener NotifyListener) NotifyListenerID { n.Lock() defer n.Unlock() @@ -348,12 +449,32 @@ func (n *NotifyRouter) RemoveListener(id NotifyListenerID) { delete(n.listeners, id) } -func (n *NotifyRouter) Shutdown() {} +// Shutdown stops every connection's sender; whatever is still queued is dropped. +func (n *NotifyRouter) Shutdown() { + n.Lock() + defer n.Unlock() + for id, s := range n.senders { + close(s.stop) + delete(n.senders, id) + } +} -func (n *NotifyRouter) setNotificationChannels(id ConnectionID, val keybase1.NotificationChannels) { +// SetClientStateReader sets how a clientState reads the state it sends. It is +// called on a connection's sender goroutine, with no router lock held. A +// clientState dequeued before there is a reader sends nothing, so every +// connection that wants one gets one queued here. +func (n *NotifyRouter) SetClientStateReader(read func(context.Context) keybase1.ClientState) { + if n == nil { + return + } n.Lock() defer n.Unlock() - n.state[id] = val + n.readClientState = read + for id, s := range n.senders { + if wantsClientState(n.state[id]) { + s.enqueue(n.sendClientState(context.Background())) + } + } } func (n *NotifyRouter) getNotificationChannels(id ConnectionID) keybase1.NotificationChannels { @@ -387,15 +508,116 @@ func (n *NotifyRouter) AddConnection(xp rpc.Transporter, ch chan error) Connecti if n == nil { return 0 } - id := n.cm.AddConnection(xp, ch) - n.setNotificationChannels(id, keybase1.NotificationChannels{}) + id := n.cm.AddConnection(xp) + n.Lock() + n.state[id] = keybase1.NotificationChannels{} + n.senders[id] = newConnSender(xp) + n.Unlock() + if ch != nil { + go func() { + <-ch + n.cm.removeConnection(id) + n.removeConnection(id) + }() + } return id } -// SetChannels sets which notification channels are interested for the connection -// with the given connection ID. +func (n *NotifyRouter) removeConnection(id ConnectionID) { + n.Lock() + defer n.Unlock() + delete(n.state, id) + if s := n.senders[id]; s != nil { + close(s.stop) + delete(n.senders, id) + } +} + +// SetChannels sets which notification channels are interested for the +// connection with the given connection ID. A connection that wants clientState +// gets one queued here, ahead of every change announced after this returns. func (n *NotifyRouter) SetChannels(i ConnectionID, nc keybase1.NotificationChannels) { - n.setNotificationChannels(i, nc) + if n == nil { + return + } + n.Lock() + defer n.Unlock() + s := n.senders[i] + if s == nil { + // the connection is gone; registering it now would leak its entry + return + } + n.state[i] = nc + if wantsClientState(nc) { + s.enqueue(n.sendClientState(context.Background())) + } +} + +// clientState rides NotifyApp, so it goes to the connections that registered it. +func wantsClientState(ch keybase1.NotificationChannels) bool { return ch.App } + +func (n *NotifyRouter) sendClientState(ctx context.Context) func(rpc.Transporter) { + return func(xp rpc.Transporter) { + n.Lock() + read := n.readClientState + n.Unlock() + if read == nil { + return + } + _ = (keybase1.NotifyAppClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).ClientState(ctx, read(ctx)) + } +} + +// announce queues a notification to every connection whose channel filter wants +// it, on that connection's sender. See connSender for why the order it is +// queued in is the order it arrives in. +func (n *NotifyRouter) announce(ctx context.Context, name string, + wants func(keybase1.NotificationChannels) bool, + send func(ctx context.Context, xp rpc.Transporter), +) { + ctx = CopyTagsToBackground(ctx) + var queued []ConnectionID + n.Lock() + for id, s := range n.senders { + if wants(n.state[id]) { + s.enqueue(func(xp rpc.Transporter) { send(ctx, xp) }) + queued = append(queued, id) + } + } + n.Unlock() + n.G().Log.CDebugf(ctx, "| NotifyRouter#%s: queued for connections %v", name, queued) +} + +// callInOrder makes a call from a sender job without holding the connection's +// queue for the reply, which a client may take its time over. The job returns +// once the call's frame is next in line for the connection's single writer -- +// the send notifier fires there, just before the write -- so everything queued +// after it is still written after it. +func (n *NotifyRouter) callInOrder(xp rpc.Transporter, call func(*rpc.Client) error) { + released := make(chan struct{}) + var once sync.Once + cli := rpc.NewClientWithSendNotifier(xp, NewContextifiedErrorUnwrapper(n.G()), nil, + func(rpc.SeqNumber) { once.Do(func() { close(released) }) }) + done := make(chan struct{}) + go func() { + defer close(done) + _ = call(cli) + }() + select { + case <-released: + case <-done: + } +} + +// AnnounceClientState queues a clientState to every connection that wants one. +func (n *NotifyRouter) AnnounceClientState(ctx context.Context) { + if n == nil { + return + } + n.announce(ctx, "AnnounceClientState", wantsClientState, + func(ctx context.Context, xp rpc.Transporter) { n.sendClientState(ctx)(xp) }) } // HandleLogout is called whenever the current user logged out. It will broadcast @@ -405,28 +627,14 @@ func (n *NotifyRouter) HandleLogout(ctx context.Context) { return } defer n.G().CTrace(ctx, "NotifyRouter#HandleLogout", nil)() - ctx = CopyTagsToBackground(ctx) - // For all connections we currently have open... - n.cm.ApplyAllDetails(func(id ConnectionID, xp rpc.Transporter, d *keybase1.ClientDetails) bool { - // If the connection wants the `Session` notification type - registered := false - if n.getNotificationChannels(id).Session { - registered = true - // In the background do... - go func() { - // A send of a `LoggedOut` RPC - _ = (keybase1.NotifySessionClient{ - Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).LoggedOut(ctx) - }() - } - desc := "" - if d != nil { - desc = fmt.Sprintf("%+v", *d) - } - n.G().Log.CDebugf(ctx, "| NotifyRouter#HandleLogout: client %s (sent=%v)", desc, registered) - return true - }) + n.announce(ctx, "HandleLogout", + func(ch keybase1.NotificationChannels) bool { return ch.Session }, + func(ctx context.Context, xp rpc.Transporter) { + _ = (keybase1.NotifySessionClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).LoggedOut(ctx) + }) + n.AnnounceClientState(ctx) n.runListeners(func(listener NotifyListener) { listener.Logout() @@ -459,24 +667,17 @@ func (n *NotifyRouter) SendLogin(ctx context.Context, u string, signedUp bool) { return } n.G().Log.CDebugf(ctx, "+ Sending login notification, as user %q, signedUp %t", u, signedUp) - // For all connections we currently have open... - ctx = CopyTagsToBackground(ctx) - n.cm.ApplyAll(func(id ConnectionID, xp rpc.Transporter) bool { - // If the connection wants the `Session` notification type - if n.getNotificationChannels(id).Session { - // In the background do... - go func() { - // A send of a `LoggedIn` RPC - _ = (keybase1.NotifySessionClient{ - Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).LoggedIn(ctx, keybase1.LoggedInArg{ + n.announce(ctx, "SendLogin", + func(ch keybase1.NotificationChannels) bool { return ch.Session }, + func(ctx context.Context, xp rpc.Transporter) { + n.callInOrder(xp, func(cli *rpc.Client) error { + return (keybase1.NotifySessionClient{Cli: cli}).LoggedIn(ctx, keybase1.LoggedInArg{ Username: u, SignedUp: signedUp, }) - }() - } - return true - }) + }) + }) + n.AnnounceClientState(ctx) n.runListeners(func(listener NotifyListener) { listener.Login(u) @@ -2823,21 +3024,59 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase if n == nil { return } - n.cm.ApplyAll(func(id ConnectionID, xp rpc.Transporter) bool { - if n.getNotificationChannels(id).Service { - go func() { - _ = (keybase1.NotifyServiceClient{ - Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).HTTPSrvInfoUpdate(ctx, info) - }() - } - return true - }) + n.announce(ctx, "HandleHTTPSrvInfoUpdate", + func(ch keybase1.NotificationChannels) bool { return ch.Service }, + func(ctx context.Context, xp rpc.Transporter) { + _ = (keybase1.NotifyServiceClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).HTTPSrvInfoUpdate(ctx, info) + }) n.runListeners(func(listener NotifyListener) { listener.HTTPSrvInfoUpdate(info) }) } +// 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 + } + n.announce(ctx, "HandleMobileAppState", + func(ch keybase1.NotificationChannels) bool { return ch.App }, + func(ctx context.Context, xp rpc.Transporter) { + _ = (keybase1.NotifyAppClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).MobileAppStateChanged(ctx, state) + }) +} + +// HandlePushTapRouteAvailable nudges clients that a notification tap resolved +// to a route. It carries nothing: the route rides peekPushTapRoute's reply, so +// the reader is the same one whether the tap happened before a client existed +// or while it was connected, and the route is retired by an ack from whoever +// acted on it rather than by having been read. +func (n *NotifyRouter) HandlePushTapRouteAvailable(ctx context.Context) { + if n == nil { + return + } + n.announce(ctx, "HandlePushTapRouteAvailable", + func(ch keybase1.NotificationChannels) bool { return ch.App }, + func(ctx context.Context, xp rpc.Transporter) { + _ = (keybase1.NotifyAppClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).PushTapRouteAvailable(ctx) + }) +} + func (n *NotifyRouter) HandleHandleKeybaseLink(ctx context.Context, link string, deferred bool) { if n == nil { return diff --git a/go/libkb/notify_router_test.go b/go/libkb/notify_router_test.go new file mode 100644 index 000000000000..d53a83e1ec85 --- /dev/null +++ b/go/libkb/notify_router_test.go @@ -0,0 +1,334 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package libkb + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readSessionOnly stands in for the service's reader: the session straight off +// the active device, read when the clientState is sent. +func readSessionOnly(g *GlobalContext) func(context.Context) keybase1.ClientState { + return func(context.Context) keybase1.ClientState { + session := keybase1.ClientSession{LoggedIn: g.ActiveDevice.Valid(), Uid: g.ActiveDevice.UID()} + return keybase1.ClientState{Session: &session} + } +} + +func clientStates(t *testing.T, rec *NotifyRecorder) []keybase1.ClientState { + t.Helper() + var ret []keybase1.ClientState + for _, m := range rec.Messages() { + if m.Method != "keybase.1.NotifyApp.clientState" { + continue + } + var arg keybase1.ClientStateArg + require.NoError(t, m.Decode(&arg)) + ret = append(ret, arg.State) + } + return ret +} + +// testLoginWrite is the write a provisioning flow makes partway through +// (kex2_provisionee, signup's device_wrap): it leaves a valid session that no +// login has announced yet. +func testLoginWrite(m MetaContext, uid keybase1.UID, name string) error { + sig, err := GenerateNaclSigningKeyPair() + if err != nil { + return err + } + enc, err := GenerateNaclDHKeyPair() + if err != nil { + return err + } + deviceID, err := NewDeviceID() + if err != nil { + return err + } + uv := keybase1.UserVersion{Uid: uid, EldestSeqno: 1} + return m.SwitchUserNewConfigActiveDevice(uv, NewNormalizedUsername(name), nil, deviceID, + sig, enc, "testdevice", KeychainModeNone) +} + +func testUID(i int) keybase1.UID { + return keybase1.UID(fmt.Sprintf("%030x19", i+1)) +} + +// A write that leaves a valid session is a login still in progress, and the +// client must not see it logged in until that login completes and says so. +func TestProvisionalValidWriteQueuesNoClientState(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + g.NotifyRouter.SetClientStateReader(readSessionOnly(g)) + m := NewMetaContextForTest(tc) + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true, Session: true}) + defer rec.Close() + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "the one queued on subscribing") + + require.NoError(t, testLoginWrite(m, testUID(0), "testuser")) + require.True(t, g.ActiveDevice.Valid()) + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "nothing for a login that has not completed") + + g.NotifyRouter.SendLogin(context.Background(), "testuser", false) + rec.Flush() + states := clientStates(t, rec) + require.Len(t, states, 2, "the completed login queues one") + require.True(t, states[1].Session.LoggedIn) +} + +// A write that makes the session valid outside any login flow -- a Device +// prereq bootstrapping the active device from the secret store, say -- has no +// SendLogin behind it, so the write itself has to reach clients. +func TestBootstrapStyleWriteQueuesClientState(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + g.NotifyRouter.SetClientStateReader(readSessionOnly(g)) + m := NewMetaContextForTest(tc) + + uid := testUID(0) + deviceID, err := NewDeviceID() + require.NoError(t, err) + require.NoError(t, m.SwitchUserNewConfig(uid, NewNormalizedUsername("testuser"), nil, deviceID)) + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true, Session: true}) + defer rec.Close() + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "the one queued on subscribing") + + sig, err := GenerateNaclSigningKeyPair() + require.NoError(t, err) + enc, err := GenerateNaclDHKeyPair() + require.NoError(t, err) + require.NoError(t, m.SetActiveDevice(keybase1.UserVersion{Uid: uid, EldestSeqno: 1}, deviceID, + sig, enc, "testdevice", KeychainModeNone)) + require.True(t, g.ActiveDevice.Valid()) + rec.Flush() + states := clientStates(t, rec) + require.Len(t, states, 2, "the write that made the session valid queued one") + require.True(t, states[1].Session.LoggedIn) +} + +// A release that changes nothing about the session has nothing to tell. +func TestUnchangedSessionQueuesNoClientState(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + g.NotifyRouter.SetClientStateReader(readSessionOnly(g)) + m := NewMetaContextForTest(tc) + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true, Session: true}) + defer rec.Close() + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "the one queued on subscribing") + + require.False(t, g.ActiveDevice.Valid()) + require.NoError(t, m.SwitchUserLoggedOut()) + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "logged out before and after") +} + +// A clear needs no announce to reach clients: a flow that fails and clears +// what it set, without a logout, still leaves every client logged out. +func TestSessionClearQueuesClientState(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + g.NotifyRouter.SetClientStateReader(readSessionOnly(g)) + m := NewMetaContextForTest(tc) + + require.NoError(t, testLoginWrite(m, testUID(0), "testuser")) + g.NotifyRouter.SendLogin(context.Background(), "testuser", false) + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true, Session: true}) + defer rec.Close() + rec.Flush() + states := clientStates(t, rec) + require.Len(t, states, 1) + require.True(t, states[0].Session.LoggedIn) + + require.NoError(t, m.SwitchUserLoggedOut()) + rec.Flush() + states = clientStates(t, rec) + require.Len(t, states, 2, "the clear queued one without any announce") + require.False(t, states[1].Session.LoggedIn) +} + +// Logins and logouts are not serialized against each other -- a login writes +// its device under switchUserMu and announces after releasing it -- so their +// loggedIn/loggedOut events can arrive in any order. What must hold anyway is +// that the last clientState every connection gets carries the session as it +// finally is. +func TestLastClientStateCarriesFinalSession(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + g.NotifyRouter.SetClientStateReader(readSessionOnly(g)) + m := NewMetaContextForTest(tc) + + var recs []*NotifyRecorder + for range 3 { + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true, Session: true}) + defer rec.Close() + recs = append(recs, rec) + } + + ctx := context.Background() + var wg sync.WaitGroup + for i := range 6 { + wg.Add(1) + go func() { + defer wg.Done() + for j := range 10 { + switch (i + j) % 3 { + case 0: + name := fmt.Sprintf("testuser%d", i) + if assert.NoError(t, testLoginWrite(m, testUID(i*10+j), name)) { + g.NotifyRouter.SendLogin(ctx, name, false) + } + case 1: + assert.NoError(t, m.LogoutKeepSecrets()) + default: + // a flow that fails and clears what it set, announcing nothing + assert.NoError(t, m.SwitchUserLoggedOut()) + } + } + }() + } + wg.Wait() + + want := keybase1.ClientSession{LoggedIn: g.ActiveDevice.Valid(), Uid: g.ActiveDevice.UID()} + for _, rec := range recs { + rec.Flush() + states := clientStates(t, rec) + require.NotEmpty(t, states) + require.Equal(t, want, *states[len(states)-1].Session, "connection %d", rec.ID) + } +} + +// A clientState reads the state when it is sent, not when it is queued. That is +// what lets the last one carry the latest session although nothing orders a +// login's write against a logout's announce: whichever clientState is sent last +// reads after every write that queued one. +func TestClientStateReadsWhenSent(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + var mu sync.Mutex + loggedIn := false + var reads atomic.Int32 + entered := make(chan struct{}) + gate := make(chan struct{}) + g.NotifyRouter.SetClientStateReader(func(context.Context) keybase1.ClientState { + if reads.Add(1) == 1 { + close(entered) + <-gate + } + mu.Lock() + defer mu.Unlock() + session := keybase1.ClientSession{LoggedIn: loggedIn} + return keybase1.ClientState{Session: &session} + }) + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true}) + defer rec.Close() + // the sender is now busy with the first clientState, so the next one waits in the queue + <-entered + g.NotifyRouter.AnnounceClientState(context.Background()) + mu.Lock() + loggedIn = true + mu.Unlock() + close(gate) + rec.Flush() + + states := clientStates(t, rec) + require.Len(t, states, 2) + require.True(t, states[1].Session.LoggedIn, "queued before the change, read after it") +} + +// A late SetChannels for a connection that has already closed must not bring +// its entry back: nothing would ever remove it again. +func TestSetChannelsAfterCloseRegistersNothing(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + n := g.NotifyRouter + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true}) + rec.Close() + require.Eventually(t, func() bool { + n.Lock() + defer n.Unlock() + return n.senders[rec.ID] == nil + }, 5*time.Second, time.Millisecond) + + n.SetChannels(rec.ID, keybase1.NotificationChannels{App: true}) + n.Lock() + _, registered := n.state[rec.ID] + n.Unlock() + require.False(t, registered) +} + +// A oneshot device is a login still in progress, like a provisioning write: +// clients must not see it logged in until the login completes and says so. +func TestOneshotDeviceQueuesNoClientState(t *testing.T) { + tc := SetupTest(t, "NotifyRouter", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + g.NotifyRouter.SetClientStateReader(readSessionOnly(g)) + m := NewMetaContextForTest(tc) + + rec := NewNotifyRecorder(g, keybase1.NotificationChannels{App: true, Session: true}) + defer rec.Close() + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "the one queued on subscribing") + + sig, err := GenerateNaclSigningKeyPair() + require.NoError(t, err) + enc, err := GenerateNaclDHKeyPair() + require.NoError(t, err) + deviceID, err := NewDeviceID() + require.NoError(t, err) + uv := keybase1.UserVersion{Uid: testUID(0), EldestSeqno: 1} + require.NoError(t, m.SwitchUserToActiveOneshotDevice(uv, NewNormalizedUsername("testuser"), + NewDeviceWithKeys(sig, enc, deviceID, "testdevice", KeychainModeNone))) + require.True(t, g.ActiveDevice.Valid()) + rec.Flush() + require.Len(t, clientStates(t, rec), 1, "nothing for a login that has not completed") + + g.NotifyRouter.SendLogin(context.Background(), "testuser", false) + rec.Flush() + states := clientStates(t, rec) + require.Len(t, states, 2, "the completed login queues one") + require.True(t, states[1].Session.LoggedIn) +} + +// A standalone client runs the service without ever setting up a router. +func TestNilRouterSettersAreNoOps(t *testing.T) { + var n *NotifyRouter + n.SetClientStateReader(func(context.Context) keybase1.ClientState { return keybase1.ClientState{} }) + n.SetChannels(ConnectionID(1), keybase1.NotificationChannels{App: true}) +} diff --git a/go/libkb/pushtap.go b/go/libkb/pushtap.go new file mode 100644 index 000000000000..e44993fe9d70 --- /dev/null +++ b/go/libkb/pushtap.go @@ -0,0 +1,179 @@ +package libkb + +import ( + "context" + "encoding/json" + "strings" + "sync" + + "github.com/keybase/client/go/protocol/keybase1" +) + +// PendingPushTap holds the route a tapped notification resolved to until a +// client says it has acted on it. +// +// It is the whole of the exactly-once guarantee for a tap. A tap can arrive +// when no client exists -- on iOS a tap that launches the process, on Android a +// tap that starts PushTapActivity before the RN host -- so it has to wait +// somewhere that outlives the client, which is here. +// +// Reading does not clear, because a reply lost on the way out would take the +// tap with it and nothing would be left to say a tap had ever happened. The +// client is the only party that knows it acted, so the client says so: Peek +// leaves the route armed and Ack retires it. Each route carries an id, so an +// ack that crosses a newer tap retires nothing. +type PendingPushTap struct { + Contextified + sync.Mutex + route *keybase1.PushTapRoute + lastID int +} + +func NewPendingPushTap(g *GlobalContext) *PendingPushTap { + return &PendingPushTap{Contextified: NewContextified(g)} +} + +// Set stores the route a tap resolved to, gives it a fresh id, and nudges +// connected clients. A tap not yet acked is replaced: the newest tap is the one +// the user just made, and queueing them would navigate through a backlog. +// +// Ids count the taps of this process and start at 1, so 0 is never a route a +// client has seen and is safe as a client-side sentinel. +func (p *PendingPushTap) Set(ctx context.Context, route keybase1.PushTapRoute) { + p.Lock() + p.lastID++ + route.Id = p.lastID + p.route = &route + p.Unlock() + p.G().NotifyRouter.HandlePushTapRouteAvailable(ctx) +} + +// Peek returns the waiting route without retiring it, or nil when none is +// waiting. It stays armed for the next reader until it is acked. The result is +// a copy, so the holder's own route is never reachable through a reader. +func (p *PendingPushTap) Peek() *keybase1.PushTapRoute { + p.Lock() + defer p.Unlock() + if p.route == nil { + return nil + } + route := *p.route + return &route +} + +// Ack retires the waiting route if it is still the one with this id, and +// reports whether it did. A stale id means a newer tap arrived while the ack +// was in flight, and that one must survive to be acted on. +func (p *PendingPushTap) Ack(id int) bool { + p.Lock() + defer p.Unlock() + if p.route == nil || p.route.Id != id { + return false + } + p.route = nil + return true +} + +// pushTapNoRouteTypes are the push types a tap never opens anything for: they +// are acted on natively and here, and have no screen of their own. +var pushTapNoRouteTypes = map[string]bool{ + "autoreset": true, + "chat.extension": true, + "chat.failedpending": true, + "chat.newmessageSilent_2": true, + "chat.readmessage": true, +} + +// pushTapContactPrefix is all that is read of a contact-joined message. The +// rest names a person, and only the prefix decides the destination. +const pushTapContactPrefix = "Your contact" + +// ResolvePushTap turns the payload of a tapped notification into the route it +// opens. The second result is false when the tap only opens the app. +// +// payloadJSON is the push as the OS delivered it -- APNs userInfo on iOS, the +// FCM data Bundle on Android -- so every value is whatever the sender put +// there: fields may be missing, and a number is as likely as a string. +func ResolvePushTap(payloadJSON string) (keybase1.PushTapRoute, bool) { + var none keybase1.PushTapRoute + if !json.Valid([]byte(payloadJSON)) { + return none, false + } + dec := json.NewDecoder(strings.NewReader(payloadJSON)) + // Numbers keep their literal text, so a numeric convID reads back as the + // digits that were sent rather than a float rendering of them. + dec.UseNumber() + var parsed any + if err := dec.Decode(&parsed); err != nil { + return none, false + } + fields, isObject := parsed.(map[string]any) + if !isObject { + return none, false + } + get := func(key string) string { + switch value := fields[key].(type) { + case string: + return value + case json.Number: + return value.String() + default: + return "" + } + } + forAccount := func(url, uid string) (keybase1.PushTapRoute, bool) { + return keybase1.PushTapRoute{Url: url, TargetUID: uid}, true + } + + typ := get("type") + switch { + case typ == "chat.newmessage": + if convID := get("convID"); convID != "" { + return forAccount("keybase://convid/"+encodeURIComponent(convID), get("uid")) + } + case typ == "follow": + if username := get("username"); username != "" { + uid := get("uid") + if uid == "" { + uid = get("targetUID") + } + return forAccount("keybase://profile/show/"+encodeURIComponent(username), uid) + } + case typ == "device.new", typ == "device.revoked": + if uid := get("uid"); uid != "" { + return forAccount("keybase://devices", uid) + } + case pushTapNoRouteTypes[typ]: + default: + if strings.HasPrefix(get("message"), pushTapContactPrefix) { + // No account: a contact-joined push is not account-scoped, so a tap + // on it must not switch accounts. + return keybase1.PushTapRoute{Url: "keybase://tabs.peopleTab"}, true + } + } + return none, false +} + +const pushTapUnreservedMarks = "-_.!~*'()" + +// encodeURIComponent escapes a path segment the way JavaScript's function of +// that name does. Go's url escapers each differ from it somewhere -- a space, +// or one of the marks below -- and the result here is compared against URLs +// clients build with the JavaScript one. +func encodeURIComponent(s string) string { + var out strings.Builder + const hex = "0123456789ABCDEF" + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', + strings.IndexByte(pushTapUnreservedMarks, c) >= 0: + out.WriteByte(c) + default: + out.WriteByte('%') + out.WriteByte(hex[c>>4]) + out.WriteByte(hex[c&0xf]) + } + } + return out.String() +} diff --git a/go/libkb/pushtap_test.go b/go/libkb/pushtap_test.go new file mode 100644 index 000000000000..857a005c926e --- /dev/null +++ b/go/libkb/pushtap_test.go @@ -0,0 +1,158 @@ +package libkb + +import ( + "context" + "testing" + + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// The cases are the table the client used to carry (deep-link-emitter.test.ts), +// kept so the destination a tap opens did not change when the mapping moved +// here. +func TestResolvePushTap(t *testing.T) { + route := func(url, uid string) *keybase1.PushTapRoute { + return &keybase1.PushTapRoute{Url: url, TargetUID: uid} + } + cases := []struct { + name string + payload string + want *keybase1.PushTapRoute + }{ + { + "chat with account", `{"type":"chat.newmessage","convID":"0000ab","uid":"u1"}`, + route("keybase://convid/0000ab", "u1"), + }, + { + "chat without account", `{"type":"chat.newmessage","convID":"0000ab"}`, + route("keybase://convid/0000ab", ""), + }, + {"chat without conversation", `{"type":"chat.newmessage"}`, nil}, + { + "apns chat with numbers and aps", + `{"type":"chat.newmessage","convID":"0000ab","uid":"u1","t":1,"aps":{"alert":{"body":"hi"}}}`, + route("keybase://convid/0000ab", "u1"), + }, + { + "a numeric convID becomes a string", `{"type":"chat.newmessage","convID":1234}`, + route("keybase://convid/1234", ""), + }, + { + "the uid is kept verbatim", `{"type":"chat.newmessage","convID":"0000ab","uid":"u 1&x"}`, + route("keybase://convid/0000ab", "u 1&x"), + }, + { + "follow with uid", `{"type":"follow","username":"testuser","uid":"u1"}`, + route("keybase://profile/show/testuser", "u1"), + }, + { + "follow with targetUID", `{"type":"follow","username":"testuser","targetUID":"u2"}`, + route("keybase://profile/show/testuser", "u2"), + }, + {"follow without username", `{"type":"follow","uid":"u1"}`, nil}, + { + "new device", `{"type":"device.new","uid":"u1","device_id":"d1"}`, + route("keybase://devices", "u1"), + }, + {"revoked device without account", `{"type":"device.revoked","device_id":"d1"}`, nil}, + { + "contacts joined", `{"message":"Your contact testuser joined Keybase"}`, + route("keybase://tabs.peopleTab", ""), + }, + {"read receipt", `{"type":"chat.readmessage","b":0,"message":"Your contact x"}`, nil}, + {"silent chat", `{"type":"chat.newmessageSilent_2","c":"0000ab"}`, nil}, + {"extension", `{"type":"chat.extension","convID":"0000ab"}`, nil}, + {"autoreset", `{"type":"autoreset","uid":"u1"}`, nil}, + {"failed pending", `{"type":"chat.failedpending","convID":"0000ab","uid":""}`, nil}, + {"an unknown type opens nothing", `{"type":"something.new","uid":"u1"}`, nil}, + {"not json", `not json`, nil}, + {"json that is not an object", `"just a string"`, nil}, + {"json with trailing garbage", `{"type":"chat.newmessage","convID":"0000ab"} x`, nil}, + { + "a conversation id is escaped into the URL", + `{"type":"chat.newmessage","convID":"a/b c&d"}`, + route("keybase://convid/a%2Fb%20c%26d", ""), + }, + { + "a username is escaped into the URL", + `{"type":"follow","username":"a b/c"}`, + route("keybase://profile/show/a%20b%2Fc", ""), + }, + {"a non-string message is not a contact push", `{"message":1}`, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := ResolvePushTap(tc.payload) + if tc.want == nil { + require.False(t, ok) + require.Equal(t, keybase1.PushTapRoute{}, got) + return + } + require.True(t, ok) + require.Equal(t, *tc.want, got) + }) + } +} + +// encodeURIComponent's escape set is what keeps a URL built here identical to +// the one the client used to build, so the marks JavaScript leaves alone are +// pinned rather than assumed. +func TestEncodeURIComponent(t *testing.T) { + require.Equal(t, "-_.!~*'()", encodeURIComponent("-_.!~*'()")) + require.Equal(t, "abcXYZ019", encodeURIComponent("abcXYZ019")) + require.Equal(t, "%20%2B%2F%3F%23%26%3D%25", encodeURIComponent(" +/?#&=%")) + require.Equal(t, "%E2%9C%93", encodeURIComponent("✓")) + require.Empty(t, encodeURIComponent("")) +} + +func TestPendingPushTapPeekIsNotDestructive(t *testing.T) { + tc := SetupTest(t, "pushtap", 1) + defer tc.Cleanup() + g := tc.G + ctx := context.Background() + + require.Nil(t, g.PendingPushTap.Peek()) + + g.PendingPushTap.Set(ctx, keybase1.PushTapRoute{Url: "keybase://convid/0000ab", TargetUID: "u1"}) + first := g.PendingPushTap.Peek() + require.NotNil(t, first) + require.Equal(t, "keybase://convid/0000ab", first.Url) + require.NotZero(t, first.Id, "Set stamps an id") + + // The peek that never reached the client -- or whose reply did not come back -- + // must leave the tap where it was, or the tap is gone with nothing to say so. + again := g.PendingPushTap.Peek() + require.Equal(t, first, again) + + require.True(t, g.PendingPushTap.Ack(first.Id)) + require.Nil(t, g.PendingPushTap.Peek(), "the ack retired it") + require.False(t, g.PendingPushTap.Ack(first.Id), "nothing left to retire") +} + +func TestPendingPushTapAckDoesNotRetireANewerTap(t *testing.T) { + tc := SetupTest(t, "pushtap", 1) + defer tc.Cleanup() + g := tc.G + ctx := context.Background() + + g.PendingPushTap.Set(ctx, keybase1.PushTapRoute{Url: "keybase://convid/0000ab"}) + stale := g.PendingPushTap.Peek() + require.NotNil(t, stale) + + // A tap not yet acked is replaced rather than queued: the newest tap is the + // one the user just made. + g.PendingPushTap.Set(ctx, keybase1.PushTapRoute{Url: "keybase://devices", TargetUID: "u2"}) + newer := g.PendingPushTap.Peek() + require.NotNil(t, newer) + require.Equal(t, "keybase://devices", newer.Url) + require.NotEqual(t, stale.Id, newer.Id) + + // The ack for the tap it replaced was already in flight; it must not take the + // newer one with it. + require.False(t, g.PendingPushTap.Ack(stale.Id)) + require.Equal(t, newer, g.PendingPushTap.Peek()) + + require.True(t, g.PendingPushTap.Ack(newer.Id)) + require.Nil(t, g.PendingPushTap.Peek()) +} diff --git a/go/libkb/test_notify_recorder.go b/go/libkb/test_notify_recorder.go new file mode 100644 index 000000000000..4a3401653c37 --- /dev/null +++ b/go/libkb/test_notify_recorder.go @@ -0,0 +1,164 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package libkb + +import ( + "context" + "errors" + "io" + "net" + "sync" + "time" + + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/go-codec/codec" + "github.com/keybase/go-framed-msgpack-rpc/rpc" +) + +// NotifyRecorder is a connection registered with a NotifyRouter, for tests. +// It records the notifications and calls sent to it in the order they were +// written: each is decoded inside the transport's Write, so it is recorded +// before the send returns. It never answers a call. A Go rpc.Server on the +// far end would serve each notification on its own goroutine and lose that +// order. +type NotifyRecorder struct { + ID ConnectionID + router *NotifyRouter + conn *recorderConn + closed chan error +} + +// RecordedNotify is one notification or call a NotifyRecorder saw. +type RecordedNotify struct { + Method string + arg []byte +} + +func newRecorderHandle() *codec.MsgpackHandle { + return &codec.MsgpackHandle{WriteExt: true, RawToString: true} +} + +// Decode decodes the message's single argument, e.g. into a +// keybase1.ClientStateArg. +func (r RecordedNotify) Decode(v any) error { + return codec.NewDecoderBytes(r.arg, newRecorderHandle()).Decode(v) +} + +// NewNotifyRecorder adds a connection to g's router and registers it for the +// given channels. +func NewNotifyRecorder(g *GlobalContext, channels keybase1.NotificationChannels) *NotifyRecorder { + conn := &recorderConn{readDone: make(chan struct{})} + xp := rpc.NewTransport(conn, NewRPCLogFactory(g), g.LocalNetworkInstrumenterStorage, + MakeWrapError(g), rpc.DefaultMaxFrameLength) + closed := make(chan error, 1) + // runs the transport's reader, as the service does, so that closing the + // connection fails the calls that were never answered + rpc.NewServer(xp, MakeWrapError(g)).Run() + id := g.NotifyRouter.AddConnection(xp, closed) + g.NotifyRouter.SetChannels(id, channels) + return &NotifyRecorder{ID: id, router: g.NotifyRouter, conn: conn, closed: closed} +} + +const recorderFlushMethod = "keybase.1.NotifyRecorder.flush" + +// Flush waits until everything queued to this connection so far has been +// written. It queues a marker notification and waits for its send, which +// returns only once the connection's single writer has written it, and so +// everything ahead of it. +func (r *NotifyRecorder) Flush() { + n := r.router + done := make(chan struct{}) + n.Lock() + s := n.senders[r.ID] + if s != nil { + s.enqueue(func(xp rpc.Transporter) { + defer close(done) + _ = rpc.NewClient(xp, nil, nil).Notify(context.Background(), recorderFlushMethod, []any{}, 0) + }) + } + n.Unlock() + if s == nil { + return + } + select { + case <-done: + case <-s.stop: + } +} + +// Messages returns what has been recorded so far, oldest first. +func (r *NotifyRecorder) Messages() []RecordedNotify { + r.conn.mu.Lock() + defer r.conn.mu.Unlock() + return append([]RecordedNotify(nil), r.conn.msgs...) +} + +// Close closes the connection, which removes it from the router. +func (r *NotifyRecorder) Close() { + _ = r.conn.Close() + r.closed <- io.EOF +} + +type recorderConn struct { + mu sync.Mutex + msgs []RecordedNotify + closeOnce sync.Once + readDone chan struct{} +} + +var _ net.Conn = (*recorderConn)(nil) + +// Write gets exactly one frame per call: the rpc encoder writes each frame, its +// length prefix included, in a single Write. +func (c *recorderConn) Write(b []byte) (int, error) { + dec := codec.NewDecoderBytes(b, newRecorderHandle()) + var length int + var frame []any + if err := dec.Decode(&length); err != nil { + return 0, err + } + if err := dec.Decode(&frame); err != nil { + return 0, err + } + // a notification is [2, method, args, tags?]; a call is [0, seqid, method, args, tags?] + if len(frame) > 1 { + if _, isMethod := frame[1].(string); !isMethod { + frame = append(frame[:1], frame[2:]...) + } + } + if len(frame) < 3 { + return 0, errors.New("NotifyRecorder: not a call or a notification") + } + method, _ := frame[1].(string) + if method == recorderFlushMethod { + return len(b), nil + } + args, _ := frame[2].([]any) + var arg []byte + if len(args) > 0 { + if err := codec.NewEncoderBytes(&arg, newRecorderHandle()).Encode(args[0]); err != nil { + return 0, err + } + } + c.mu.Lock() + c.msgs = append(c.msgs, RecordedNotify{Method: method, arg: arg}) + c.mu.Unlock() + return len(b), nil +} + +func (c *recorderConn) Read([]byte) (int, error) { + <-c.readDone + return 0, io.EOF +} + +func (c *recorderConn) Close() error { + c.closeOnce.Do(func() { close(c.readDone) }) + return nil +} + +func (c *recorderConn) LocalAddr() net.Addr { return nil } +func (c *recorderConn) RemoteAddr() net.Addr { return nil } +func (c *recorderConn) SetDeadline(time.Time) error { return nil } +func (c *recorderConn) SetReadDeadline(time.Time) error { return nil } +func (c *recorderConn) SetWriteDeadline(time.Time) error { return nil } diff --git a/go/protocol/keybase1/appstate.go b/go/protocol/keybase1/appstate.go index 9850958b713e..ebf300c5986f 100644 --- a/go/protocol/keybase1/appstate.go +++ b/go/protocol/keybase1/appstate.go @@ -78,16 +78,39 @@ func (o MobileNetworkState) String() string { return fmt.Sprintf("%v", int(o)) } +type PushTapRoute struct { + Url string `codec:"url" json:"url"` + TargetUID string `codec:"targetUID" json:"targetUID"` + Id int `codec:"id" json:"id"` +} + +func (o PushTapRoute) DeepCopy() PushTapRoute { + return PushTapRoute{ + Url: o.Url, + TargetUID: o.TargetUID, + Id: o.Id, + } +} + type UpdateMobileNetStateArg struct { State string `codec:"state" json:"state"` } +type PeekPushTapRouteArg struct { +} + +type AckPushTapRouteArg struct { + Id int `codec:"id" json:"id"` +} + type PowerMonitorEventArg struct { Event string `codec:"event" json:"event"` } type AppStateInterface interface { UpdateMobileNetState(context.Context, string) error + PeekPushTapRoute(context.Context) (*PushTapRoute, error) + AckPushTapRoute(context.Context, int) error PowerMonitorEvent(context.Context, string) error } @@ -110,6 +133,31 @@ func AppStateProtocol(i AppStateInterface) rpc.Protocol { return }, }, + "peekPushTapRoute": { + MakeArg: func() any { + var ret [1]PeekPushTapRouteArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + ret, err = i.PeekPushTapRoute(ctx) + return + }, + }, + "ackPushTapRoute": { + MakeArg: func() any { + var ret [1]AckPushTapRouteArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + typedArgs, ok := args.(*[1]AckPushTapRouteArg) + if !ok { + err = rpc.NewTypeError((*[1]AckPushTapRouteArg)(nil), args) + return + } + err = i.AckPushTapRoute(ctx, typedArgs[0].Id) + return + }, + }, "powerMonitorEvent": { MakeArg: func() any { var ret [1]PowerMonitorEventArg @@ -139,6 +187,17 @@ func (c AppStateClient) UpdateMobileNetState(ctx context.Context, state string) return } +func (c AppStateClient) PeekPushTapRoute(ctx context.Context) (res *PushTapRoute, err error) { + err = c.Cli.Call(ctx, "keybase.1.appState.peekPushTapRoute", []any{PeekPushTapRouteArg{}}, &res, 0*time.Millisecond) + return +} + +func (c AppStateClient) AckPushTapRoute(ctx context.Context, id int) (err error) { + __arg := AckPushTapRouteArg{Id: id} + err = c.Cli.Call(ctx, "keybase.1.appState.ackPushTapRoute", []any{__arg}, nil, 0*time.Millisecond) + return +} + func (c AppStateClient) PowerMonitorEvent(ctx context.Context, event string) (err error) { __arg := PowerMonitorEventArg{Event: event} err = c.Cli.Call(ctx, "keybase.1.appState.powerMonitorEvent", []any{__arg}, nil, 0*time.Millisecond) diff --git a/go/protocol/keybase1/notify_app.go b/go/protocol/keybase1/notify_app.go index 19120a54d2d3..5db46395b403 100644 --- a/go/protocol/keybase1/notify_app.go +++ b/go/protocol/keybase1/notify_app.go @@ -13,8 +13,22 @@ import ( type ExitArg struct { } +type MobileAppStateChangedArg struct { + State MobileAppState `codec:"state" json:"state"` +} + +type ClientStateArg struct { + State ClientState `codec:"state" json:"state"` +} + +type PushTapRouteAvailableArg struct { +} + type NotifyAppInterface interface { Exit(context.Context) error + MobileAppStateChanged(context.Context, MobileAppState) error + ClientState(context.Context, ClientState) error + PushTapRouteAvailable(context.Context) error } func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { @@ -31,6 +45,46 @@ 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 + }, + }, + "clientState": { + MakeArg: func() any { + var ret [1]ClientStateArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + typedArgs, ok := args.(*[1]ClientStateArg) + if !ok { + err = rpc.NewTypeError((*[1]ClientStateArg)(nil), args) + return + } + err = i.ClientState(ctx, typedArgs[0].State) + return + }, + }, + "pushTapRouteAvailable": { + MakeArg: func() any { + var ret [1]PushTapRouteAvailableArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + err = i.PushTapRouteAvailable(ctx) + return + }, + }, }, } } @@ -43,3 +97,20 @@ 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 +} + +func (c NotifyAppClient) ClientState(ctx context.Context, state ClientState) (err error) { + __arg := ClientStateArg{State: state} + err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.clientState", []any{__arg}, 0*time.Millisecond) + return +} + +func (c NotifyAppClient) PushTapRouteAvailable(ctx context.Context) (err error) { + err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.pushTapRouteAvailable", []any{PushTapRouteAvailableArg{}}, 0*time.Millisecond) + return +} diff --git a/go/protocol/keybase1/notify_ctl.go b/go/protocol/keybase1/notify_ctl.go index 9300430778a5..414347af0234 100644 --- a/go/protocol/keybase1/notify_ctl.go +++ b/go/protocol/keybase1/notify_ctl.go @@ -88,6 +88,50 @@ func (o NotificationChannels) DeepCopy() NotificationChannels { } } +type ClientSession struct { + LoggedIn bool `codec:"loggedIn" json:"loggedIn"` + Uid UID `codec:"uid" json:"uid"` + Username string `codec:"username" json:"username"` + DeviceID DeviceID `codec:"deviceID" json:"deviceID"` + DeviceName string `codec:"deviceName" json:"deviceName"` +} + +func (o ClientSession) DeepCopy() ClientSession { + return ClientSession{ + LoggedIn: o.LoggedIn, + Uid: o.Uid.DeepCopy(), + Username: o.Username, + DeviceID: o.DeviceID.DeepCopy(), + DeviceName: o.DeviceName, + } +} + +type ClientState struct { + Session *ClientSession `codec:"session,omitempty" json:"session,omitempty"` + HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` + AppState MobileAppState `codec:"appState" json:"appState"` +} + +func (o ClientState) DeepCopy() ClientState { + return ClientState{ + Session: (func(x *ClientSession) *ClientSession { + if x == nil { + return nil + } + tmp := x.DeepCopy() + return &tmp + })(o.Session), + HttpSrvInfo: (func(x *HttpSrvInfo) *HttpSrvInfo { + if x == nil { + return nil + } + tmp := x.DeepCopy() + return &tmp + })(o.HttpSrvInfo), + AppState: o.AppState.DeepCopy(), + } +} + type SetNotificationsArg struct { Channels NotificationChannels `codec:"channels" json:"channels"` } diff --git a/go/service/appstate.go b/go/service/appstate.go index c6e0e44b2b94..87ff14624d7a 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) @@ -54,6 +45,26 @@ func (a *appStateHandler) UpdateMobileNetState(ctx context.Context, stateStr str return nil } +// PeekPushTapRoute reports the route a tapped notification resolved to, and +// leaves it armed until the client acks. It is its own call rather than a field +// in setNotifications' snapshot: that reply goes to every subscriber, kbfs +// inside this same process among them, and a tap carried there would be read by +// whichever one subscribed first. +func (a *appStateHandler) PeekPushTapRoute(ctx context.Context) (*keybase1.PushTapRoute, error) { + route := a.G().PendingPushTap.Peek() + a.G().Log.CDebugf(ctx, "PeekPushTapRoute: waiting tap: %v", route != nil) + return route, nil +} + +// AckPushTapRoute retires the tap the client has acted on. Until this call the +// route stays armed, so a peek whose reply never arrived costs a repeat rather +// than the tap. +func (a *appStateHandler) AckPushTapRoute(ctx context.Context, id int) error { + retired := a.G().PendingPushTap.Ack(id) + a.G().Log.CDebugf(ctx, "AckPushTapRoute(%d): retired: %v", id, retired) + return nil +} + func (a *appStateHandler) PowerMonitorEvent(ctx context.Context, event string) (err error) { a.G().Log.CDebugf(ctx, "PowerMonitorEvent(%v)", event) a.G().DesktopAppState.Update(a.MetaContext(ctx), event, a.xp) diff --git a/go/service/appstate_test.go b/go/service/appstate_test.go new file mode 100644 index 000000000000..1d34a47323af --- /dev/null +++ b/go/service/appstate_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "testing" + + "github.com/keybase/client/go/libkb" + keybase1 "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// A peek is not a take. The reply can be lost on the way to the client, and the +// client is the only party that knows whether it acted, so the route stays armed +// until the client says so -- a lost reply then costs a repeat, not the tap. +func TestPeekPushTapRouteLeavesTheTapArmed(t *testing.T) { + tc := libkb.SetupTest(t, "appstate", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + h := newAppStateHandler(nil, g) + ctx := context.Background() + + got, err := h.PeekPushTapRoute(ctx) + require.NoError(t, err) + require.Nil(t, got, "no tap has happened") + + g.PendingPushTap.Set(ctx, keybase1.PushTapRoute{Url: "keybase://convid/0000ab", TargetUID: "u1"}) + + first, err := h.PeekPushTapRoute(ctx) + require.NoError(t, err) + require.NotNil(t, first) + require.Equal(t, "keybase://convid/0000ab", first.Url) + + again, err := h.PeekPushTapRoute(ctx) + require.NoError(t, err) + require.Equal(t, first, again, "still armed for a client that never got the first reply") + + require.NoError(t, h.AckPushTapRoute(ctx, first.Id)) + + got, err = h.PeekPushTapRoute(ctx) + require.NoError(t, err) + require.Nil(t, got, "the client said it acted") +} + +// An ack that crosses a newer tap must retire nothing: the user tapped again, +// and that tap has not been acted on. +func TestAckPushTapRouteIgnoresAStaleID(t *testing.T) { + tc := libkb.SetupTest(t, "appstate", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + h := newAppStateHandler(nil, g) + ctx := context.Background() + + g.PendingPushTap.Set(ctx, keybase1.PushTapRoute{Url: "keybase://convid/0000ab"}) + stale, err := h.PeekPushTapRoute(ctx) + require.NoError(t, err) + require.NotNil(t, stale) + + g.PendingPushTap.Set(ctx, keybase1.PushTapRoute{Url: "keybase://devices", TargetUID: "u2"}) + + require.NoError(t, h.AckPushTapRoute(ctx, stale.Id)) + + survived, err := h.PeekPushTapRoute(ctx) + require.NoError(t, err) + require.NotNil(t, survived) + require.Equal(t, "keybase://devices", survived.Url) +} + +// A tap must ride its own call and nothing else. Every app subscriber gets a +// clientState, so a tap carried there would be consumed by whichever one got +// it first. +func TestSetNotificationsLeavesTheTapAlone(t *testing.T) { + tc := libkb.SetupTest(t, "appstate", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + ctx := context.Background() + route := keybase1.PushTapRoute{Url: "keybase://devices", TargetUID: "u1"} + g.PendingPushTap.Set(ctx, route) + + svc := newTestClientStateService(t, g) + svc.settleInitialLoginAttempt(ctx) + rec := libkb.NewNotifyRecorder(g, keybase1.NotificationChannels{}) + defer rec.Close() + require.NoError(t, NewNotifyCtlHandler(nil, rec.ID, g).SetNotifications(ctx, keybase1.NotificationChannels{App: true})) + rec.Flush() + + got, err := newAppStateHandler(nil, g).PeekPushTapRoute(ctx) + require.NoError(t, err) + require.NotNil(t, got, "the subscribe did not consume the tap") + require.Equal(t, route.Url, got.Url) +} diff --git a/go/service/config.go b/go/service/config.go index 70be37b8f22e..5e67f484bf51 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -15,6 +15,7 @@ import ( "github.com/keybase/client/go/engine" "github.com/keybase/client/go/install" + "github.com/keybase/client/go/kbhttp/manager" "github.com/keybase/client/go/libkb" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/client/go/status" @@ -360,23 +361,15 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r return res, err } res = eng.Status() - m.Debug("GetBootstrapStatus: attempting to get HTTP server address") - for range 40 { // wait at most 2 seconds - addr, addrErr := h.svc.httpSrv.Addr() - if addrErr != nil { - m.Debug("GetBootstrapStatus: failed to get HTTP server address: %s", addrErr) - } else { - m.Debug("GetBootstrapStatus: http server: addr: %s token: %s", addr, h.svc.httpSrv.Token()) - res.HttpSrvInfo = &keybase1.HttpSrvInfo{ - Address: addr, - Token: h.svc.httpSrv.Token(), - } - break - } - time.Sleep(50 * time.Millisecond) - } - if res.HttpSrvInfo == nil { - m.Debug("GetBootstrapStatus: failed to get HTTP srv info after max attempts") + // Not waited on: a client learns the address from clientState and from + // HTTPSrvInfoUpdate, which the server sends whenever its address changes. + // This field is left as a convenience for a status read once the server has + // bound. + if info, infoErr := h.svc.httpSrv.Info(); infoErr != nil { + m.Debug("GetBootstrapStatus: no HTTP server address: %s", infoErr) + } else { + m.Debug("GetBootstrapStatus: http server: addr: %s token: %s", info.Address, manager.TokenPrefix(info.Token)) + res.HttpSrvInfo = &info } return res, nil } diff --git a/go/service/gregor.go b/go/service/gregor.go index 5fe2ba3edbc9..7484ee6836db 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -196,14 +196,23 @@ type gregorHandler struct { gregorCli *grclient.Client firehoseHandlers []libkb.GregorFirehoseHandler - badger *badges.Badger + badger gregorBadger reachability *reachability chatLog utils.DebugLabeler + // connGate decides when to connect and disconnect, and runs the steps + // OnConnect applies after syncing that can't be undone (badge pushes), so + // none of them lands after a Shutdown for the connection it came from. + connGate *gregorConnGate + // This mutex protects the con object connMutex sync.Mutex conn *rpc.Connection - uri *rpc.FMPURI + // connCtx lives as long as conn: Shutdown cancels it under connMutex. + // OnConnect runs under a ctx derived from it, and the connection's ping + // loop and push state debouncer exit when it is done. + connCtx context.Context + connCancel context.CancelFunc // connectHappened will be closed after gregor connection established connectHappened chan struct{} @@ -221,17 +230,26 @@ type gregorHandler struct { // a pushState call to firehose handlers pushStateFilter func(m gregor.Message) bool - shutdownCh chan struct{} broadcastCh chan gregor1.Message replayCh chan replayThreadArg pushStateCh chan struct{} forcePingCh chan struct{} // Testing - testingEvents *testingEvents + testingEvents *testingEvents + // authParamsForTest, if set, replaces authParams in OnConnect. + authParamsForTest func(ctx context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) transportForTesting *connTransport } +// gregorBadger is the part of the badger gregor pushes to. +type gregorBadger interface { + PushState(ctx context.Context, state gregor.State) + PushChatFullUpdate(ctx context.Context, update chat1.UnreadUpdateFull) +} + +var _ gregorBadger = (*badges.Badger)(nil) + var ( _ libkb.GregorState = (*gregorHandler)(nil) _ libkb.GregorListener = (*gregorHandler)(nil) @@ -250,6 +268,8 @@ func newGregorHandler(g *globals.Context) *gregorHandler { pushStateCh: make(chan struct{}, 100), forcePingCh: make(chan struct{}, 5), } + eg := g.ExternalG() + gh.connGate = newGregorConnGate(eg.MobileAppState, eg.DesktopAppState, gh, gh.chatLog.Debug, gh.forcePing) return gh } @@ -258,66 +278,15 @@ func (g *gregorHandler) Init() { // Start broadcast handler goroutine go g.broadcastMessageHandler() // Start the app state monitor thread - go g.monitorAppState() + g.connGate.start() + g.G().PushShutdownHook(func(libkb.MetaContext) error { + g.connGate.stop() + return nil + }) // Start replay thread go g.syncReplayThread() } -const ( - monitorConnect int = iota - monitorDisconnect - monitorNoop -) - -func (g *gregorHandler) monitorAppState() { - ctx := libkb.WithLogTag(context.Background(), "GRGRMON") - // Wait for state updates and react accordingly - state := keybase1.MobileAppState_FOREGROUND - suspended := false - for { - monitorAction := monitorNoop - select { - case <-g.G().MobileAppState.NextUpdate(state): - state = g.G().MobileAppState.State() - switch state { - case keybase1.MobileAppState_FOREGROUND: - g.forcePing(ctx) - monitorAction = monitorConnect - case keybase1.MobileAppState_BACKGROUNDACTIVE: - monitorAction = monitorConnect - case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_INACTIVE: - monitorAction = monitorDisconnect - } - case <-g.G().DesktopAppState.NextSuspendUpdate(suspended): - suspended = g.G().DesktopAppState.Suspended() - if !suspended { - monitorAction = monitorConnect - g.chatLog.Debug(ctx, "resumed, connecting") - } else { - g.chatLog.Debug(ctx, "suspended, disconnecting") - monitorAction = monitorDisconnect - } - } - switch monitorAction { - case monitorConnect: - // Make sure the URI is set before attempting this (possible it isn't in a race) - if g.uri != nil { - g.chatLog.Debug(ctx, "foregrounded, reconnecting") - if err := g.Connect(g.uri); err != nil { - g.chatLog.Debug(ctx, "error reconnecting: %s", err) - } - } - case monitorDisconnect: - g.chatLog.Debug(ctx, "backgrounded, shutting down connection") - g.Shutdown(ctx) - } - } -} - -func (g *gregorHandler) GetURI() *rpc.FMPURI { - return g.uri -} - func (g *gregorHandler) GetIncomingClient() gregor1.IncomingInterface { cli := g.getRPCCli() if g.IsShutdown() || cli == nil { @@ -371,6 +340,11 @@ func (g *gregorHandler) shutdownGregorClient(ctx context.Context) { } } +// resetGregorClient installs a new client for uid unless ctx is cancelled. +// OnConnect passes the ctx it derives for its connection, which Shutdown +// cancels under connMutex; checking and installing under connMutex too means +// Reset, which drops the client after its Shutdown, never runs between the +// two. func (g *gregorHandler) resetGregorClient(ctx context.Context, uid gregor1.UID, deviceID gregor1.DeviceID) (gcli *grclient.Client, err error) { defer g.chatLog.Trace(ctx, &err, "resetGregorClient")() // Create client object if we are logged in @@ -386,6 +360,14 @@ func (g *gregorHandler) resetGregorClient(ctx context.Context, uid gregor1.UID, g.Debug(ctx, "restore local state failed: %s", err) } } + g.connMutex.Lock() + defer g.connMutex.Unlock() + if ctx.Err() != nil { + if gcli != nil { + gcli.Stop() + } + return nil, chat.ErrDuplicateConnection + } g.gregorCliMu.Lock() gcliOld := g.gregorCli g.gregorCli = gcli @@ -437,7 +419,19 @@ func (g *gregorHandler) setReachability(r *reachability) { g.reachability = r } -func (g *gregorHandler) Connect(uri *rpc.FMPURI) (err error) { +// Connect connects to uri unless the app is in BACKGROUND or the desktop is +// suspended, in which case it connects once that ends. +func (g *gregorHandler) Connect(uri *rpc.FMPURI) error { + return g.connGate.connect(libkb.WithLogTag(context.Background(), "GRGRCONN"), uri, false) +} + +// ConnectFresh is Connect, resetting any existing connection first so it +// authenticates again. +func (g *gregorHandler) ConnectFresh(uri *rpc.FMPURI) error { + return g.connGate.connect(libkb.WithLogTag(context.Background(), "GRGRCONN"), uri, true) +} + +func (g *gregorHandler) connectNow(uri *rpc.FMPURI) (err error) { ctx := libkb.WithLogTag(context.Background(), "GRGRCONN") defer g.chatLog.Trace(ctx, &err, "Connect")() @@ -452,18 +446,32 @@ func (g *gregorHandler) Connect(uri *rpc.FMPURI) (err error) { g.connectHappened = make(chan struct{}) }() - // In case we need to interrupt auth'ing or the ping loop, - // set up this channel. - g.shutdownCh = make(chan struct{}) - g.uri = uri - go g.pushStateNewDataDebouncer(g.shutdownCh) + var conn *rpc.Connection if uri.UseTLS() { - err = g.connectTLS(ctx) + conn, err = g.connectTLS(ctx, uri) + if err != nil { + return err + } } else { - err = g.connectNoTLS(ctx) + conn = g.connectNoTLS(ctx, uri) } + g.conn = conn + g.connCtx, g.connCancel = context.WithCancel(context.Background()) - return err + // The client we get here will reconnect to gregord on disconnect if necessary. + // We should grab it here instead of in OnConnect, since the connection is not + // fully established in OnConnect. Anything that wants to make calls outside + // of OnConnect should use g.cli, everything else should the client that is + // a parameter to OnConnect + g.cli = WrapGenericClientWithTimeout(conn.GetClient(), GregorRequestTimeout, + chat.ErrChatServerTimeout) + g.pingCli = conn.GetClient() // Don't want this to have a timeout from here + + // Start up ping loop to keep the connection to gregord alive, and to kick + // off the reconnect logic in the RPC library + go g.pingLoop(ctx, g.connCtx.Done()) + go g.pushStateNewDataDebouncer(g.connCtx.Done()) + return nil } func (g *gregorHandler) HandlerName() string { @@ -536,7 +544,7 @@ func (g *gregorHandler) iterateOverFirehoseHandlers(f func(h libkb.GregorFirehos g.firehoseHandlers = freshHandlers } -func (g *gregorHandler) pushStateNewDataDebouncer(shutdownCh chan struct{}) { +func (g *gregorHandler) pushStateNewDataDebouncer(done <-chan struct{}) { shouldSend := false var lastTime time.Time dur := time.Second @@ -556,7 +564,7 @@ func (g *gregorHandler) pushStateNewDataDebouncer(shutdownCh chan struct{}) { } case <-time.After(dur): trigger() - case <-shutdownCh: + case <-done: return } } @@ -768,25 +776,42 @@ func (g *gregorHandler) notificationParams(ctx context.Context, gcli *grclient.C return t } +// onConnectCtx returns the ctx OnConnect runs under, or ErrDuplicateConnection +// if conn is not the current connection. The rpc library's own ctx is not +// enough: it cancels only the reconnect loop running when the connection is +// shut down, and any later call on that connection starts a new loop, and so +// a new OnConnect, under a ctx nothing cancels. The returned ctx is cancelled +// by conn's Shutdown, synchronously under connMutex, as well as by the rpc +// library. The rpc library's ctx carries no values, so none are lost. +func (g *gregorHandler) onConnectCtx(ctx context.Context, conn *rpc.Connection) (context.Context, context.CancelFunc, error) { + g.connMutex.Lock() + defer g.connMutex.Unlock() + if conn == nil || conn != g.conn { + return nil, nil, chat.ErrDuplicateConnection + } + res, cancel := context.WithCancel(g.connCtx) + stop := context.AfterFunc(ctx, cancel) + return res, func() { + stop() + cancel() + }, nil +} + // OnConnect is called by the rpc library to indicate we have connected to // gregord -func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, +func (g *gregorHandler) OnConnect(rpcCtx context.Context, conn *rpc.Connection, cli rpc.GenericClient, srv *rpc.Server, ) (err error) { + ctx, cancel, err := g.onConnectCtx(rpcCtx, conn) + if err != nil { + g.chatLog.Debug(libkb.WithLogTag(rpcCtx, "GRGRONCONN"), "aborting, not the current connection") + return err + } + defer cancel() ctx = libkb.WithLogTag(ctx, "GRGRONCONN") defer g.chatLog.Trace(ctx, &err, "OnConnect")() - // If we get a random OnConnect on some other connection that is not g.conn, then - // just reject it. - g.connMutex.Lock() - if conn != g.conn { - g.connMutex.Unlock() - g.chatLog.Debug(ctx, "aborting on dup connection") - return chat.ErrDuplicateConnection - } - g.connMutex.Unlock() - g.chatLog.Debug(ctx, "connected") timeoutCli := WrapGenericClientWithTimeout(cli, GregorRequestTimeout, chat.ErrChatServerTimeout) chatCli := chat1.RemoteClient{Cli: chat.NewRemoteClient(g.G(), cli)} @@ -794,13 +819,18 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, return fmt.Errorf("error registering protocol: %s", err) } - uid, deviceID, token, nist, err := g.authParams(ctx) + authParams := g.authParams + if g.authParamsForTest != nil { + authParams = g.authParamsForTest + } + uid, deviceID, token, nist, err := authParams(ctx) if err != nil { return err } gcli, err := g.resetGregorClient(ctx, uid, deviceID) if err != nil { - return fmt.Errorf("failed to get gregor client: %s", err) + // %w keeps ErrDuplicateConnection visible to ShouldRetryOnConnect. + return fmt.Errorf("failed to get gregor client: %w", err) } iboxVers := g.inboxParams(ctx, uid) latestCtime := g.notificationParams(ctx, gcli) @@ -810,6 +840,12 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, var identBreaks []keybase1.TLFIdentifyFailure ctx = globals.ChatCtx(ctx, g.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, &identBreaks, chat.NewCachingIdentifyNotifier(g.G())) + // Every connect sets the gate's uri before connecting and a logout cancels + // ctx as it clears it, so the uri is set while ctx is live. + var uri *rpc.FMPURI + if !g.onGateIfCurrent(ctx, func() { uri = g.connGate.uri }) { + return chat.ErrDuplicateConnection + } g.chatLog.Debug(ctx, "OnConnect begin") syncAllRes, err := chatCli.SyncAll(ctx, chat1.SyncAllArg{ Uid: uid, @@ -819,7 +855,7 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, Ctime: latestCtime, Fresh: g.isFirstConnect(), ProtVers: chat1.SyncAllProtVers_V1, - HostName: g.GetURI().Host, + HostName: uri.Host, SummarizeMaxMsgs: true, ParticipantsMode: chat1.InboxParticipantsMode_SKIP_TEAMS, }) @@ -841,6 +877,31 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, return fmt.Errorf("error authenticating: %s", err) } + return g.onConnectSynced(ctx, chatCli, timeoutCli, uid, gcli, syncAllRes) +} + +// onGateIfCurrent runs f under the connection gate if OnConnect's ctx is +// still live, and reports whether it ran. Every Shutdown and Reset is made +// under the gate too, and Shutdown cancels ctx, so a disconnect lands entirely +// before f, and f is then skipped, or entirely after it. f must not call back +// into the gate: its mutex is not reentrant. +func (g *gregorHandler) onGateIfCurrent(ctx context.Context, f func()) bool { + g.connGate.mu.Lock() + defer g.connGate.mu.Unlock() + if ctx.Err() != nil { + return false + } + f() + return true +} + +// onConnectSynced applies a SyncAll result for OnConnect's connection. A +// logout or reconnect can shut the connection down at any point, so each +// step applies only while ctx is live, and OnConnect then fails with +// ErrDuplicateConnection. +func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.RemoteInterface, + timeoutCli rpc.GenericClient, uid gregor1.UID, gcli *grclient.Client, syncAllRes chat1.SyncAllResult, +) error { // Update badging for chat. // This happens before Syncer.Connected for a reason. // If the new inbox version (e.g. 8) were committed to disk and then the @@ -848,40 +909,56 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, // badging update (7->8) then on reconnect an incomplete chat badge update (8->9) // could be received. // See: https://github.com/keybase/client/pull/12651 - if g.badger != nil { - g.badger.PushChatFullUpdate(ctx, syncAllRes.Badge) + if !g.onGateIfCurrent(ctx, func() { + if g.badger != nil { + g.badger.PushChatFullUpdate(ctx, syncAllRes.Badge) + } + }) { + return chat.ErrDuplicateConnection } // Sync chat data using a Syncer object // This commits the new inbox version to persistent storage. - if err := g.G().Syncer.Connected(ctx, chatCli, uid, &syncAllRes.Chat); err != nil { + // It runs outside the gate, which holds off every connect and disconnect, + // to keep the gate's holds short: it writes storage and notifies. The + // Syncer ignores a cancelled ctx, and Shutdown marks it disconnected after + // cancelling. + if err := g.G().Syncer.Connected(ctx, chatCli, uid, &syncAllRes.Chat); err != nil && ctx.Err() == nil { return fmt.Errorf("error running chat sync: %s", err) } // Sync down events since we have been dead + // TODO: unlike the badge steps around it, serverSync is check-then-act: the connection can + // shut down between this check and the sync. Gating it means running an RPC under the gate. + if ctx.Err() != nil { + return chat.ErrDuplicateConnection + } if _, err := g.serverSync(ctx, gregor1.IncomingClient{Cli: timeoutCli}, gcli, &syncAllRes.Notification); err != nil { g.chatLog.Debug(ctx, "serverSync: failure: %s", err) return fmt.Errorf("error running state sync: %s", err) } - // Update badging from gregor. - if g.badger != nil { - state, err := gcli.StateMachineState(ctx, nil, false) - if err != nil { - g.chatLog.Debug(ctx, "unable to get gregor state for badging: %v", err) - g.badger.PushState(ctx, gregor1.State{}) - } else { - g.badger.PushState(ctx, state) + // Update badging from gregor, and call out to reachability module if we + // have one. + if !g.onGateIfCurrent(ctx, func() { + if g.badger != nil { + state, err := gcli.StateMachineState(ctx, nil, false) + if err != nil { + g.chatLog.Debug(ctx, "unable to get gregor state for badging: %v", err) + g.badger.PushState(ctx, gregor1.State{}) + } else { + g.badger.PushState(ctx, state) + } } - } - - // Call out to reachability module if we have one - if g.reachability != nil { - g.chatLog.Debug(ctx, "setting reachability") - g.reachability.setReachability(keybase1.Reachability{ - Reachable: keybase1.Reachable_YES, - }) + if g.reachability != nil { + g.chatLog.Debug(ctx, "setting reachability") + g.reachability.setReachability(keybase1.Reachability{ + Reachable: keybase1.Reachable_YES, + }) + } + }) { + return chat.ErrDuplicateConnection } // Broadcast reconnect oobm. Spawn this off into a goroutine so that we don't delay @@ -895,12 +972,15 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, } }(g.makeReconnectOobm()) - // No longer first connect if we are now connected - g.chatLog.Debug(ctx, "setting first connect to false") - g.setFirstConnect(false) - g.setConnectedAt(time.Now()) + // No longer first connect if we are now connected. + if !g.onGateIfCurrent(ctx, func() { + g.chatLog.Debug(ctx, "setting first connect to false") + g.setFirstConnect(false) + g.setConnectedAt(time.Now()) + }) { + return chat.ErrDuplicateConnection + } g.chatLog.Debug(ctx, "OnConnect complete") - return nil } @@ -1355,6 +1435,10 @@ func (g *gregorHandler) handleOutOfBandMessage(ctx context.Context, obm gregor.O } } +// Shutdown disconnects. In production it is only ever called under the +// connection gate, from reconcile, reconnect or Reset, which is what keeps it +// from interleaving with the steps OnConnect applies after syncing. Tests +// call it directly. func (g *gregorHandler) Shutdown(ctx context.Context) { defer g.chatLog.Trace(ctx, nil, "Shutdown")() g.connMutex.Lock() @@ -1364,16 +1448,24 @@ func (g *gregorHandler) Shutdown(ctx context.Context) { return } - // Alert chat syncer that we are now disconnected - g.G().Syncer.Disconnected(ctx) - - close(g.shutdownCh) + g.connCancel() g.conn.Shutdown() + // After connCancel, which cancels the ctx of an OnConnect in flight, so a + // Syncer.Connected from it either lands before this and is overwritten, + // or sees the cancel and is skipped. + g.G().Syncer.Disconnected(ctx) g.conn = nil g.cli = nil + g.pingCli = nil g.setConnectedAt(time.Time{}) } +// Disconnect resets the connection and keeps it down until the next Connect, +// whatever the app state does meanwhile. +func (g *gregorHandler) Disconnect() error { + return g.connGate.forget(libkb.WithLogTag(context.Background(), "GRGRCONN")) +} + func (g *gregorHandler) Reset() error { g.Shutdown(context.Background()) g.setFirstConnect(true) @@ -1390,12 +1482,12 @@ const ( ) func (g *gregorHandler) loggedIn(ctx context.Context) (uid keybase1.UID, did keybase1.DeviceID, token string, nist *libkb.NIST, res loggedInRes) { - // Check to see if we have been shut down, - select { - case <-g.shutdownCh: + // Check to see if we have been shut down. + g.connMutex.Lock() + connCtx := g.connCtx + g.connMutex.Unlock() + if connCtx != nil && connCtx.Err() != nil { return uid, did, token, nil, loggedInMaybe - default: - // if we were going to block, then that means we are still alive } var err error @@ -1463,26 +1555,17 @@ func (g *gregorHandler) isReachable(ctx context.Context) bool { } if err != nil { g.chatLog.Debug(ctx, "isReachable: error: terminating connection: %s", err.Error()) - if _, err := g.Reconnect(ctx); err != nil { - g.chatLog.Debug(ctx, "isReachable: error reconnecting: %s", err.Error()) - } + g.Reconnect(ctx) return false } return true } -func (g *gregorHandler) Reconnect(ctx context.Context) (didShutdown bool, err error) { - if g.IsConnected() { - didShutdown = true - g.chatLog.Debug(ctx, "Reconnect: reconnecting to server") - g.Shutdown(ctx) - return didShutdown, g.Connect(g.uri) - } - - didShutdown = false - g.chatLog.Debug(ctx, "Reconnect: skipping reconnect, already disconnected") - return didShutdown, nil +// Reconnect drops a live connection and connects again when the app state +// allows it, without waiting for either. +func (g *gregorHandler) Reconnect(ctx context.Context) { + g.connGate.requestReconnect(ctx) } func (g *gregorHandler) forcePing(ctx context.Context) { @@ -1493,7 +1576,14 @@ func (g *gregorHandler) forcePing(ctx context.Context) { } } -func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCancel context.CancelFunc) { +func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, done <-chan struct{}) { + g.connMutex.Lock() + pingCli := g.pingCli + g.connMutex.Unlock() + if pingCli == nil { + g.chatLog.Debug(ctx, "ping loop: id: %x no connection, skipping ping", id) + return + } var err error doneCh := make(chan error) timeout := g.G().Env.GetGregorPingTimeout() @@ -1505,14 +1595,14 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCancel var timeoutCancel context.CancelFunc var timeoutCtx context.Context timeoutCtx, timeoutCancel = context.WithTimeout(ctx, timeout) - _, err = gregor1.IncomingClient{Cli: g.pingCli}.Ping(timeoutCtx) + _, err = gregor1.IncomingClient{Cli: pingCli}.Ping(timeoutCtx) timeoutCancel() } else { // If we are not connected, we don't want to timeout anything // Just hook into the normal reconnect chan stuff in the RPC // library g.chatLog.Debug(ctx, "ping loop: id: %x normal ping, not connected", id) - _, err = gregor1.IncomingClient{Cli: g.pingCli}.Ping(ctx) + _, err = gregor1.IncomingClient{Cli: pingCli}.Ping(ctx) g.chatLog.Debug(ctx, "ping loop: id: %x normal ping success", id) } select { @@ -1525,32 +1615,22 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCancel select { case err = <-doneCh: - case <-g.shutdownCh: + case <-done: g.chatLog.Debug(ctx, "ping loop: id: %x shutdown received", id) - shutdownCancel() return } if err != nil { g.Debug(ctx, "ping loop: id: %x error: %s", id, err) if errors.Is(err, context.DeadlineExceeded) { g.chatLog.Debug(ctx, "ping loop: timeout: terminating connection") - var didShutdown bool - var err error - if didShutdown, err = g.Reconnect(ctx); err != nil { - g.chatLog.Debug(ctx, "ping loop: id: %x error reconnecting: %s", id, err) - } - // It is possible that we have already reconnected by the time we call Reconnect - // above. If that is the case, we don't want to terminate the ping loop. Only - // if Reconnect has actually reset the connection do we stop this ping loop. - if didShutdown { - shutdownCancel() - return - } + g.Reconnect(ctx) } } } -func (g *gregorHandler) pingLoop(ctx context.Context) { +// pingLoop runs until done, the Done channel of the ctx of the connection it +// was started for, closes. +func (g *gregorHandler) pingLoop(ctx context.Context, done <-chan struct{}) { id, _ := libkb.RandBytes(4) duration := g.G().Env.GetGregorPingInterval() timeout := g.G().Env.GetGregorPingTimeout() @@ -1564,34 +1644,27 @@ func (g *gregorHandler) pingLoop(ctx context.Context) { defer g.chatLog.Debug(ctx, "ping loop: id: %x terminating", id) ticker := time.NewTicker(duration) for { - pingCtx, shutdownCancel := context.WithCancel(libkb.CopyTagsToBackground(ctx)) + pingCtx, pingCancel := context.WithCancel(libkb.CopyTagsToBackground(ctx)) select { case <-g.forcePingCh: g.chatLog.Debug(pingCtx, "ping loop: forced attempt") - g.pingOnce(pingCtx, id, shutdownCancel) + g.pingOnce(pingCtx, id, done) case <-ticker.C: - g.pingOnce(pingCtx, id, shutdownCancel) - case <-g.shutdownCh: + g.pingOnce(pingCtx, id, done) + case <-done: g.chatLog.Debug(pingCtx, "ping loop: id: %x shutdown received", id) - shutdownCancel() + pingCancel() return } - shutdownCancel() + pingCancel() } } -// connMutex must be locked before calling this -func (g *gregorHandler) connectTLS(ctx context.Context) error { - if g.conn != nil { - g.chatLog.Debug(ctx, "skipping connect, conn is not nil") - return nil - } - - uri := g.uri +func (g *gregorHandler) connectTLS(ctx context.Context, uri *rpc.FMPURI) (*rpc.Connection, error) { g.chatLog.Debug(ctx, "connecting to gregord via TLS at %s", uri) rawCA := g.G().Env.GetBundledCA(uri.Host) if len(rawCA) == 0 { - return fmt.Errorf("No bundled CA for %s", uri.Host) + return nil, fmt.Errorf("No bundled CA for %s", uri.Host) } g.chatLog.Debug(ctx, "Using CA for gregor: %s", libkb.ShortCA(rawCA)) // Let people know we are trying to sync @@ -1608,37 +1681,17 @@ func (g *gregorHandler) connectTLS(ctx context.Context) error { // We deliberately avoid ForceInitialBackoff here, because we don't // want to penalize mobile, which tears down its connection frequently. } - g.conn = rpc.NewTLSConnectionWithDialable(rpc.NewFixedRemote(uri.HostPort), + return rpc.NewTLSConnectionWithDialable(rpc.NewFixedRemote(uri.HostPort), []byte(rawCA), libkb.NewContextifiedErrorUnwrapper(g.G().ExternalG()), g, libkb.NewRPCLogFactory(g.G().ExternalG()), g.G().ExternalG().RemoteNetworkInstrumenterStorage, logger.LogOutputWithDepthAdder{Logger: g.G().Log}, rpc.DefaultMaxFrameLength, opts, - libkb.NewProxyDialable(g.G().Env)) - - // The client we get here will reconnect to gregord on disconnect if necessary. - // We should grab it here instead of in OnConnect, since the connection is not - // fully established in OnConnect. Anything that wants to make calls outside - // of OnConnect should use g.cli, everything else should the client that is - // a parameter to OnConnect - g.cli = WrapGenericClientWithTimeout(g.conn.GetClient(), GregorRequestTimeout, - chat.ErrChatServerTimeout) - g.pingCli = g.conn.GetClient() // Don't want this to have a timeout from here - - // Start up ping loop to keep the connection to gregord alive, and to kick - // off the reconnect logic in the RPC library - go g.pingLoop(ctx) - - return nil + libkb.NewProxyDialable(g.G().Env)), nil } // connMutex must be locked before calling this -func (g *gregorHandler) connectNoTLS(ctx context.Context) error { - if g.conn != nil { - g.chatLog.Debug(ctx, "skipping connect, conn is not nil") - return nil - } - uri := g.uri +func (g *gregorHandler) connectNoTLS(ctx context.Context, uri *rpc.FMPURI) *rpc.Connection { g.chatLog.Debug(ctx, "connecting to gregord without TLS at %s", uri) t := newConnTransport(g.G().ExternalG(), uri.HostPort) g.transportForTesting = t @@ -1650,19 +1703,9 @@ func (g *gregorHandler) connectNoTLS(ctx context.Context) error { return backoff.NewConstantBackOff(GregorConnectionRetryInterval) }, } - g.conn = rpc.NewConnectionWithTransport(g, t, + return rpc.NewConnectionWithTransport(g, t, libkb.NewContextifiedErrorUnwrapper(g.G().ExternalG()), logger.LogOutputWithDepthAdder{Logger: g.G().Log}, opts) - - g.cli = WrapGenericClientWithTimeout(g.conn.GetClient(), GregorRequestTimeout, - chat.ErrChatServerTimeout) - g.pingCli = g.conn.GetClient() - - // Start up ping loop to keep the connection to gregord alive, and to kick - // off the reconnect logic in the RPC library - go g.pingLoop(ctx) - - return nil } func (g *gregorHandler) currentUID() gregor1.UID { diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go new file mode 100644 index 000000000000..2b0fe6b89b27 --- /dev/null +++ b/go/service/gregor_conn.go @@ -0,0 +1,212 @@ +package service + +import ( + "context" + "sync" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/go-framed-msgpack-rpc/rpc" +) + +// gregorConnector is the connection gregorConnGate drives: the gregor +// handler, or a fake in tests. +type gregorConnector interface { + // connectNow connects to uri, doing nothing if already connected. + connectNow(uri *rpc.FMPURI) error + // Shutdown disconnects, doing nothing if not connected. + Shutdown(ctx context.Context) + Reset() error + IsConnected() bool +} + +// gregorAppState is the mobile app state the gate follows, as an interface so +// it can be substituted: tests wrap the real one to act between a connect's +// state read and what the connect does with it. +type gregorAppState interface { + State() keybase1.MobileAppState + NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} +} + +// gregorConnGate decides when gregor is connected. Only BACKGROUND, or a +// desktop suspend, takes the connection down; INACTIVE keeps it up. +// +// Every connect and the monitor read the app state and act on it under mu. +// A BACKGROUND that lands after a connect read the state wakes the monitor, +// which then waits for that connect before taking the connection down. mu +// also runs the steps OnConnect applies after syncing (the handler takes it in +// onGateIfCurrent), so none of them interleaves with a disconnect, and guards +// the uri OnConnect reads. +// +// This is a mutex gate rather than a single owning goroutine like +// kbhttp/manager's Srv: connect and forget return errors their callers need, +// and the OnConnect steps must report "no longer current" back on the caller's +// goroutine so onConnectSynced can return ErrDuplicateConnection. A +// request-channel loop would need a reply channel per request -- more code and +// more states -- so do not harmonise the two shapes. Only reconnect, whose +// callers need no result, is a request the monitor runs. +type gregorConnGate struct { + mobile gregorAppState + desktop *libkb.DesktopAppState + conn gregorConnector + debug func(ctx context.Context, format string, args ...any) + onForeground func(ctx context.Context) + + mu sync.Mutex + // uri is the last URI a connect asked for. It is kept when the connect is + // held back by BACKGROUND or a desktop suspend, so the monitor connects + // once that ends. + uri *rpc.FMPURI + // The monitor's last seen states and the change channels it waits on for + // them; tests use them to wait until the monitor has caught up. + monitorState keybase1.MobileAppState + monitorSuspended bool + monitorWait <-chan struct{} + monitorSuspendWait <-chan struct{} + + // reconnectCh holds at most one reconnect request for the monitor, so a + // burst of requests coalesces. + reconnectCh chan struct{} + + startOnce sync.Once + stopOnce sync.Once + stopCh chan struct{} + monitorDone chan struct{} +} + +func newGregorConnGate(mobile gregorAppState, desktop *libkb.DesktopAppState, conn gregorConnector, + debug func(ctx context.Context, format string, args ...any), onForeground func(ctx context.Context), +) *gregorConnGate { + return &gregorConnGate{ + mobile: mobile, + desktop: desktop, + conn: conn, + debug: debug, + onForeground: onForeground, + reconnectCh: make(chan struct{}, 1), + stopCh: make(chan struct{}), + monitorDone: make(chan struct{}), + } +} + +// start reconciles against the current state and starts the monitor. +func (c *gregorConnGate) start() { + c.startOnce.Do(func() { + ctx := libkb.WithLogTag(context.Background(), "GRGRMON") + state, suspended := c.mobile.State(), c.desktop.Suspended() + c.debug(ctx, "monitorAppState: starting up in %v (suspended: %v)", state, suspended) + c.reconcile(ctx) + go c.monitor(ctx, state, suspended) + }) +} + +// stop tells the monitor to exit, without waiting for it. It does not +// disconnect. +func (c *gregorConnGate) stop() { + c.stopOnce.Do(func() { close(c.stopCh) }) +} + +// connect connects to uri when reconcile allows it. With reset, any existing +// connection is reset first so it authenticates again; that includes one that +// is not connected, such as one whose auth failed while logged out, which +// would otherwise keep connectNow from dialing. +func (c *gregorConnGate) connect(ctx context.Context, uri *rpc.FMPURI, reset bool) error { + c.mu.Lock() + defer c.mu.Unlock() + c.uri = uri + if reset { + if err := c.conn.Reset(); err != nil { + return err + } + } + return c.reconcileLocked(ctx) +} + +// forget resets the connection and drops the uri, so nothing reconnects until +// the next connect. +func (c *gregorConnGate) forget(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + c.debug(ctx, "forget: resetting and forgetting the uri") + c.uri = nil + return c.conn.Reset() +} + +// requestReconnect asks the monitor to reconnect and returns without waiting. +func (c *gregorConnGate) requestReconnect(ctx context.Context) { + select { + case c.reconnectCh <- struct{}{}: + c.debug(ctx, "Reconnect: requested") + default: + c.debug(ctx, "Reconnect: one is already pending") + } +} + +// reconnect drops a live connection and connects again when reconcile allows +// it. +func (c *gregorConnGate) reconnect(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.conn.IsConnected() { + c.debug(ctx, "Reconnect: skipping reconnect, already disconnected") + return + } + c.debug(ctx, "Reconnect: reconnecting to server") + c.conn.Shutdown(ctx) + if err := c.reconcileLocked(ctx); err != nil { + c.debug(ctx, "Reconnect: error connecting: %s", err) + } +} + +func (c *gregorConnGate) reconcile(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.reconcileLocked(ctx); err != nil { + c.debug(ctx, "reconcile: error connecting: %s", err) + } +} + +// reconcileLocked is the only place that decides whether a connection may +// exist: none in BACKGROUND or while the desktop is suspended, otherwise one +// to the uri, if any. c.mu must be held. +func (c *gregorConnGate) reconcileLocked(ctx context.Context) error { + state, suspended := c.mobile.State(), c.desktop.Suspended() + if state == keybase1.MobileAppState_BACKGROUND || suspended { + c.debug(ctx, "reconcile: disconnecting in %v (suspended: %v)", state, suspended) + c.conn.Shutdown(ctx) + return nil + } + // Nothing asked to connect yet, for example before login. + if c.uri == nil { + return nil + } + c.debug(ctx, "reconcile: connecting in %v", state) + return c.conn.connectNow(c.uri) +} + +func (c *gregorConnGate) monitor(ctx context.Context, state keybase1.MobileAppState, suspended bool) { + defer close(c.monitorDone) + for { + next := c.mobile.NextUpdate(state) + nextSuspend := c.desktop.NextSuspendUpdate(suspended) + c.mu.Lock() + c.monitorState, c.monitorSuspended = state, suspended + c.monitorWait, c.monitorSuspendWait = next, nextSuspend + c.mu.Unlock() + select { + case <-next: + case <-nextSuspend: + case <-c.reconnectCh: + c.reconnect(ctx) + continue + case <-c.stopCh: + return + } + prev := state + state, suspended = c.mobile.State(), c.desktop.Suspended() + if state != prev && state == keybase1.MobileAppState_FOREGROUND { + c.onForeground(ctx) + } + c.reconcile(ctx) + } +} diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go new file mode 100644 index 000000000000..c3feee99957b --- /dev/null +++ b/go/service/gregor_conn_test.go @@ -0,0 +1,1348 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net" + "os" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/chat" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/gregor" + grclient "github.com/keybase/client/go/gregor/client" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/go-framed-msgpack-rpc/rpc" + "github.com/stretchr/testify/require" +) + +// fakeGregorConn models the handler's connection: it can exist without being +// connected (stale), and connectNow does nothing while one exists. +type fakeGregorConn struct { + sync.Mutex + exists bool + up bool + uri *rpc.FMPURI + connects int + shutdowns int + resets int +} + +func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { + f.Lock() + defer f.Unlock() + if !f.exists { + f.exists, f.up = true, true + f.uri = uri + f.connects++ + } + return nil +} + +func (f *fakeGregorConn) Shutdown(context.Context) { + f.Lock() + defer f.Unlock() + if f.exists { + f.exists, f.up = false, false + f.shutdowns++ + } +} + +func (f *fakeGregorConn) Reset() error { + f.Shutdown(context.Background()) + f.Lock() + defer f.Unlock() + f.resets++ + return nil +} + +// goStale leaves the connection in place but not connected, as when its auth +// fails with an error the connection does not retry. +func (f *fakeGregorConn) goStale() { + f.Lock() + defer f.Unlock() + f.up = false +} + +func (f *fakeGregorConn) IsConnected() bool { + f.Lock() + defer f.Unlock() + return f.up +} + +type fakeGregorCounts struct { + up bool + connects, shutdowns, resets int +} + +func (f *fakeGregorConn) counts() fakeGregorCounts { + f.Lock() + defer f.Unlock() + return fakeGregorCounts{up: f.up, connects: f.connects, shutdowns: f.shutdowns, resets: f.resets} +} + +func (f *fakeGregorConn) lastURI() *rpc.FMPURI { + f.Lock() + defer f.Unlock() + return f.uri +} + +// gregorTestAppState wraps the real app state so a test can act between a +// connect's state read and what the connect does with that read. +type gregorTestAppState struct { + *libkb.MobileAppState + mu sync.Mutex + // afterRead, if set, runs once after a State read, before the reader acts. + afterRead func() +} + +func (a *gregorTestAppState) State() keybase1.MobileAppState { + state := a.MobileAppState.State() + a.mu.Lock() + f := a.afterRead + a.afterRead = nil + a.mu.Unlock() + if f != nil { + f() + } + return state +} + +func (a *gregorTestAppState) setAfterRead(f func()) { + a.mu.Lock() + defer a.mu.Unlock() + a.afterRead = f +} + +type gregorConnTest struct { + tc libkb.TestContext + gate *gregorConnGate + mobile *gregorTestAppState + conn *fakeGregorConn + pings *atomic.Int64 +} + +func testGregorURI(t testing.TB, host string) *rpc.FMPURI { + uri, err := rpc.ParseFMPURI(fmt.Sprintf("fmprpc+tls://%s:443", host)) + require.NoError(t, err) + return uri +} + +// setupGregorConn builds a gate in state and starts it, as Init does before +// the service's first connect. +func setupGregorConn(t *testing.T, state keybase1.MobileAppState) *gregorConnTest { + tc := libkb.SetupTest(t, "gregorconn", 2) + t.Cleanup(tc.Cleanup) + tc.G.MobileAppState.Update(state) + conn := &fakeGregorConn{} + pings := &atomic.Int64{} + mobile := &gregorTestAppState{MobileAppState: tc.G.MobileAppState} + gate := newGregorConnGate(mobile, tc.G.DesktopAppState, conn, + func(ctx context.Context, format string, args ...any) { t.Logf(format, args...) }, + func(context.Context) { pings.Add(1) }) + gate.start() + t.Cleanup(func() { + gate.stop() + select { + case <-gate.monitorDone: + case <-time.After(10 * time.Second): + t.Error("monitor did not exit on stop") + } + }) + return &gregorConnTest{tc: tc, gate: gate, mobile: mobile, conn: conn, pings: pings} +} + +// waitMonitor waits until the monitor has acted on the current states and is +// waiting for the next change. +func (c *gregorConnTest) waitMonitor(t *testing.T) { + t.Helper() + g := c.tc.G + require.Eventually(t, func() bool { + c.gate.mu.Lock() + state, suspended := c.gate.monitorState, c.gate.monitorSuspended + wait, suspendWait := c.gate.monitorWait, c.gate.monitorSuspendWait + c.gate.mu.Unlock() + if wait == nil || wait != g.MobileAppState.NextUpdate(state) || + suspendWait != g.DesktopAppState.NextSuspendUpdate(suspended) { + return false + } + select { + case <-wait: + return false + case <-suspendWait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "monitor did not catch up") +} + +func (c *gregorConnTest) update(t *testing.T, state keybase1.MobileAppState) { + t.Helper() + c.tc.G.MobileAppState.Update(state) + c.waitMonitor(t) +} + +func (c *gregorConnTest) requireUp(t *testing.T, up bool, msg string) { + t.Helper() + require.Equal(t, up, c.conn.IsConnected(), msg) +} + +func TestGregorConnStartupInBackground(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_BACKGROUND) + c.waitMonitor(t) + uri := testGregorURI(t, "gregord.test") + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + c.requireUp(t, false, "connected during a background launch") + require.Equal(t, 0, c.conn.counts().connects) + + c.update(t, keybase1.MobileAppState_BACKGROUNDACTIVE) + c.requireUp(t, true, "did not connect on leaving BACKGROUND") + require.Equal(t, uri, c.conn.lastURI()) + require.Equal(t, fakeGregorCounts{up: true, connects: 1}, c.conn.counts()) +} + +func TestGregorConnLoginInBackground(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + first := testGregorURI(t, "first.test") + require.NoError(t, c.gate.connect(context.Background(), first, true)) + require.Equal(t, fakeGregorCounts{up: true, connects: 1, resets: 1}, c.conn.counts()) + + c.update(t, keybase1.MobileAppState_BACKGROUND) + c.requireUp(t, false, "still connected in BACKGROUND") + + second := testGregorURI(t, "second.test") + require.NoError(t, c.gate.connect(context.Background(), second, true)) + c.requireUp(t, false, "login connected in BACKGROUND") + require.Equal(t, fakeGregorCounts{connects: 1, shutdowns: 1, resets: 2}, c.conn.counts()) + + c.update(t, keybase1.MobileAppState_FOREGROUND) + c.requireUp(t, true, "did not connect on foreground after a background login") + require.Equal(t, second, c.conn.lastURI()) + + // A login while connected resets the connection before connecting. + require.NoError(t, c.gate.connect(context.Background(), first, true)) + require.Equal(t, fakeGregorCounts{up: true, connects: 3, shutdowns: 2, resets: 3}, c.conn.counts()) + require.Equal(t, first, c.conn.lastURI()) +} + +func TestGregorConnInactiveStaysConnected(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + require.NoError(t, c.gate.connect(context.Background(), testGregorURI(t, "gregord.test"), false)) + for range 3 { + c.update(t, keybase1.MobileAppState_INACTIVE) + c.requireUp(t, true, "INACTIVE disconnected") + c.update(t, keybase1.MobileAppState_FOREGROUND) + c.requireUp(t, true, "FOREGROUND disconnected") + } + require.Equal(t, fakeGregorCounts{up: true, connects: 1}, c.conn.counts()) + require.EqualValues(t, 3, c.pings.Load()) +} + +func TestGregorConnDuplicateEvents(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_BACKGROUND) + c.waitMonitor(t) + uri := testGregorURI(t, "gregord.test") + for range 3 { + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + } + for range 3 { + c.update(t, keybase1.MobileAppState_BACKGROUND) + } + require.Equal(t, fakeGregorCounts{}, c.conn.counts()) + + for round := 1; round <= 3; round++ { + for range 3 { + c.update(t, keybase1.MobileAppState_BACKGROUNDACTIVE) + c.requireUp(t, true, "down in BACKGROUNDACTIVE") + } + for range 3 { + c.update(t, keybase1.MobileAppState_FOREGROUND) + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + c.requireUp(t, true, "down in FOREGROUND") + } + for range 3 { + c.update(t, keybase1.MobileAppState_BACKGROUND) + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + c.requireUp(t, false, "up in BACKGROUND") + } + require.Equal(t, fakeGregorCounts{connects: round, shutdowns: round}, c.conn.counts()) + } + require.EqualValues(t, 3, c.pings.Load()) +} + +var allAppStates = []keybase1.MobileAppState{ + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, +} + +// requireStaysDown drives every transition and checks that nothing connects. +func (c *gregorConnTest) requireStaysDown(t *testing.T, why string) { + t.Helper() + connects := c.conn.counts().connects + for _, state := range allAppStates { + c.update(t, state) + c.gate.reconnect(context.Background()) + c.requireUp(t, false, fmt.Sprintf("connected in %v %s", state, why)) + } + require.Equal(t, connects, c.conn.counts().connects, "connect attempted "+why) +} + +func TestGregorConnLogoutStaysDown(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + uri := testGregorURI(t, "gregord.test") + require.NoError(t, c.gate.connect(context.Background(), uri, true)) + c.requireUp(t, true, "login did not connect") + + require.NoError(t, c.gate.forget(context.Background())) + c.requireUp(t, false, "logout left gregor connected") + c.requireStaysDown(t, "after logout") + + c.update(t, keybase1.MobileAppState_FOREGROUND) + require.NoError(t, c.gate.connect(context.Background(), uri, true)) + c.requireUp(t, true, "login after logout did not connect") +} + +func TestGregorConnLogoutInBackground(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + require.NoError(t, c.gate.connect(context.Background(), testGregorURI(t, "gregord.test"), true)) + c.update(t, keybase1.MobileAppState_BACKGROUND) + require.NoError(t, c.gate.forget(context.Background())) + c.update(t, keybase1.MobileAppState_BACKGROUNDACTIVE) + c.update(t, keybase1.MobileAppState_FOREGROUND) + c.requireUp(t, false, "foreground after a background logout connected") + require.Equal(t, 1, c.conn.counts().connects) +} + +// A connection whose auth failed while logged out stays in place without +// being connected; the next login must still connect. +func TestGregorConnLoginReplacesStaleConn(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + uri := testGregorURI(t, "gregord.test") + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + c.conn.goStale() + c.update(t, keybase1.MobileAppState_INACTIVE) + c.update(t, keybase1.MobileAppState_FOREGROUND) + require.NoError(t, c.gate.connect(context.Background(), uri, true)) + c.requireUp(t, true, "login left a stale connection in place") + require.Equal(t, fakeGregorCounts{up: true, connects: 2, shutdowns: 1, resets: 1}, c.conn.counts()) +} + +// A BACKGROUND applied while a connect is deciding must not leave gregor +// connected. +func TestGregorConnBackgroundRacingConnect(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + c.mobile.setAfterRead(func() { + // connect has read FOREGROUND. The monitor is idle, so mu is held + // here only if connect holds it; otherwise let the monitor fully + // apply BACKGROUND before connect acts on its stale read. + holdsMu := !c.gate.mu.TryLock() + if !holdsMu { + c.gate.mu.Unlock() + } + c.tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + if !holdsMu { + c.waitMonitor(t) + } + }) + require.NoError(t, c.gate.connect(context.Background(), testGregorURI(t, "gregord.test"), false)) + c.waitMonitor(t) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, c.tc.G.MobileAppState.State()) + c.requireUp(t, false, "connected in BACKGROUND after racing a connect") +} + +func TestGregorConnReconnectInBackground(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + uri := testGregorURI(t, "gregord.test") + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + + c.gate.reconnect(context.Background()) + require.Equal(t, fakeGregorCounts{up: true, connects: 2, shutdowns: 1}, c.conn.counts()) + + // A connection left up while BACKGROUND lands, as when a ping times out + // before the monitor has acted. + c.gate.mu.Lock() + c.tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + c.gate.mu.Unlock() + c.waitMonitor(t) + require.NoError(t, c.conn.connectNow(uri)) + c.gate.reconnect(context.Background()) + c.requireUp(t, false, "reconnect connected in BACKGROUND") + require.Equal(t, fakeGregorCounts{connects: 3, shutdowns: 3}, c.conn.counts()) + + c.gate.reconnect(context.Background()) + c.requireUp(t, false, "reconnect connected while disconnected") + require.Equal(t, fakeGregorCounts{connects: 3, shutdowns: 3}, c.conn.counts()) +} + +// A reconnect request returns without waiting, even while the gate is held, +// and a burst of requests made before the monitor runs is one reconnect. +func TestGregorConnReconnectRequestsCoalesce(t *testing.T) { + tc := libkb.SetupTest(t, "gregorconn", 1) + defer tc.Cleanup() + conn := &fakeGregorConn{} + mobile := &gregorTestAppState{MobileAppState: tc.G.MobileAppState} + gate := newGregorConnGate(mobile, tc.G.DesktopAppState, conn, + func(ctx context.Context, format string, args ...any) { t.Logf(format, args...) }, + func(context.Context) {}) + c := &gregorConnTest{tc: tc, gate: gate, mobile: mobile, conn: conn} + require.NoError(t, gate.connect(context.Background(), testGregorURI(t, "gregord.test"), false)) + + gate.mu.Lock() + done := make(chan struct{}) + go func() { + defer close(done) + for range 10 { + gate.requestReconnect(context.Background()) + } + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("a reconnect request waited") + } + gate.mu.Unlock() + require.Equal(t, fakeGregorCounts{up: true, connects: 1}, conn.counts(), "reconnected without the monitor") + + gate.start() + defer func() { + gate.stop() + <-gate.monitorDone + }() + want := fakeGregorCounts{up: true, connects: 2, shutdowns: 1} + require.Eventually(t, func() bool { return conn.counts() == want }, 10*time.Second, time.Millisecond, + "did not reconnect exactly once") + c.waitMonitor(t) + require.Empty(t, gate.reconnectCh, "a request is still pending") + require.Equal(t, want, conn.counts(), "reconnected more than once") +} + +func TestGregorConnDesktopSuspend(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + require.NoError(t, c.gate.connect(context.Background(), testGregorURI(t, "gregord.test"), false)) + mctx := libkb.NewMetaContextForTest(c.tc) + c.tc.G.DesktopAppState.Update(mctx, "suspend", nil) + c.waitMonitor(t) + c.requireUp(t, false, "connected while suspended") + c.tc.G.DesktopAppState.Update(mctx, "resume", nil) + c.waitMonitor(t) + c.requireUp(t, true, "did not connect on resume") + require.Equal(t, fakeGregorCounts{up: true, connects: 2, shutdowns: 1}, c.conn.counts()) +} + +// A ping timeout that reconnects while the machine is suspended must not +// dial, and neither must a connect; resuming connects. +func TestGregorReconnectWhileSuspendedDoesNotConnect(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + c.waitMonitor(t) + uri := testGregorURI(t, "gregord.test") + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + + // A connection left up while the suspend lands, as when a ping times out + // before the monitor has acted. + mctx := libkb.NewMetaContextForTest(c.tc) + c.gate.mu.Lock() + c.tc.G.DesktopAppState.Update(mctx, "suspend", nil) + c.gate.mu.Unlock() + c.waitMonitor(t) + require.NoError(t, c.conn.connectNow(uri)) + c.gate.reconnect(context.Background()) + c.requireUp(t, false, "reconnect connected while suspended") + require.Equal(t, fakeGregorCounts{connects: 2, shutdowns: 2}, c.conn.counts()) + + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + c.requireUp(t, false, "connect connected while suspended") + require.Equal(t, 2, c.conn.counts().connects) + + c.tc.G.DesktopAppState.Update(mctx, "resume", nil) + c.waitMonitor(t) + c.requireUp(t, true, "did not connect on resume") + require.Equal(t, 3, c.conn.counts().connects) +} + +// TestGregorConnScenarioReplay replays every lifecycle scenario from the +// service's startup connect: gregor is connected after each step exactly +// when the app is not in BACKGROUND, and a login at that point doesn't +// change that. +func TestGregorConnScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + c := setupGregorConn(t, lifecycletest.InitialState) + uri := testGregorURI(t, "gregord.test") + require.NoError(t, c.gate.connect(context.Background(), uri, false)) + lifecycletest.Play(t, c.tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + want := step.Want != keybase1.MobileAppState_BACKGROUND + c.waitMonitor(t) + if got := c.conn.IsConnected(); got != want { + t.Fatalf("step %d %v: connected %v in %v", i, step.Do, got, step.Want) + } + require.NoError(t, c.gate.connect(context.Background(), uri, true)) + c.waitMonitor(t) + if got := c.conn.IsConnected(); got != want { + t.Fatalf("step %d %v: connected %v in %v after a login", i, step.Do, got, step.Want) + } + require.NoError(t, c.gate.forget(context.Background())) + c.waitMonitor(t) + if c.conn.IsConnected() { + t.Fatalf("step %d %v: connected in %v after a logout", i, step.Do, step.Want) + } + require.NoError(t, c.gate.connect(context.Background(), uri, true)) + c.waitMonitor(t) + if got := c.conn.IsConnected(); got != want { + t.Fatalf("step %d %v: connected %v in %v after a logout and login", i, step.Do, got, step.Want) + } + }) + }) + t.Run(sc.Name+"/logged out", func(t *testing.T) { + c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) + require.NoError(t, c.gate.connect(context.Background(), testGregorURI(t, "gregord.test"), true)) + require.NoError(t, c.gate.forget(context.Background())) + lifecycletest.Play(t, c.tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + c.waitMonitor(t) + if c.conn.IsConnected() || c.conn.counts().connects != 1 { + t.Fatalf("step %d %v: connect attempted in %v while logged out", i, step.Do, step.Want) + } + }) + }) + } +} + +func TestGregorConnStress(t *testing.T) { + tc := libkb.SetupTest(t, "gregorconn", 1) + defer tc.Cleanup() + baseline := runtime.NumGoroutine() + + conn := &fakeGregorConn{} + mobile := &gregorTestAppState{MobileAppState: tc.G.MobileAppState} + gate := newGregorConnGate(mobile, tc.G.DesktopAppState, conn, + func(context.Context, string, ...any) {}, func(context.Context) {}) + gate.start() + c := &gregorConnTest{tc: tc, gate: gate, mobile: mobile, conn: conn} + uri := testGregorURI(t, "gregord.test") + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } + + stop := make(chan struct{}) + var workers, writers sync.WaitGroup + for w := range 4 { + workers.Add(1) + go func() { + defer workers.Done() + ctx := context.Background() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + switch (i + w) % 4 { + case 0: + _ = gate.connect(ctx, uri, false) + case 1: + _ = gate.connect(ctx, uri, true) + case 2: + _ = gate.forget(ctx) + default: + gate.requestReconnect(ctx) + } + runtime.Gosched() + } + }() + } + for w := range 4 { + writers.Add(1) + go func() { + defer writers.Done() + rng := rand.New(rand.NewSource(int64(w))) + for range 500 { + tc.G.MobileAppState.Update(states[rng.Intn(len(states))]) + if rng.Intn(4) == 0 { + time.Sleep(time.Duration(rng.Intn(200)) * time.Microsecond) + } + } + }() + } + + done := make(chan struct{}) + go func() { + writers.Wait() + close(stop) + workers.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(60 * time.Second): + // A deadlock leaves the workers holding the gate, so the cleanup that + // stops the monitor never returns. Nothing this test writes is + // flushed through that hung unwind, neither t.Fatal's message nor a + // panic's, so say it on stderr first; go test's own timeout then + // dumps every stack. + const msg = "deadlock: transitions and connects did not finish" + fmt.Fprintln(os.Stderr, msg) + t.Fatal(msg) + } + + require.NoError(t, gate.forget(context.Background())) + c.requireStaysDown(t, "after settling logged out") + require.NoError(t, gate.connect(context.Background(), uri, true)) + c.requireUp(t, true, "login did not connect after settling") + c.update(t, keybase1.MobileAppState_BACKGROUND) + c.requireUp(t, false, "up after settling in BACKGROUND") + c.update(t, keybase1.MobileAppState_BACKGROUNDACTIVE) + c.requireUp(t, true, "down after settling in BACKGROUNDACTIVE") + counts := conn.counts() + t.Logf("%d connects, %d shutdowns, %d resets", counts.connects, counts.shutdowns, counts.resets) + + gate.stop() + select { + case <-gate.monitorDone: + case <-time.After(10 * time.Second): + t.Fatal("monitor did not exit on stop") + } + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "leaked goroutines") +} + +// Connects and shutdowns race a reader of the gate's URI and the transport's +// dial. Nothing listens on the port, so OnConnect never runs; this covers the +// gate under -race, not a live connection. +func TestGregorHandlerConnectRaces(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + + uri := closedPortURI(t) + h := newGregorHandler(g) + stop := make(chan struct{}) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + for { + select { + case <-stop: + return + default: + } + _ = gateURI(h) + runtime.Gosched() + } + }() + for i := range 20 { + require.NoError(t, h.Connect(uri)) + // Vary how far the dial gets before the shutdown. + time.Sleep(time.Duration(i%4) * time.Millisecond) + h.Shutdown(context.Background()) + } + close(stop) + <-readerDone + require.Equal(t, uri, gateURI(h)) +} + +func gateURI(h *gregorHandler) *rpc.FMPURI { + h.connGate.mu.Lock() + defer h.connGate.mu.Unlock() + return h.connGate.uri +} + +// A connect that fails before it creates a connection, as with no bundled CA +// for the host, leaves nothing running for it, however often it is retried. +func TestGregorHandlerFailedConnectLeavesNothingRunning(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + h := newGregorHandler(g) + uri, err := rpc.ParseFMPURI("fmprpc+tls://no-bundled-ca.test:443") + require.NoError(t, err) + + baseline := runtime.NumGoroutine() + for range 20 { + require.ErrorContains(t, h.Connect(uri), "No bundled CA") + h.connGate.reconcile(context.Background()) + } + require.False(t, hasConn(h)) + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "a failed connect leaked goroutines") +} + +// Everything a connection starts, its ping loop and push state debouncer +// included, exits when it is shut down. +func TestGregorHandlerShutdownStopsConnGoroutines(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + h := newGregorHandler(g) + uri := closedPortURI(t) + + baseline := runtime.NumGoroutine() + for range 10 { + require.NoError(t, h.Connect(uri)) + require.True(t, hasConn(h)) + h.Shutdown(context.Background()) + } + deadline := time.Now().Add(10 * time.Second) + for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "a shut down connection left goroutines running") +} + +// A shut down connection's auth reports loggedInMaybe instead of checking the +// login. +func TestGregorHandlerLoggedInAfterShutdown(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + h := newGregorHandler(g) + ctx := context.Background() + + _, _, _, _, res := h.loggedIn(ctx) + require.Equal(t, loggedInNo, res) + require.NoError(t, h.Connect(closedPortURI(t))) + _, _, _, _, res = h.loggedIn(ctx) + require.Equal(t, loggedInNo, res) + h.Shutdown(ctx) + _, _, _, _, res = h.loggedIn(ctx) + require.Equal(t, loggedInMaybe, res) +} + +// closedPortURI points at a closed port, so a connection only retries until +// shut down. +func closedPortURI(t *testing.T) *rpc.FMPURI { + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := l.Addr().String() + require.NoError(t, l.Close()) + uri, err := rpc.ParseFMPURI("fmprpc://" + addr) + require.NoError(t, err) + return uri +} + +func hasConn(h *gregorHandler) bool { + h.connMutex.Lock() + defer h.connMutex.Unlock() + return h.conn != nil +} + +// The service's startup and login connects go through the handler's gate. +// Logout goes through the handler's gate, so no transition reconnects. +func TestGregorHandlerDisconnectStaysDown(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + + h := newGregorHandler(g) + require.NoError(t, h.ConnectFresh(closedPortURI(t))) + require.True(t, hasConn(h), "did not connect") + require.NoError(t, h.Disconnect()) + require.False(t, hasConn(h), "Disconnect left a connection") + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + h.connGate.reconcile(context.Background()) + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + h.connGate.reconcile(context.Background()) + require.False(t, hasConn(h), "reconnected after Disconnect") +} + +// The service skips Init when gregor is disabled or in Tor mode, so the +// gate's monitor never starts, but a logout still disconnects. It must +// return instead of waiting for anything. +func TestGregorHandlerDisconnectWithoutInit(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + + h := newGregorHandler(g) + done := make(chan error, 1) + go func() { done <- h.Disconnect() }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "Disconnect blocked with no Init") + } +} + +func TestGregorHandlerConnectInBackground(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + + h := newGregorHandler(g) + uri := closedPortURI(t) + require.NoError(t, h.Connect(uri)) + require.False(t, hasConn(h), "Connect connected in BACKGROUND") + require.NoError(t, h.ConnectFresh(uri)) + require.False(t, hasConn(h), "ConnectFresh connected in BACKGROUND") + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + h.connGate.reconcile(context.Background()) + require.True(t, hasConn(h), "did not connect on leaving BACKGROUND") + h.Shutdown(context.Background()) +} + +// acceptingListener accepts and holds connections, counting them, so a +// connection dials successfully and then fails in OnConnect. +type acceptingListener struct { + net.Listener + accepts atomic.Int64 + mu sync.Mutex + conns []net.Conn +} + +func newAcceptingListener(t *testing.T) *acceptingListener { + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + a := &acceptingListener{Listener: l} + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + a.mu.Lock() + a.conns = append(a.conns, c) + a.mu.Unlock() + a.accepts.Add(1) + } + }() + t.Cleanup(func() { + _ = l.Close() + a.mu.Lock() + defer a.mu.Unlock() + for _, c := range a.conns { + _ = c.Close() + } + }) + return a +} + +func (a *acceptingListener) uri(t *testing.T) *rpc.FMPURI { + uri, err := rpc.ParseFMPURI("fmprpc://" + a.Addr().String()) + require.NoError(t, err) + return uri +} + +// requireStale waits until the handler holds a connection that is not +// connected: with nobody logged in, OnConnect fails with an auth error the +// connection does not retry on its own. +func requireStale(t *testing.T, h *gregorHandler, a *acceptingListener, accepts int64) { + t.Helper() + require.Eventually(t, func() bool { + return a.accepts.Load() >= accepts && hasConn(h) && !h.IsConnected() + }, 10*time.Second, time.Millisecond, "connection did not fail") +} + +// After a terminal connect failure, the ping loop's pings redial at the ping +// interval, without tearing the connection down and without spinning. +func TestGregorHandlerTerminalFailureRedialsOnPing(t *testing.T) { + t.Setenv("KEYBASE_PUSH_PING_INTERVAL", "100ms") + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + a := newAcceptingListener(t) + + h := newGregorHandler(g) + defer h.Shutdown(context.Background()) + require.NoError(t, h.Connect(a.uri(t))) + requireStale(t, h, a, 1) + start := a.accepts.Load() + time.Sleep(time.Second) + redials := a.accepts.Load() - start + t.Logf("%d redials in 1s", redials) + require.GreaterOrEqual(t, redials, int64(3), "ping loop did not redial a failed connection") + require.LessOrEqual(t, redials, int64(13), "redialing faster than the ping interval") + require.True(t, hasConn(h), "failed connection was torn down") +} + +// A transition to FOREGROUND redials a failed connection right away instead +// of waiting for the next ping. +func TestGregorHandlerTerminalFailureRedialsOnForeground(t *testing.T) { + t.Setenv("KEYBASE_PUSH_PING_INTERVAL", "1h") + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + tc.G.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + a := newAcceptingListener(t) + + h := newGregorHandler(g) + h.Init() + defer h.Shutdown(context.Background()) + require.NoError(t, h.Connect(a.uri(t))) + requireStale(t, h, a, 1) + time.Sleep(200 * time.Millisecond) + require.EqualValues(t, 1, a.accepts.Load()) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + require.Eventually(t, func() bool { return a.accepts.Load() >= 2 }, 10*time.Second, time.Millisecond, + "FOREGROUND did not redial a failed connection") + require.True(t, hasConn(h), "failed connection was torn down") +} + +// fakeSyncer ignores a Connected whose ctx is cancelled, as chat.Syncer does. +type fakeSyncer struct { + types.Syncer + mu sync.Mutex + connected bool + connects int + // onConnected, if set, runs once inside Connected, after the syncer is + // marked connected, as a logout landing during the sync would. + onConnected func() + // onDisconnected, if set, runs once inside Disconnected, after the + // syncer is marked disconnected. + onDisconnected func() +} + +func (s *fakeSyncer) IsConnected(context.Context) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.connected +} + +func (s *fakeSyncer) Connected(ctx context.Context, _ chat1.RemoteInterface, _ gregor1.UID, _ *chat1.SyncChatRes) error { + s.mu.Lock() + if err := ctx.Err(); err != nil { + s.mu.Unlock() + return err + } + s.connected = true + s.connects++ + f := s.onConnected + s.onConnected = nil + s.mu.Unlock() + if f != nil { + f() + } + return nil +} + +func (s *fakeSyncer) Disconnected(context.Context) { + s.mu.Lock() + s.connected = false + f := s.onDisconnected + s.onDisconnected = nil + s.mu.Unlock() + if f != nil { + f() + } +} + +func (s *fakeSyncer) connectCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.connects +} + +type fakeBadger struct { + mu sync.Mutex + pushes int + // onPush, if set, runs once inside a push. + onPush func() +} + +func (b *fakeBadger) push() { + b.mu.Lock() + b.pushes++ + f := b.onPush + b.onPush = nil + b.mu.Unlock() + if f != nil { + f() + } +} + +func (b *fakeBadger) PushState(context.Context, gregor.State) { b.push() } +func (b *fakeBadger) PushChatFullUpdate(context.Context, chat1.UnreadUpdateFull) { b.push() } + +func (b *fakeBadger) count() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.pushes +} + +func currentConn(h *gregorHandler) *rpc.Connection { + h.connMutex.Lock() + defer h.connMutex.Unlock() + return h.conn +} + +// onConnectCtx returns the ctx OnConnect derives for h's current connection. +func onConnectCtx(t *testing.T, h *gregorHandler) context.Context { + ctx, cancel, err := h.onConnectCtx(context.Background(), currentConn(h)) + require.NoError(t, err) + t.Cleanup(cancel) + return ctx +} + +type onConnectTailTest struct { + h *gregorHandler + ctx context.Context + gcli *grclient.Client + syncer *fakeSyncer + badger *fakeBadger + uid gregor1.UID + syncRes chat1.SyncAllResult +} + +// setupOnConnectTail builds a handler with a current connection and a +// gregor client, as OnConnect has them once SyncAll has returned. +func setupOnConnectTail(t *testing.T) *onConnectTailTest { + tc, g := setupGregorTest(t) + t.Cleanup(tc.Cleanup) + syncer := &fakeSyncer{} + g.Syncer = syncer + h := newGregorHandler(g) + badger := &fakeBadger{} + h.badger = badger + require.NoError(t, h.Connect(closedPortURI(t))) + t.Cleanup(func() { h.Shutdown(context.Background()) }) + ctx := onConnectCtx(t, h) + uid := gregor1.UID(make([]byte, 16)) + gcli, err := h.resetGregorClient(ctx, uid, gregor1.DeviceID(make([]byte, 16))) + require.NoError(t, err) + return &onConnectTailTest{ + h: h, ctx: ctx, gcli: gcli, syncer: syncer, badger: badger, uid: uid, + syncRes: chat1.SyncAllResult{Notification: chat1.NewSyncAllNotificationResWithState(gregor1.State{})}, + } +} + +func (c *onConnectTailTest) run(ctx context.Context) error { + return c.h.onConnectSynced(ctx, chat1.RemoteClient{}, nil, c.uid, c.gcli, c.syncRes) +} + +func TestGregorOnConnectTailApplies(t *testing.T) { + c := setupOnConnectTail(t) + require.NoError(t, c.run(c.ctx)) + require.Equal(t, 2, c.badger.count()) + require.Len(t, c.h.replayCh, 1) + require.True(t, c.syncer.IsConnected(context.Background())) + require.False(t, c.h.isFirstConnect()) + require.False(t, c.h.connectedSince().IsZero()) +} + +// A tail whose connection a logout has shut down applies nothing. +func TestGregorOnConnectTailAfterLogout(t *testing.T) { + c := setupOnConnectTail(t) + require.NoError(t, c.h.Disconnect()) + require.ErrorIs(t, c.run(c.ctx), chat.ErrDuplicateConnection) + require.Zero(t, c.badger.count(), "badges pushed after logout") + require.Zero(t, c.syncer.connectCalls(), "chat sync ran after logout") + require.Empty(t, c.h.replayCh, "gregor state sync ran after logout") + require.True(t, c.h.isFirstConnect(), "first connect cleared after logout") + require.True(t, c.h.connectedSince().IsZero(), "connected time set after logout") +} + +// A logout during the chat sync leaves the syncer disconnected and stops the +// rest of the tail. +func TestGregorOnConnectLogoutDuringChatSync(t *testing.T) { + c := setupOnConnectTail(t) + c.syncer.onConnected = func() { require.NoError(t, c.h.Disconnect()) } + require.ErrorIs(t, c.run(c.ctx), chat.ErrDuplicateConnection) + require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected after logout") + require.Equal(t, 1, c.badger.count(), "badges pushed after logout") + require.Empty(t, c.h.replayCh, "gregor state sync ran after logout") + require.True(t, c.h.isFirstConnect(), "first connect cleared after logout") + require.True(t, c.h.connectedSince().IsZero(), "connected time set after logout") +} + +// A Shutdown that lands between the gregor badge push and the connected +// step leaves first connect and the connected time alone. A real Shutdown +// can't land during the push, which holds the gate, so the push cancels the +// connection's ctx as that Shutdown would. +func TestGregorOnConnectShutdownBeforeConnectedStep(t *testing.T) { + c := setupOnConnectTail(t) + c.badger.onPush = func() { + c.badger.mu.Lock() + defer c.badger.mu.Unlock() + c.badger.onPush = func() { + c.h.connMutex.Lock() + defer c.h.connMutex.Unlock() + c.h.connCancel() + } + } + require.ErrorIs(t, c.run(c.ctx), chat.ErrDuplicateConnection) + require.Equal(t, 2, c.badger.count()) + require.True(t, c.h.isFirstConnect(), "first connect cleared after shutdown") + require.True(t, c.h.connectedSince().IsZero(), "connected time set after shutdown") +} + +// Shutdown cancels OnConnect's ctx before it marks the syncer disconnected, +// so a Syncer.Connected from that OnConnect landing just after the mark is +// ignored rather than leaving the syncer connected. +func TestGregorShutdownCancelsBeforeSyncerDisconnected(t *testing.T) { + c := setupOnConnectTail(t) + c.syncer.onDisconnected = func() { + _ = c.syncer.Connected(c.ctx, chat1.RemoteClient{}, c.uid, &chat1.SyncChatRes{}) + } + require.NoError(t, c.h.Disconnect()) + require.False(t, c.syncer.IsConnected(context.Background()), "syncer connected after shutdown") +} + +// A logout can't finish while a badge push for the old connection is in +// progress, so the push can't land after the logout. +func TestGregorOnConnectBadgePushHoldsOffLogout(t *testing.T) { + c := setupOnConnectTail(t) + logoutDone := make(chan struct{}) + c.badger.onPush = func() { + go func() { + defer close(logoutDone) + _ = c.h.Disconnect() + }() + select { + case <-logoutDone: + t.Error("logout finished during a badge push") + case <-time.After(100 * time.Millisecond): + } + } + require.ErrorIs(t, c.run(c.ctx), chat.ErrDuplicateConnection) + <-logoutDone + require.Equal(t, 1, c.badger.count()) +} + +// reinstall makes conn the current connection again, as connectNow would, +// so the next tail run is not short-circuited by the logout before it. No +// dial is involved, so no connection callback races this. +func (c *onConnectTailTest) reinstall(conn *rpc.Connection) { + c.h.connMutex.Lock() + defer c.h.connMutex.Unlock() + c.h.conn = conn + c.h.connCtx, c.h.connCancel = context.WithCancel(context.Background()) +} + +// OnConnect's tail, a logout and app state transitions all run under the +// connection gate. Racing them must not deadlock, and a logout must still +// leave gregor down. +func TestGregorOnConnectTailStress(t *testing.T) { + c := setupOnConnectTail(t) + // Swap in a connection that never dials. This test puts the current + // connection back after each logout, and a dialing one would reconnect + // behind it and outlive the test. + require.NoError(t, c.h.Disconnect()) + conn := &rpc.Connection{} + c.reinstall(conn) + c.h.connGate.start() + t.Cleanup(c.h.connGate.stop) + + stop := make(chan struct{}) + var tails, writers sync.WaitGroup + for range 2 { + tails.Add(1) + go func() { + defer tails.Done() + for { + select { + case <-stop: + return + default: + } + c.reinstall(conn) + // Another tail's logout can land before the ctx is derived. + if ctx, cancel, err := c.h.onConnectCtx(context.Background(), conn); err == nil { + _ = c.run(ctx) + cancel() + } + // A run queues at most one replay, and Init's replay thread + // is not running here to take it off. + select { + case <-c.h.replayCh: + default: + } + _ = c.h.Disconnect() + runtime.Gosched() + } + }() + } + for w := range 2 { + writers.Add(1) + go func() { + defer writers.Done() + rng := rand.New(rand.NewSource(int64(w))) + for range 500 { + c.h.G().MobileAppState.Update(allAppStates[rng.Intn(len(allAppStates))]) + if rng.Intn(4) == 0 { + time.Sleep(time.Duration(rng.Intn(200)) * time.Microsecond) + } + } + }() + } + + done := make(chan struct{}) + go func() { + writers.Wait() + close(stop) + tails.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(60 * time.Second): + // A deadlock leaves the racers holding the handler's locks, so the + // cleanup that shuts the handler down never returns. Nothing this + // test writes is flushed through that hung unwind, neither t.Fatal's + // message nor a panic's, so say it on stderr first; go test's own + // timeout then dumps every stack. + const msg = "deadlock: the connect tail, logouts and transitions did not finish" + fmt.Fprintln(os.Stderr, msg) + t.Fatal(msg) + } + + require.NoError(t, c.h.Disconnect()) + require.False(t, hasConn(c.h), "logout left a connection after settling") + c.h.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + c.h.connGate.reconcile(context.Background()) + require.False(t, hasConn(c.h), "reconnected after a logout") +} + +type failingRPCClient struct{} + +func (failingRPCClient) Call(context.Context, string, any, any, time.Duration) error { + return errors.New("no server") +} + +func (failingRPCClient) CallCompressed(context.Context, string, any, any, rpc.CompressionType, time.Duration) error { + return errors.New("no server") +} + +func (failingRPCClient) Notify(context.Context, string, any, time.Duration) error { + return errors.New("no server") +} + +// An OnConnect for a connection that is no longer current, because it shut +// down before OnConnect started or while it ran, or because a newer +// connection replaced it, installs no gregor client, leaves the chat syncer +// alone, and fails with an error the connection does not retry. The rpc +// library hands a replaced connection's OnConnect a live ctx. +func TestGregorOnConnectAfterShutdownInstallsNothing(t *testing.T) { + for _, tt := range []struct { + name string + // before runs before OnConnect, during inside it, after the + // connection check. + before, during func(t *testing.T, h *gregorHandler, uri *rpc.FMPURI) + }{ + {name: "before", before: func(t *testing.T, h *gregorHandler, _ *rpc.FMPURI) { + require.NoError(t, h.Disconnect()) + }}, + {name: "during", during: func(t *testing.T, h *gregorHandler, _ *rpc.FMPURI) { + require.NoError(t, h.Disconnect()) + }}, + {name: "replaced", before: func(t *testing.T, h *gregorHandler, uri *rpc.FMPURI) { + require.NoError(t, h.Disconnect()) + require.NoError(t, h.Connect(uri)) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + syncer := &fakeSyncer{} + g.Syncer = syncer + h := newGregorHandler(g) + uri := closedPortURI(t) + require.NoError(t, h.Connect(uri)) + defer h.Shutdown(context.Background()) + conn := currentConn(h) + + h.authParamsForTest = func(context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) { + if tt.during != nil { + tt.during(t, h, uri) + } + return gregor1.UID(make([]byte, 16)), gregor1.DeviceID(make([]byte, 16)), "", nil, nil + } + if tt.before != nil { + tt.before(t, h, uri) + } + + local, remote := net.Pipe() + defer remote.Close() + xp := rpc.NewTransport(local, libkb.NewRPCLogFactory(tc.G), tc.G.RemoteNetworkInstrumenterStorage, + libkb.MakeWrapError(tc.G), rpc.DefaultMaxFrameLength) + defer xp.Close() + srv := rpc.NewServer(xp, libkb.MakeWrapError(tc.G)) + + err := h.OnConnect(context.Background(), conn, failingRPCClient{}, srv) + require.ErrorIs(t, err, chat.ErrDuplicateConnection) + require.False(t, h.ShouldRetryOnConnect(err), "retrying a connection that is not current") + _, err = h.getGregorCli() + require.Error(t, err, "installed a client for a connection that is not current") + require.Zero(t, syncer.connectCalls(), "chat sync ran for a connection that is not current") + }) + } +} + +// syncAllRecorder fails every call, recording the host of each SyncAll. +type syncAllRecorder struct { + failingRPCClient + mu sync.Mutex + hosts []string +} + +func (r *syncAllRecorder) CallCompressed(_ context.Context, _ string, arg any, _ any, _ rpc.CompressionType, _ time.Duration) error { + if args, ok := arg.([]any); ok && len(args) == 1 { + if sa, ok := args[0].(chat1.SyncAllArg); ok { + r.mu.Lock() + r.hosts = append(r.hosts, sa.HostName) + r.mu.Unlock() + } + } + return errors.New("no server") +} + +// OnConnect sends the host of the uri the gate connected to. +func TestGregorOnConnectSyncAllHost(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = &fakeSyncer{} + h := newGregorHandler(g) + uri := closedPortURI(t) + require.NoError(t, h.Connect(uri)) + defer h.Shutdown(context.Background()) + h.authParamsForTest = func(context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) { + return gregor1.UID(make([]byte, 16)), gregor1.DeviceID(make([]byte, 16)), "", nil, nil + } + + local, remote := net.Pipe() + defer remote.Close() + xp := rpc.NewTransport(local, libkb.NewRPCLogFactory(tc.G), tc.G.RemoteNetworkInstrumenterStorage, + libkb.MakeWrapError(tc.G), rpc.DefaultMaxFrameLength) + defer xp.Close() + srv := rpc.NewServer(xp, libkb.MakeWrapError(tc.G)) + + rec := &syncAllRecorder{} + err := h.OnConnect(context.Background(), currentConn(h), rec, srv) + require.ErrorContains(t, err, "error running SyncAll") + rec.mu.Lock() + defer rec.mu.Unlock() + require.Equal(t, []string{uri.Host}, rec.hosts) +} diff --git a/go/service/gregor_test.go b/go/service/gregor_test.go index f90d63e17956..de23fe4e4893 100644 --- a/go/service/gregor_test.go +++ b/go/service/gregor_test.go @@ -725,7 +725,8 @@ func TestGregorBadgesIBM(t *testing.T) { // Set up client and server h, server, uid := setupSyncTests(t, g) defer h.Shutdown(context.Background()) - h.badger = badges.NewBadger(tc.G) + badger := badges.NewBadger(tc.G) + h.badger = badger t.Logf("client setup complete") t.Logf("server message") @@ -745,7 +746,7 @@ func TestGregorBadgesIBM(t *testing.T) { ri := func() chat1.RemoteInterface { return dummyRemoteClient{RemoteClient: chat1.RemoteClient{Cli: h.cli}} } - badgerResync(context.TODO(), t, h.badger, ri, h.gregorCli) + badgerResync(context.TODO(), t, badger, ri, h.gregorCli) listener.getBadgeState(t) // skip one since resync sends 2 bs := listener.getBadgeState(t) @@ -760,7 +761,7 @@ func TestGregorBadgesIBM(t *testing.T) { require.NoError(t, err) t.Logf("client sync complete") - badgerResync(context.TODO(), t, h.badger, ri, h.gregorCli) + badgerResync(context.TODO(), t, badger, ri, h.gregorCli) bs = listener.getBadgeState(t) require.Equal(t, 1, bs.NewTlfs, "no more badges") @@ -776,7 +777,8 @@ func TestGregorTeamBadges(t *testing.T) { // Set up client and server h, server, uid := setupSyncTests(t, g) defer h.Shutdown(context.Background()) - h.badger = badges.NewBadger(tc.G) + badger := badges.NewBadger(tc.G) + h.badger = badger t.Logf("client setup complete") t.Logf("server message") @@ -798,7 +800,7 @@ func TestGregorTeamBadges(t *testing.T) { ri := func() chat1.RemoteInterface { return dummyRemoteClient{RemoteClient: chat1.RemoteClient{Cli: h.cli}} } - badgerResync(context.TODO(), t, h.badger, ri, h.gregorCli) + badgerResync(context.TODO(), t, badger, ri, h.gregorCli) listener.getBadgeState(t) // skip one since resync sends 2 bs := listener.getBadgeState(t) @@ -823,18 +825,19 @@ func TestGregorBadgesOOBM(t *testing.T) { // Set up client and server h, _, _ := setupSyncTests(t, g) defer h.Shutdown(context.Background()) - h.badger = badges.NewBadger(tc.G) + badger := badges.NewBadger(tc.G) + h.badger = badger t.Logf("client setup complete") t.Logf("sending first chat update") - h.badger.PushChatUpdate(context.TODO(), chat1.UnreadUpdate{ + badger.PushChatUpdate(context.TODO(), chat1.UnreadUpdate{ ConvID: chat1.ConversationID(`a`), UnreadMessages: 2, }, 0) _ = listener.getBadgeState(t) t.Logf("sending second chat update") - h.badger.PushChatUpdate(context.TODO(), chat1.UnreadUpdate{ + badger.PushChatUpdate(context.TODO(), chat1.UnreadUpdate{ ConvID: chat1.ConversationID(`b`), UnreadMessages: 2, }, 1) @@ -845,7 +848,7 @@ func TestGregorBadgesOOBM(t *testing.T) { t.Logf("resyncing") // Instead of calling badger.Resync, reach in and twiddle the knobs. - h.badger.State().UpdateWithChatFull(context.TODO(), chat1.UnreadUpdateFull{ + badger.State().UpdateWithChatFull(context.TODO(), chat1.UnreadUpdateFull{ InboxVers: chat1.InboxVers(4), Updates: []chat1.UnreadUpdate{ {ConvID: chat1.ConversationID(`b`), UnreadMessages: 0}, @@ -853,14 +856,14 @@ func TestGregorBadgesOOBM(t *testing.T) { }, InboxSyncStatus: chat1.SyncInboxResType_CLEAR, }, false) - err := h.badger.Send(context.TODO()) + err := badger.Send(context.TODO()) require.NoError(t, err) bs = listener.getBadgeState(t) require.Equal(t, 1, badgeStateStats(bs).UnreadChatConversations, "unread chat convs") require.Equal(t, 3, badgeStateStats(bs).UnreadChatMessages, "unread chat messages") t.Logf("clearing") - h.badger.Clear(context.TODO()) + badger.Clear(context.TODO()) bs = listener.getBadgeState(t) require.Equal(t, 0, badgeStateStats(bs).UnreadChatConversations, "unread chat convs") require.Equal(t, 0, badgeStateStats(bs).UnreadChatMessages, "unread chat messages") diff --git a/go/service/main.go b/go/service/main.go index e780fb65be58..63e04d5a3a36 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -119,7 +119,6 @@ func NewService(g *libkb.GlobalContext, isDaemon bool) *Service { teamUpgrader: teams.NewUpgrader(), walletState: stellar.NewWalletState(g, remote.NewRemoteNet(g)), offlineRPCCache: offline.NewRPCCache(g), - httpSrv: manager.NewSrv(g), initialLoginAttemptDone: make(chan struct{}), } @@ -233,6 +232,8 @@ func (d *Service) Handle(c net.Conn) { } if err := d.RegisterProtocols(server, xp, connID, logReg); err != nil { d.G().Log.Warning("RegisterProtocols error: %s", err) + // frees the connection's slot and its notification sender + cl <- err return } @@ -331,6 +332,12 @@ func (d *Service) Run() (err error) { d.SetupChatModules(nil) + // Before the listen loop on purpose: this runs the startup login attempt, so a + // client that connects once we are listening finds it already settled and its + // first clientState carries a session rather than "not known yet". Mobile + // cannot do this -- go/bind/keybase.go runs the attempt off the Init thread, + // after the loopback listener -- so a clientState says so explicitly, and + // another follows once the attempt settles. d.RunBackgroundOperations(uir) // At this point initialization is complete, and we're about to start the @@ -349,6 +356,12 @@ func (d *Service) Run() (err error) { func (d *Service) SetupCriticalSubServices() error { allG := globals.NewContext(d.G(), d.ChatG()) mctx := d.MetaContext(context.TODO()) + // Not in NewService: the service sets up NotifyRouter after that, and the + // server reads it once, when created. A standalone client never sets one + // up, so both see a nil router, which announces nothing -- and nothing + // subscribes to it anyway. + d.httpSrv = manager.NewSrv(d.G()) + d.G().NotifyRouter.SetClientStateReader(d.readClientState) d.G().RuntimeStats = runtimestats.NewRunner(allG) teams.ServiceInit(d.G()) stellar.ServiceInit(d.G(), d.walletState, d.badger) @@ -1032,7 +1045,7 @@ func (d *Service) OnLogout(m libkb.MetaContext) (err error) { log("shutting down gregor") if d.gregor != nil { - _ = d.gregor.Reset() + _ = d.gregor.Disconnect() } log("shutting down rekeyMaster") @@ -1071,16 +1084,9 @@ func (d *Service) gregordConnect() (err error) { } d.G().Log.Debug("| gregor URI: %s", uri) - // If we are already connected, then shutdown and reset the gregor - // handler - if d.gregor.IsConnected() { - if err := d.gregor.Reset(); err != nil { - return err - } - } - - // Connect to gregord - return d.gregor.Connect(uri) + // Reset a live connection so it authenticates again. Nothing connects + // while the app is in BACKGROUND or the desktop is suspended. + return d.gregor.ConnectFresh(uri) } // ReleaseLock releases the locking pidfile by closing, unlocking and @@ -1393,12 +1399,19 @@ func (d *Service) configurePath() { } } -// tryLogin runs LoginOffline which will load the local session file and unlock the -// local device keys without making any network requests. -// -// If that fails for any reason, LoginProvisionedDevice is used, which should get -// around any issue where the session.json file is out of date or missing since the -// last time the service started. +// initialLoginAttemptSettled reports whether the first startup login attempt has +// finished, without waiting for it. A caller that must not block uses this to say +// "I do not know yet" instead of reporting a logged-out session that no attempt +// has been made for. +func (d *Service) initialLoginAttemptSettled() bool { + select { + case <-d.initialLoginAttemptDone: + return true + default: + return false + } +} + // awaitInitialLoginAttempt blocks until the first startup login attempt has // finished (however it went), the context is done, or maxWait elapses. Used // by RPCs whose answer depends on login state so they don't race the login @@ -1413,10 +1426,41 @@ func (d *Service) awaitInitialLoginAttempt(m libkb.MetaContext, maxWait time.Dur } } +// settleInitialLoginAttempt marks the first startup login attempt finished and +// then sends connected clients a clientState, which now carries the session. +func (d *Service) settleInitialLoginAttempt(ctx context.Context) { + d.initialLoginAttemptOnce.Do(func() { + close(d.initialLoginAttemptDone) + d.G().NotifyRouter.AnnounceClientState(ctx) + }) +} + +// readClientState reads what a clientState notification carries. The session is +// left out until the startup login attempt has settled: before that there is no +// session to describe, and reporting a logged-out one would be a lie. The +// attempt settling queues another clientState, which carries it. +func (d *Service) readClientState(ctx context.Context) keybase1.ClientState { + res := keybase1.ClientState{AppState: d.G().MobileAppState.State()} + if d.initialLoginAttemptSettled() { + session, _ := engine.SessionState(libkb.NewMetaContext(ctx, d.G())) + res.Session = &session + } + if info, err := d.httpSrv.Info(); err == nil { + res.HttpSrvInfo = &info + } + return res +} + +// tryLogin runs LoginOffline which will load the local session file and unlock the +// local device keys without making any network requests. +// +// If that fails for any reason, LoginProvisionedDevice is used, which should get +// around any issue where the session.json file is out of date or missing since the +// last time the service started. func (d *Service) tryLogin(ctx context.Context, mode libkb.LoginAttempt) { if mode != libkb.LoginAttemptNone { // Signal on every exit path; sync.Once makes repeat calls no-ops. - defer d.initialLoginAttemptOnce.Do(func() { close(d.initialLoginAttemptDone) }) + defer d.settleInitialLoginAttempt(ctx) } d.loginAttemptMu.Lock() diff --git a/go/service/notify.go b/go/service/notify.go index c145f378da9f..085f8fc1d60d 100644 --- a/go/service/notify.go +++ b/go/service/notify.go @@ -28,6 +28,9 @@ func NewNotifyCtlHandler(xp rpc.Transporter, id libkb.ConnectionID, g *libkb.Glo } } +// SetNotifications registers the channels. A connection that registers app +// notifications then gets a clientState, ahead of every change announced after +// this returns; see libkb.connSender. func (h *NotifyCtlHandler) SetNotifications(_ context.Context, n keybase1.NotificationChannels) error { h.G().NotifyRouter.SetChannels(h.id, n) return nil diff --git a/go/service/notify_test.go b/go/service/notify_test.go new file mode 100644 index 000000000000..cbaf97281553 --- /dev/null +++ b/go/service/notify_test.go @@ -0,0 +1,268 @@ +package service + +import ( + "context" + "runtime" + "sync" + "testing" + + "github.com/keybase/client/go/kbhttp" + "github.com/keybase/client/go/kbhttp/manager" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + keybase1 "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// newTestClientStateService sets up what SetupCriticalSubServices does for +// clientState: the http server and the reader. +func newTestClientStateService(t *testing.T, g *libkb.GlobalContext) *Service { + t.Helper() + svc := NewService(g, false) + svc.httpSrv = manager.NewSrv(g) + g.NotifyRouter.SetClientStateReader(svc.readClientState) + return svc +} + +var allClientStateChannels = keybase1.NotificationChannels{App: true, Session: true, Service: true} + +const ( + methodClientState = "keybase.1.NotifyApp.clientState" + methodAppState = "keybase.1.NotifyApp.mobileAppStateChanged" + methodHTTPSrvInfo = "keybase.1.NotifyService.HTTPSrvInfoUpdate" + methodLoggedIn = "keybase.1.NotifySession.loggedIn" +) + +func decodeClientState(t *testing.T, m libkb.RecordedNotify) keybase1.ClientState { + t.Helper() + require.Equal(t, methodClientState, m.Method) + var arg keybase1.ClientStateArg + require.NoError(t, m.Decode(&arg)) + return arg.State +} + +func clientStatesOf(t *testing.T, msgs []libkb.RecordedNotify) (ret []keybase1.ClientState) { + t.Helper() + for _, m := range msgs { + if m.Method == methodClientState { + ret = append(ret, decodeClientState(t, m)) + } + } + return ret +} + +// testLoginWrite makes the session valid the way a login does before it +// announces itself. +func testLoginWrite(t *testing.T, tc libkb.TestContext, name string) { + t.Helper() + sig, err := libkb.GenerateNaclSigningKeyPair() + require.NoError(t, err) + enc, err := libkb.GenerateNaclDHKeyPair() + require.NoError(t, err) + deviceID, err := libkb.NewDeviceID() + require.NoError(t, err) + uv := keybase1.UserVersion{Uid: libkb.UsernameToUID(name), EldestSeqno: 1} + require.NoError(t, libkb.NewMetaContextForTest(tc).SwitchUserNewConfigActiveDevice(uv, + libkb.NewNormalizedUsername(name), nil, deviceID, sig, enc, "testdevice", libkb.KeychainModeNone)) +} + +// A client applies what it gets in arrival order, so the state as of +// subscribing has to arrive before any change announced after it. +func TestSnapshotIsFirstOnConnection(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + svc := newTestClientStateService(t, g) + svc.settleInitialLoginAttempt(context.Background()) + + rec := libkb.NewNotifyRecorder(g, allClientStateChannels) + defer rec.Close() + g.NotifyRouter.HandleLogout(context.Background()) + g.MobileLifecycle.UIInactive() + rec.Flush() + + msgs := rec.Messages() + require.NotEmpty(t, msgs) + first := decodeClientState(t, msgs[0]) + require.NotNil(t, first.Session) + info, err := svc.httpSrv.Info() + require.NoError(t, err) + require.Equal(t, &info, first.HttpSrvInfo) + require.Greater(t, len(msgs), 1, "the changes after it arrive after it") +} + +// Each field has one writer, which queues its notification in write order, and +// a clientState reads every field when it is sent, so whatever interleaving the +// writers and the clientStates take, the last value a connection receives for a +// field is the field's current value. +func TestLastMessagePerFieldIsLatest(t *testing.T) { + // Two Ps: the writers still run in parallel, but goroutines started in a row + // no longer reliably run in the order they were started, which is what a + // fan-out of one goroutine per message gets wrong. With one P per core that + // fan-out passes this test almost every time. + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(2)) + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + svc := NewService(g, false) + // A fresh port on every bind, unlike the service's pinned one, so each + // rebind is an address change the server announces. + srv, err := manager.New("Srv", g.GetLog(), g.MobileAppState.State, g.MobileAppState.NextUpdate, + func() kbhttp.ListenerSource { return kbhttp.NewAutoPortListenerSource() }, true, + g.NotifyRouter.HandleHTTPSrvInfoUpdate) + require.NoError(t, err) + svc.httpSrv = srv + g.NotifyRouter.SetClientStateReader(svc.readClientState) + svc.settleInitialLoginAttempt(context.Background()) + + rec := libkb.NewNotifyRecorder(g, allClientStateChannels) + defer rec.Close() + + ctx := context.Background() + var wg sync.WaitGroup + // 50 app state updates from 5 goroutines. Each move from BACKGROUND to the + // foreground rebinds the http server, which is the http address's writer. + for i := range 5 { + wg.Add(1) + go func() { + defer wg.Done() + for j := range 10 { + switch (i + j) % 3 { + case 0: + g.MobileLifecycle.UIActive() + case 1: + g.MobileLifecycle.UIInactive() + default: + lifecycletest.ToBackground(g.MobileLifecycle) + } + } + }() + } + // clientStates interleaved with all of it + wg.Add(1) + go func() { + defer wg.Done() + for range 20 { + g.NotifyRouter.AnnounceClientState(ctx) + } + }() + wg.Wait() + // Stops the http server's writer for good: nothing moves the address after this. + svc.httpSrv.Shutdown() + rec.Flush() + + var lastAppState *keybase1.MobileAppState + var lastHTTP *keybase1.HttpSrvInfo + var appStateChanges []keybase1.MobileAppState + var httpChanges []keybase1.HttpSrvInfo + for _, m := range rec.Messages() { + switch m.Method { + case methodClientState: + state := decodeClientState(t, m) + lastAppState = &state.AppState + lastHTTP = state.HttpSrvInfo + case methodAppState: + var arg keybase1.MobileAppStateChangedArg + require.NoError(t, m.Decode(&arg)) + lastAppState = &arg.State + appStateChanges = append(appStateChanges, arg.State) + case methodHTTPSrvInfo: + var arg keybase1.HTTPSrvInfoUpdateArg + require.NoError(t, m.Decode(&arg)) + lastHTTP = &arg.Info + httpChanges = append(httpChanges, arg.Info) + } + } + // Each writer announces only a change, so in write order no two of its + // notifications in a row carry the same value. + for i := 1; i < len(appStateChanges); i++ { + require.NotEqual(t, appStateChanges[i-1], appStateChanges[i], "app state notification %d", i) + } + for i := 1; i < len(httpChanges); i++ { + require.NotEqual(t, httpChanges[i-1], httpChanges[i], "http notification %d", i) + } + require.NotNil(t, lastAppState) + require.Equal(t, g.MobileAppState.State(), *lastAppState) + info, err := svc.httpSrv.Info() + require.NoError(t, err) + require.NotNil(t, lastHTTP) + require.Equal(t, info, *lastHTTP) + require.NotEmpty(t, httpChanges, "the http server rebound while connected") +} + +// The identity comes from clientState alone, so a completed login is followed +// by one that carries it. +func TestSessionChangeFollowedBySnapshot(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + svc := newTestClientStateService(t, g) + svc.settleInitialLoginAttempt(context.Background()) + + rec := libkb.NewNotifyRecorder(g, allClientStateChannels) + defer rec.Close() + rec.Flush() + before := len(rec.Messages()) + + testLoginWrite(t, tc, "testuser") + g.NotifyRouter.SendLogin(context.Background(), "testuser", false) + rec.Flush() + + after := rec.Messages()[before:] + require.Len(t, after, 2) + require.Equal(t, methodLoggedIn, after[0].Method) + state := decodeClientState(t, after[1]) + require.NotNil(t, state.Session) + require.True(t, state.Session.LoggedIn) + require.Equal(t, "testuser", state.Session.Username) +} + +// Before the startup login attempt settles there is no session to describe -- +// not a logged-out one -- so the clientState says nothing about it, and the +// attempt settling sends one that does. +func TestNullSessionUntilLoginSettles(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + svc := newTestClientStateService(t, g) + + rec := libkb.NewNotifyRecorder(g, allClientStateChannels) + defer rec.Close() + rec.Flush() + states := clientStatesOf(t, rec.Messages()) + require.Len(t, states, 1) + require.Nil(t, states[0].Session, "the startup login attempt has not run") + + svc.settleInitialLoginAttempt(context.Background()) + rec.Flush() + states = clientStatesOf(t, rec.Messages()) + require.Len(t, states, 2) + require.NotNil(t, states[1].Session, "the attempt settled, so there is a session to report") + require.False(t, states[1].Session.LoggedIn, "logged out in a fresh test context") +} + +// SetNotifications is what registers the channels, and a connection that +// registers app notifications gets its clientState from it. +func TestSetNotificationsQueuesClientState(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + newTestClientStateService(t, g) + + rec := libkb.NewNotifyRecorder(g, keybase1.NotificationChannels{}) + defer rec.Close() + h := NewNotifyCtlHandler(nil, rec.ID, g) + require.NoError(t, h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true})) + require.True(t, g.NotifyRouter.GetChannels(rec.ID).Session) + rec.Flush() + require.Empty(t, rec.Messages(), "clientState rides NotifyApp, which this client did not register") + + require.NoError(t, h.SetNotifications(context.Background(), allClientStateChannels)) + rec.Flush() + require.Len(t, clientStatesOf(t, rec.Messages()), 1) +} diff --git a/go/service/rpc.go b/go/service/rpc.go index 15c9994e64db..d0c9838d6c89 100644 --- a/go/service/rpc.go +++ b/go/service/rpc.go @@ -3,6 +3,7 @@ package service import ( "context" "net" + "sync" "github.com/keybase/client/go/libkb" "github.com/keybase/go-framed-msgpack-rpc/rpc" @@ -11,7 +12,11 @@ import ( // connTransport implements rpc.ConnectionTransport type connTransport struct { libkb.Contextified - host string + host string + + // mu guards the fields below: the connection dials on its own goroutine + // while Shutdown closes the transport. + mu sync.Mutex conn net.Conn transport rpc.Transporter stagedTransport rpc.Transporter @@ -27,44 +32,60 @@ func newConnTransport(g *libkb.GlobalContext, host string) *connTransport { } func (t *connTransport) Dial(context.Context) (rpc.Transporter, error) { - var err error - t.conn, err = libkb.ProxyDial(t.G().Env, "tcp", t.host) + conn, err := libkb.ProxyDial(t.G().Env, "tcp", t.host) if err != nil { return nil, err } - t.stagedTransport = rpc.NewTransport(t.conn, libkb.NewRPCLogFactory(t.G()), + transport := rpc.NewTransport(conn, libkb.NewRPCLogFactory(t.G()), t.G().RemoteNetworkInstrumenterStorage, libkb.MakeWrapError(t.G()), rpc.DefaultMaxFrameLength) - return t.stagedTransport, nil + t.mu.Lock() + defer t.mu.Unlock() + t.conn = conn + t.stagedTransport = transport + return transport, nil } func (t *connTransport) IsConnected() bool { - return t.transport != nil && t.transport.IsConnected() + t.mu.Lock() + transport := t.transport + t.mu.Unlock() + return transport != nil && transport.IsConnected() } +// Finalize and Close close transports outside mu, because closing blocks until +// the transport's loops stop and IsConnected should not wait on that. func (t *connTransport) Finalize() { - if t.transport != nil { - t.transport.Close() - } + t.mu.Lock() + old := t.transport t.transport = t.stagedTransport t.stagedTransport = nil + t.mu.Unlock() + if old != nil { + old.Close() + } } func (t *connTransport) Close() { - if t.conn != nil { - t.conn.Close() + t.mu.Lock() + conn, transport, staged := t.conn, t.transport, t.stagedTransport + t.transport = nil + t.stagedTransport = nil + t.mu.Unlock() + if conn != nil { + conn.Close() } - if t.transport != nil { - t.transport.Close() + if transport != nil { + transport.Close() } - t.transport = nil - if t.stagedTransport != nil { - t.stagedTransport.Close() + if staged != nil { + staged.Close() } - t.stagedTransport = nil } func (t *connTransport) Reset() { + t.mu.Lock() + defer t.mu.Unlock() t.transport = nil t.stagedTransport = nil } diff --git a/protocol/avdl/keybase1/appstate.avdl b/protocol/avdl/keybase1/appstate.avdl index 8cc21333147d..d6062abad1af 100644 --- a/protocol/avdl/keybase1/appstate.avdl +++ b/protocol/avdl/keybase1/appstate.avdl @@ -17,10 +17,39 @@ protocol appState { NOTAVAILABLE_4 } + // Where a tapped notification opens. The service resolves this from the push + // payload native hands it, so no client parses a push payload. + record PushTapRoute { + // A keybase:// URL. + string url; + // The account the notification belongs to, or empty. Only a route resolved + // from a real notification tap can name one, which is what keeps a link + // opened by another app from switching accounts. + string targetUID; + // Identifies this tap, so an ack cannot clear a newer one. Rises with each + // tap and is meaningless across a restart of the service. + int id; + } + // gui -> service // mobile only void updateMobileNetState(string state); + // gui -> service + // mobile only + // Returns the route a tapped notification resolved to, or null when no tap is + // waiting. Reading does NOT clear it: a reply lost on the way to the client + // would take the tap with it, and the client is what knows whether it acted. + // The route stays armed until ackPushTapRoute. + union { null, PushTapRoute } peekPushTapRoute(); + + // gui -> service + // mobile only + // Says the client has acted on the route with this id, which clears it. A + // stale id -- the tap was replaced by a newer one while this was in flight -- + // clears nothing. + void ackPushTapRoute(int id); + // gui -> service // desktop only // https://electronjs.org/docs/api/power-monitor diff --git a/protocol/avdl/keybase1/notify_app.avdl b/protocol/avdl/keybase1/notify_app.avdl index 31dfac72e695..04668d8fbd63 100644 --- a/protocol/avdl/keybase1/notify_app.avdl +++ b/protocol/avdl/keybase1/notify_app.avdl @@ -1,7 +1,31 @@ @namespace("keybase.1") protocol NotifyApp { + import idl "common.avdl"; + import idl "appstate.avdl"; + import idl "notify_ctl.avdl"; void exit() oneway; + // The app's lifecycle state changed. The service derives it from the UI + // reports native makes, so this and clientState's appState are the only + // places 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; + + // The session, the http server address and the app state, read by the + // service when this is sent. Sent first on subscribing, after every session + // change and once the startup login attempt settles. It rides the same + // ordered stream as the notifications above, so the latest one a client + // received is never older than any of them. + void clientState(ClientState state) oneway; + + // A notification tap resolved to a route and it is waiting to be acted on. A + // nudge, not a delivery: the route rides peekPushTapRoute's reply, so the + // reader is the same one whether the tap happened before this client existed + // or while it was connected. Carries nothing for that reason -- acting on + // this rather than on what the peek reports would be a second delivery path, + // and the two could then act on one tap twice. + void pushTapRouteAvailable() oneway; + } diff --git a/protocol/avdl/keybase1/notify_ctl.avdl b/protocol/avdl/keybase1/notify_ctl.avdl index 77d1849a29c0..9b6bba782c9c 100644 --- a/protocol/avdl/keybase1/notify_ctl.avdl +++ b/protocol/avdl/keybase1/notify_ctl.avdl @@ -3,6 +3,8 @@ protocol notifyCtl { import idl "common.avdl"; + import idl "notify_service.avdl"; + import idl "appstate.avdl"; record NotificationChannels { boolean session; @@ -45,5 +47,35 @@ protocol notifyCtl { boolean devicehistory; } + record ClientSession { + boolean loggedIn; + UID uid; + string username; + DeviceID deviceID; + string deviceName; + } + + // ClientState is the state a client needs before it can render anything. It + // arrives as the clientState notification, on the same ordered per-connection + // stream as the notifications that change it, so a client applies everything + // in arrival order. It carries only what is available with nothing to wait + // on; the slower derived fields stay on getBootstrapStatus. + record ClientState { + // Null until the service's own startup login attempt has settled, because + // until then there is no session to describe -- not a logged-out one. A + // client keeps waiting on a null: the service sends another clientState + // once the attempt settles. + union { null, ClientSession } session; + union { null, HttpSrvInfo } httpSrvInfo; + // The app's lifecycle state, derived here from the UI reports native makes. + // A client never derives it for itself: on iOS it would have to read a + // different OS notification stream than the one this is derived from, with + // no ordering between the two. On a platform with no lifecycle to report + // this is a constant FOREGROUND and means nothing. + MobileAppState appState; + } + + // Registers the channels. A connection subscribed to app notifications then + // gets a clientState first, ahead of any change announced after it. void setNotifications(NotificationChannels channels); } diff --git a/protocol/avdl/keybase1/notify_session.avdl b/protocol/avdl/keybase1/notify_session.avdl index e9bc17cc3dfa..814dfa705db8 100644 --- a/protocol/avdl/keybase1/notify_session.avdl +++ b/protocol/avdl/keybase1/notify_session.avdl @@ -1,6 +1,7 @@ @namespace("keybase.1") protocol NotifySession { + import idl "common.avdl"; @notify("") void loggedOut(); diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index 664a2f886ccb..9bba7f004b96 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -151,7 +151,10 @@ "chat.1.local.updateTyping": {"promise":true}, "chat.1.local.updateUnsentText": {"promise":true}, "chat.1.local.userEmojis": {"promise":true}, + "keybase.1.NotifyApp.clientState": {"incoming":true}, "keybase.1.NotifyApp.exit": {"custom":true}, + "keybase.1.NotifyApp.mobileAppStateChanged": {"incoming":true}, + "keybase.1.NotifyApp.pushTapRouteAvailable": {"incoming":true}, "keybase.1.NotifyAudit.boxAuditError": {"incoming":true}, "keybase.1.NotifyAudit.rootAuditError": {"incoming":true}, "keybase.1.NotifyBadges.badgeState": {"incoming":true}, @@ -251,6 +254,8 @@ "keybase.1.apiserver.Post": {"promise":true}, "keybase.1.apiserver.PostJSON": {"promise":true}, "keybase.1.appState.powerMonitorEvent": {"promise":true}, + "keybase.1.appState.ackPushTapRoute": {"promise":true}, + "keybase.1.appState.peekPushTapRoute": {"promise":true}, "keybase.1.appState.updateMobileNetState": {"promise":true}, "keybase.1.config.appendGUILogs": {"promise":true}, "keybase.1.config.generateWebAuthToken": {"promise":true}, @@ -389,8 +394,6 @@ "keybase.1.provisionUi.chooseGPGMethod": {"custom":true}, "keybase.1.provisionUi.switchToGPGSignOK": {"custom":true}, "keybase.1.reachability.checkReachability": {"promise":true}, - "keybase.1.reachability.reachabilityChanged": {"incoming":true}, - "keybase.1.reachability.startReachability": {"promise":true}, "keybase.1.rekey.getRevokeWarning": {"promise":true}, "keybase.1.rekey.rekeyStatusFinish": {"promise":true}, "keybase.1.rekey.showPendingRekeyStatus": {"promise":true}, diff --git a/protocol/json/keybase1/appstate.json b/protocol/json/keybase1/appstate.json index 4d096179e475..94a212f08962 100644 --- a/protocol/json/keybase1/appstate.json +++ b/protocol/json/keybase1/appstate.json @@ -22,6 +22,24 @@ "UNKNOWN_3", "NOTAVAILABLE_4" ] + }, + { + "type": "record", + "name": "PushTapRoute", + "fields": [ + { + "type": "string", + "name": "url" + }, + { + "type": "string", + "name": "targetUID" + }, + { + "type": "int", + "name": "id" + } + ] } ], "messages": { @@ -34,6 +52,22 @@ ], "response": null }, + "peekPushTapRoute": { + "request": [], + "response": [ + null, + "PushTapRoute" + ] + }, + "ackPushTapRoute": { + "request": [ + { + "name": "id", + "type": "int" + } + ], + "response": null + }, "powerMonitorEvent": { "request": [ { diff --git a/protocol/json/keybase1/notify_app.json b/protocol/json/keybase1/notify_app.json index c1a6ab87bd48..40184b5e862c 100644 --- a/protocol/json/keybase1/notify_app.json +++ b/protocol/json/keybase1/notify_app.json @@ -1,12 +1,50 @@ { "protocol": "NotifyApp", - "imports": [], + "imports": [ + { + "path": "common.avdl", + "type": "idl" + }, + { + "path": "appstate.avdl", + "type": "idl" + }, + { + "path": "notify_ctl.avdl", + "type": "idl" + } + ], "types": [], "messages": { "exit": { "request": [], "response": null, "oneway": true + }, + "mobileAppStateChanged": { + "request": [ + { + "name": "state", + "type": "MobileAppState" + } + ], + "response": null, + "oneway": true + }, + "clientState": { + "request": [ + { + "name": "state", + "type": "ClientState" + } + ], + "response": null, + "oneway": true + }, + "pushTapRouteAvailable": { + "request": [], + "response": null, + "oneway": true } }, "namespace": "keybase.1" diff --git a/protocol/json/keybase1/notify_ctl.json b/protocol/json/keybase1/notify_ctl.json index 20bdedc83795..af0337f1c806 100644 --- a/protocol/json/keybase1/notify_ctl.json +++ b/protocol/json/keybase1/notify_ctl.json @@ -4,6 +4,14 @@ { "path": "common.avdl", "type": "idl" + }, + { + "path": "notify_service.avdl", + "type": "idl" + }, + { + "path": "appstate.avdl", + "type": "idl" } ], "types": [ @@ -152,6 +160,56 @@ "name": "devicehistory" } ] + }, + { + "type": "record", + "name": "ClientSession", + "fields": [ + { + "type": "boolean", + "name": "loggedIn" + }, + { + "type": "UID", + "name": "uid" + }, + { + "type": "string", + "name": "username" + }, + { + "type": "DeviceID", + "name": "deviceID" + }, + { + "type": "string", + "name": "deviceName" + } + ] + }, + { + "type": "record", + "name": "ClientState", + "fields": [ + { + "type": [ + null, + "ClientSession" + ], + "name": "session" + }, + { + "type": [ + null, + "HttpSrvInfo" + ], + "name": "httpSrvInfo" + }, + { + "type": "MobileAppState", + "name": "appState" + } + ] } ], "messages": { diff --git a/protocol/json/keybase1/notify_session.json b/protocol/json/keybase1/notify_session.json index afd0e01b1cfa..a571d0d2245c 100644 --- a/protocol/json/keybase1/notify_session.json +++ b/protocol/json/keybase1/notify_session.json @@ -1,6 +1,11 @@ { "protocol": "NotifySession", - "imports": [], + "imports": [ + { + "path": "common.avdl", + "type": "idl" + } + ], "types": [], "messages": { "loggedOut": { diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 3e4c2fe9caa6..c65813474fc7 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -7,7 +7,6 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build -import android.os.Bundle import android.os.Environment import android.provider.Settings import android.text.format.DateFormat @@ -376,37 +375,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // Android manages badge counts automatically via notification channels. } - @ReactMethod - override fun getInitialNotification(promise: Promise) { - // Clear on read so it behaves as a one-shot, matching iOS. - val bundle = KbModule.initialNotificationBundle - KbModule.initialNotificationBundle = null - if (bundle != null) { - try { - @Suppress("UNCHECKED_CAST") - val payload: WritableMap = Arguments.fromBundle(bundle) as WritableMap - promise.resolve(payload) - } catch (e: Exception) { - promise.resolve(null) - } - } else { - promise.resolve(null) - } - } - - private fun emitPushNotificationInternal(notification: Bundle) { - if (reactContext.hasActiveReactInstance() && canEmit()) { - try { - val payload = Arguments.fromBundle(notification) - emitOnPushNotification(payload) - } catch (e: Exception) { - NativeLogger.error("emitPushNotificationInternal failed to emit: " + e.message) - } - } else { - NativeLogger.warn("emitPushNotificationInternal no active react instance") - } - } - internal fun emitShareDataInternal(data: WritableMap) { if (reactContext.hasActiveReactInstance() && canEmit()) { try { @@ -467,18 +435,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // chance of being delivered before committing to it. internal fun canDeliverReset(): Boolean = reactContext.hasActiveReactInstance() && canEmit() - // No current caller (kept for future use). - @ReactMethod - override fun engineReset() { - try { - Keybase.reset() - nativeResetRecv() - relayReset() - } catch (e: Exception) { - NativeLogger.error("Exception in engineReset", e) - } - } - @ReactMethod override fun notifyJSReady() { NativeLogger.info("JS signaled ready, starting ReadFromKBLib loop") @@ -780,35 +736,11 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // visibility guarantee so the reader never sees a stale instance. @Volatile var instance: KbModule? = null - @JvmStatic - internal var initialNotificationBundle: Bundle? = null - @JvmStatic fun keyPressed(keyName: String) { instance?.sendHardwareKeyEvent(keyName) } - @JvmStatic - fun setInitialNotification(bundle: Bundle?) { - initialNotificationBundle = bundle - } - - @JvmStatic - fun isReactNativeRunning(): Boolean { - return instance != null - } - - @JvmStatic - fun emitPushNotification(notification: Bundle) { - val module = instance - if (module == null) { - // NativeLogger writes to the Go service, which may not be up here. - android.util.Log.w("KbModule", "emitPushNotification called but instance is null (app may not be running)") - return - } - module.emitPushNotificationInternal(notification) - } - @JvmStatic fun emitShareData(data: WritableMap) { val module = instance diff --git a/rnmodules/react-native-kb/ios/Kb.h b/rnmodules/react-native-kb/ios/Kb.h index 929434082d22..aec81655919a 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -22,8 +22,3 @@ // Push notification helpers - can be called from AppDelegate FOUNDATION_EXPORT void KbSetDeviceToken(NSString *token); -FOUNDATION_EXPORT void KbSetInitialNotification(NSDictionary *notification); -FOUNDATION_EXPORT void KbEmitPushNotification(NSDictionary *notification); -// Re-emits a stored user-interaction notification once when the app becomes -// active (covers notification taps that arrive before React Native is ready). -FOUNDATION_EXPORT void KbEmitStoredNotificationOnBecomeActive(void); diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 48ad75ca8097..76231a5e19df 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -49,7 +49,6 @@ + (id)sharedFsPathsHolder { static std::mutex kbSharedInstanceMutex; static BOOL kbPasteImageEnabled = NO; static NSString *kbStoredDeviceToken = nil; -static NSDictionary *kbInitialNotification = nil; // The bridge is created on the JS thread and consumed by the reader thread, // so every access goes through this lock — a plain shared_ptr member would be @@ -496,21 +495,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime RCT_EXPORT_METHOD(shareListenersRegistered) { } -// No current caller (kept for future use). -RCT_EXPORT_METHOD(engineReset) { - NSError *error = nil; - KeybaseReset(&error); - if (auto bridge = kbGetBridge()) { - bridge->resetRecv(); - } - if ([self canEmit]) { - [self emitOnMetaEvent:metaEventEngineReset]; - } - if (error) { - NSLog(@"Error in reset: %@", error); - } -} - RCT_EXPORT_METHOD(notifyJSReady) { // KeybaseNotifyJSReady is a sync.Once on the Go side, so repeat calls after // a reload are free. It must not run on the JS thread — do it on the reader @@ -798,16 +782,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime }); } -RCT_EXPORT_METHOD(getInitialNotification: (RCTPromiseResolveBlock)resolve reject: (RCTPromiseRejectBlock)reject) { - if (kbInitialNotification) { - NSDictionary *notification = kbInitialNotification; - kbInitialNotification = nil; - resolve(notification); - } else { - resolve([NSNull null]); - } -} - RCT_EXPORT_METHOD(removeAllPendingNotificationRequests) { UNUserNotificationCenter *current = UNUserNotificationCenter.currentNotificationCenter; [current removeAllPendingNotificationRequests]; @@ -892,20 +866,6 @@ + (void)setDeviceToken:(NSString *)token { }); } -+ (void)setInitialNotification:(NSDictionary *)notification { - kbInitialNotification = notification; -} - -+ (void)emitPushNotification:(NSDictionary *)notification { - Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { - [instance emitOnPushNotification:notification]; - NSLog(@"Kb.emitPushNotification: sent event 'onPushNotification' to JS"); - } else { - NSLog(@"Kb.emitPushNotification: WARNING - module not ready, event not sent"); - } -} - - (void)handleHardwareKeyPressed:(NSNotification *)notification { NSString *keyName = notification.userInfo[@"pressedKey"]; if (keyName && [self canEmit]) { @@ -951,33 +911,3 @@ - (void)kb_paste:(id)sender { void KbSetDeviceToken(NSString *token) { [Kb setDeviceToken:token]; } - -void KbSetInitialNotification(NSDictionary *notification) { - [Kb setInitialNotification:notification]; -} - -void KbEmitPushNotification(NSDictionary *notification) { - [Kb emitPushNotification:notification]; -} - -void KbEmitStoredNotificationOnBecomeActive(void) { - NSDictionary *stored = kbInitialNotification; - kbInitialNotification = nil; - if (!stored) { - NSLog(@"KbEmitStoredNotificationOnBecomeActive: no stored notification"); - return; - } - if (![stored[@"userInteraction"] boolValue]) { - // Not from a user tap; nothing to re-emit. - return; - } - if ([stored[@"reEmittedInBecomeActive"] boolValue]) { - // Already re-emitted once; keep it stored for getInitialNotification. - kbInitialNotification = stored; - return; - } - [Kb emitPushNotification:stored]; - NSMutableDictionary *copy = [stored mutableCopy]; - copy[@"reEmittedInBecomeActive"] = @YES; - kbInitialNotification = copy; -} diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index 86133a5402e1..dc12a7416c51 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -1,11 +1,10 @@ import {TurboModuleRegistry, type TurboModule} from 'react-native' -import type {EventEmitter, UnsafeObject} from 'react-native/Libraries/Types/CodegenTypes' +import type {EventEmitter} from 'react-native/Libraries/Types/CodegenTypes' export interface Spec extends TurboModule { readonly onMetaEvent: EventEmitter readonly onHardwareKeyPressed: EventEmitter readonly onPasteImage: EventEmitter> - readonly onPushNotification: EventEmitter readonly onPushToken: EventEmitter readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> getTypedConstants(): { @@ -60,10 +59,8 @@ export interface Spec extends TurboModule { requestPushPermissions(): Promise getRegistrationToken(): Promise setApplicationIconBadgeNumber(n: number): void - getInitialNotification(): Promise removeAllPendingNotificationRequests(): void addNotificationRequest(config: {body: string; id: string}): Promise - engineReset(): void notifyJSReady(): void shareListenersRegistered(): void setEnablePasteImage(enabled: boolean): void diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index aeccfb245834..c5560f4960d6 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -97,10 +97,6 @@ export const setApplicationIconBadgeNumber = (n: number): void => { Kb.setApplicationIconBadgeNumber(n) } -export const getInitialNotification = (): Promise => { - return Kb.getInitialNotification() -} - export const removeAllPendingNotificationRequests = (): void => { Kb.removeAllPendingNotificationRequests() } @@ -143,9 +139,6 @@ export const onMetaEvent = (callback: (payload: string) => void): EventSubscript } // Push events -export const onPushNotification = (callback: (notification: object) => void): EventSubscription => { - return Kb.onPushNotification(n => callback(n)) -} export const onPushToken = (callback: (token: string) => void): EventSubscription => { return Kb.onPushToken(callback) @@ -158,16 +151,12 @@ export const onShareData = ( return Kb.onShareData(callback) } -export const engineReset = (): void => { - return Kb.engineReset() -} export const notifyJSReady = (): void => { return Kb.notifyJSReady() } export const shareListenersRegistered = (): void => { return Kb.shareListenersRegistered() } - export const clearLocalLogs = (): Promise => { return Kb.clearLocalLogs() } diff --git a/shared/android/app/build.gradle b/shared/android/app/build.gradle index 9327e574c2cb..e205353b3d61 100644 --- a/shared/android/app/build.gradle +++ b/shared/android/app/build.gradle @@ -171,6 +171,8 @@ dependencies { implementation 'com.android.installreferrer:installreferrer:2.2' implementation "androidx.lifecycle:lifecycle-common-java8:2.10.0" implementation "androidx.lifecycle:lifecycle-process:2.10.0" + + testImplementation "junit:junit:4.13.2" } // This requires a google-services.json file locally. Drop it in diff --git a/shared/android/app/src/main/AndroidManifest.xml b/shared/android/app/src/main/AndroidManifest.xml index b790337d942f..0e94d6d8ea59 100644 --- a/shared/android/app/src/main/AndroidManifest.xml +++ b/shared/android/app/src/main/AndroidManifest.xml @@ -74,6 +74,15 @@ + + diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt new file mode 100644 index 000000000000..f644bf3e399e --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -0,0 +1,109 @@ +package io.keybase.ossifrage + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +// The Go lifecycle entry points. Kept free of Android and gomobile types so +// the event mapping runs in JVM tests. +internal interface LifecycleBind { + fun uiActive() + fun uiInactive() + fun uiBackground() + fun willExit() +} + +// Reports the app's process lifecycle to Go as events; Go decides the state. +// +// Events reach Go on the calling thread, before the callback returns: every Go +// lifecycle call returns at once, except willExit, whose warning about +// messages still sending reads the outbox. +// +// Only the process lifecycle counts. Activity pauses (dialogs, permission +// prompts, choosers, the photo picker sheet) report nothing, not even +// UIInactive: only the process lifecycle decides what Go sees. A full-screen +// picker or camera stops the process like any other exit. +// +// A process started without UI (a push, a quick reply) reports nothing: Go +// starts in the background. +internal class AppLifecycleReporter( + private val bind: LifecycleBind, + private val log: (String) -> Unit, +) : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + report("uiInactive") { bind.uiInactive() } + } + + override fun onResume(owner: LifecycleOwner) { + report("uiActive") { bind.uiActive() } + } + + override fun onStop(owner: LifecycleOwner) { + report("uiBackground") { bind.uiBackground() } + } + + // Activity recreation and a task moved to the back are not an exit. + fun onMainActivityDestroy(isFinishing: Boolean, isChangingConfigurations: Boolean) { + if (!isFinishing || isChangingConfigurations) { + return + } + report("willExit") { bind.willExit() } + } + + private fun report(event: String, call: () -> Unit) { + log("AppLifecycleReporter: $event") + try { + call() + } catch (e: Exception) { + log("AppLifecycleReporter: $event failed: $e") + } + } +} + +// Sends a notification quick reply. Returns the text for the replied +// notification. +internal fun sendQuickReply(error: (String, Throwable) -> Unit, send: () -> Unit): String = + try { + send() + QUICK_REPLY_SENT + } catch (e: Exception) { + error("Failed to send quick reply", e) + QUICK_REPLY_FAILED + } + +// Runs a receiver's work off the main thread and calls finish exactly once: +// when the work ends or when budgetMs runs out, whichever is first, so the +// broadcast never outlives its limit. Work that overruns keeps going. An +// exception from work is logged, since it would otherwise kill the process. +internal fun runReceiverWork( + budgetMs: Long, + start: (Runnable) -> Unit, + warn: (String) -> Unit, + error: (String, Throwable) -> Unit, + finish: () -> Unit, + work: () -> Unit, +) { + val done = CountDownLatch(1) + start(Runnable { + try { + work() + } catch (e: Exception) { + error("runReceiverWork: work failed", e) + } finally { + done.countDown() + } + }) + start(Runnable { + try { + if (!done.await(budgetMs, TimeUnit.MILLISECONDS)) { + warn("runReceiverWork: still running after ${budgetMs}ms, finishing the broadcast") + } + } finally { + finish() + } + }) +} + +internal const val QUICK_REPLY_SENT = "Replied" +internal const val QUICK_REPLY_FAILED = "Couldn't send reply" diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt index afb2f3a7dd03..8a2bd9bdc198 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/ChatBroadcastReceiver.kt @@ -19,37 +19,36 @@ class ChatBroadcastReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { - setupKBRuntime(context, false) val convData = ConvData.fromIntent(intent) val openConv = intent.getParcelableExtra("openConvPendingIntent") - val repliedNotification = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID) - .setContentIntent(openConv) - .setTimeoutAfter(1000) - .setSmallIcon(R.drawable.ic_notif) - val notificationManager = NotificationManagerCompat.from(context) val messageBody = getMessageText(intent) - if (messageBody != null) { - try { - val withBackgroundActive: WithBackgroundActive = object : WithBackgroundActive { - override fun task() { - Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody) - } + val pendingResult = goAsync() + runReceiverWork(RECEIVER_BUDGET_MS, { Thread(it).start() }, { NativeLogger.warn(it) }, { msg, e -> NativeLogger.error(msg, e) }, + { pendingResult.finish() }) { + val status = if (messageBody == null) { + NativeLogger.error("Message Body in quick reply was null") + "Couldn't send reply - Failed to read input." + } else { + setupKBRuntime(context, false) + sendQuickReply({ msg, e -> NativeLogger.error(msg, e) }) { + Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody, + KBPushNotifier(context, Bundle())) } - withBackgroundActive.whileActive(context) - repliedNotification.setContentText("Replied") - } catch (e: Exception) { - repliedNotification.setContentText("Couldn't send reply") - NativeLogger.error("Failed to send quick reply", e) } - } else { - repliedNotification.setContentText("Couldn't send reply - Failed to read input.") - NativeLogger.error("Message Body in quick reply was null") + val repliedNotification = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID) + .setContentIntent(openConv) + .setTimeoutAfter(1000) + .setSmallIcon(R.drawable.ic_notif) + .setContentText(status) + NotificationManagerCompat.from(context).notify(convData.convID, 0, repliedNotification.build()) } - notificationManager.notify(convData.convID, 0, repliedNotification.build()) } companion object { const val KEY_TEXT_REPLY = "key_text_reply" + + // goAsync gives a broadcast 10s; leave margin. + private const val RECEIVER_BUDGET_MS = 9_000L } } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt index 1eed346fdc0b..8c32ae7f8ac2 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt @@ -12,8 +12,6 @@ import android.graphics.PorterDuffXfermode import android.graphics.Rect import android.net.Uri import android.os.Bundle -import android.os.Handler -import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person @@ -22,9 +20,9 @@ import androidx.core.graphics.drawable.IconCompat import keybase.ChatNotification import keybase.PushNotifier import java.io.BufferedInputStream -import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest class KBPushNotifier internal constructor(private val context: Context, private val bundle: Bundle) : PushNotifier { private var convMsgCache: SmallMsgRingBuffer? = null @@ -38,17 +36,39 @@ class KBPushNotifier internal constructor(private val context: Context, private this.convMsgCache = convMsgCache } - // Controls the Intent that gets built - private fun buildPendingIntent(bundle: Bundle): PendingIntent { - val open_activity_intent = Intent(context, MainActivity::class.java) - open_activity_intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - open_activity_intent.setPackage(context.packageName) - open_activity_intent.putExtra("notification", bundle) + // A tap goes through PushTapActivity, which hands the push to the service. The payload rides + // in the extras, and the data is a digest of it: PendingIntent.getActivity hands back an + // existing PendingIntent for any Intent that filterEquals the new one, and extras are not part + // of filterEquals, so two notifications with different payloads must differ in the data or the + // second tap would open the first one's target. A digest rather than the payload itself + // because a data URI is printed by `dumpsys activity`, where an extra is not. Immutable, so + // whoever holds this PendingIntent can't substitute another payload. + // + // The whole push goes in rather than a projection of it, since which fields matter is the + // service's business. A push is a few hundred bytes against the ~1MB a Binder transaction + // allows, but it is the sender who decides how big, so a payload that grows without bound is + // the thing that would break this. + private fun tapIntent(bundle: Bundle): Intent = + Intent(context, PushTapActivity::class.java) + .setData(Uri.parse("kbpushtap:" + payloadDigest(bundle))) + .putExtras(bundle) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - // unique so our intents are deduped, else it'll reuse old ones - return PendingIntent.getActivity(context, (System.currentTimeMillis() / 1000).toInt(), open_activity_intent, PendingIntent.FLAG_MUTABLE) + private fun payloadDigest(bundle: Bundle): String { + val digest = MessageDigest.getInstance("SHA-256") + for (key in bundle.keySet().sorted()) { + @Suppress("DEPRECATION") + val value = bundle.get(key)?.toString() ?: "" + // Length-prefixed so no pair of keys and values can run together into the same digest + // input as a different pair would. + digest.update("${key.length}:$key${value.length}:$value".toByteArray()) + } + return digest.digest().joinToString("") { "%02x".format(it) } } + private fun buildPendingIntent(bundle: Bundle): PendingIntent = + PendingIntent.getActivity(context, 0, tapIntent(bundle), PendingIntent.FLAG_IMMUTABLE) + private fun getKeybaseAvatar(avatarUri: String): IconCompat? { if (avatarUri.isEmpty()) return null @@ -105,7 +125,6 @@ class KBPushNotifier internal constructor(private val context: Context, private private fun displayChatNotification2(chatNotification: ChatNotification) { try { KeybasePushNotificationListenerService.createNotificationChannel(context) - bundle.putBoolean("userInteraction", true) bundle.putString("type", "chat.newmessage") bundle.putString("convID", chatNotification.convID) if (chatNotification.uid.isNotEmpty()) { @@ -179,7 +198,6 @@ class KBPushNotifier internal constructor(private val context: Context, private fun followNotification(username: String, notificationMsg: String?) { val bundle = bundle.clone() as Bundle - bundle.putBoolean("userInteraction", true) bundle.putString("type", "follow") bundle.putString("username", username) val builder = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.FOLLOW_CHANNEL_ID) @@ -203,7 +221,6 @@ class KBPushNotifier internal constructor(private val context: Context, private } fun genericNotification(uniqueTag: String?, notificationTitle: String?, notificationMsg: String?, bundle: Bundle, channelID: String?) { - bundle.putBoolean("userInteraction", true) val builder = NotificationCompat.Builder(context, channelID!!) .setSmallIcon(R.drawable.ic_notif) // Set the intent that will fire when the user taps the notification .setContentIntent(buildPendingIntent(bundle)) diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt new file mode 100644 index 000000000000..34f0a34e8ca1 --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt @@ -0,0 +1,17 @@ +package io.keybase.ossifrage + +import android.content.Context +import android.os.Bundle +import keybase.Keybase + +internal class KeybaseLifecycleBind(private val context: Context) : LifecycleBind { + override fun uiActive() = Keybase.appUIActive() + + override fun uiInactive() = Keybase.appUIInactive() + + override fun uiBackground() { + Keybase.appUIBackground(KBPushNotifier(context, Bundle())) + } + + override fun willExit() = Keybase.appWillExit(KBPushNotifier(context, Bundle())) +} 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 4c668349266b..05796e312221 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 @@ -5,17 +5,13 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.app.Person import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase -import com.reactnativekb.KbModule import org.json.JSONObject class KeybasePushNotificationListenerService : FirebaseMessagingService() { @@ -23,23 +19,16 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { // was notified about to give context to future notifications. private val msgCache = HashMap() - // Avoid ever showing doubles - private val seenChatNotifications = HashSet() + // Go's seen cache dedupes what Go displays, but not the fallback below: a + // redelivered push that Go fails on again would show the fallback twice, + // and each display adds the message to msgCache's history again. + private val seenChatNotifications = object : LinkedHashMap(16, 0.75f, false) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > SEEN_CHAT_NOTIFICATIONS_MAX + } private fun isOtherAccountPushError(ex: Exception): Boolean { return ex.message?.contains("different account") == true } - private fun buildStyle(convID: String, person: Person): NotificationCompat.Style { - val style = NotificationCompat.MessagingStyle(person) - val buf = msgCache[convID] - if (buf != null) { - for (msg in buf.summary()) { - style.addMessage(msg) - } - } - return style - } - override fun onCreate() { setupKBRuntime(this, false) NativeLogger.info("KeybasePushNotificationListenerService created") @@ -94,12 +83,12 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { // Silent notifications are processed but not marked as seen, allowing the non-silent one to display if (!dontNotify) { val notificationKey = n.convID + n.messageId - if (seenChatNotifications.contains(notificationKey)) { + if (seenChatNotifications.containsKey(notificationKey)) { NativeLogger.info("KeybasePushNotificationListenerService skipping duplicate notification: $notificationKey") return } // Mark as seen immediately to prevent duplicate processing - seenChatNotifications.add(notificationKey) + seenChatNotifications[notificationKey] = Unit NativeLogger.info("KeybasePushNotificationListenerService marked notification as seen: $notificationKey") } @@ -116,46 +105,22 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { var goProcessingSucceeded = false try { - val withBackgroundActive: WithBackgroundActive = object : WithBackgroundActive { - override fun task() { - try { - Keybase.handleBackgroundNotification(n.convID, payload, n.serverMessageBody, n.sender, - n.membersType.toLong(), n.displayPlaintext, n.messageId.toLong(), n.pushId, - n.badgeCount.toLong(), n.unixTime, n.soundName, if (dontNotify) null else notifier, true, - targetUID) - goProcessingSucceeded = true - if (!dontNotify) { - seenChatNotifications.add(n.convID + n.messageId) - } - } catch (ex: Exception) { - if (isOtherAccountPushError(ex)) { - NativeLogger.info("Go skipped notification for a different active account: " + ex.message) - } else { - NativeLogger.error("Go Couldn't handle background notification2: " + ex.message) - } - throw ex - } - } - } - withBackgroundActive.whileActive(applicationContext) + // Go holds the app up while it handles the push, and in the + // foreground acks it without displaying it. + Keybase.handleBackgroundNotification(n.convID, payload, n.serverMessageBody, n.sender, + n.membersType.toLong(), n.displayPlaintext, n.messageId.toLong(), n.pushId, + n.badgeCount.toLong(), n.unixTime, n.soundName, if (dontNotify) null else notifier, true, + targetUID, KBPushNotifier(applicationContext, Bundle())) + goProcessingSucceeded = true } catch (ex: Exception) { if (isOtherAccountPushError(ex)) { - NativeLogger.info("Skipping active-account processing for different-account push") + NativeLogger.info("Go skipped notification for a different active account: " + ex.message) } else { - NativeLogger.error("Failed to process notification (app may not be running): " + ex.message) + NativeLogger.error("Go couldn't handle background notification: " + ex.message) } - goProcessingSucceeded = false } - val isReactNativeRunning = try { - com.reactnativekb.KbModule.isReactNativeRunning() - } catch (e: Exception) { - NativeLogger.info("KeybasePushNotificationListenerService couldn't check if React Native is running: ${e.message}, assuming not") - false - } - NativeLogger.info("KeybasePushNotificationListenerService isReactNativeRunning: $isReactNativeRunning") - val isForeground = try { Keybase.isAppStateForeground() } catch (e: Exception) { @@ -164,14 +129,9 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { } NativeLogger.info("KeybasePushNotificationListenerService isForeground: $isForeground") - // Don't show notifications if app is foreground - user is already looking at the app - if (isForeground) { - - } else if (dontNotify) { - // Silent notifications should never display - they're processed by Go but no notification shown - } else if (!goProcessingSucceeded && type == "chat.newmessage") { - // Only show fallback if Go processing failed AND it's a non-silent notification - // If Go succeeded, it already displayed the notification (via notifier parameter) + // In the foreground the app already has the message. A silent push never + // displays. Otherwise fall back only if Go failed to display it itself. + if (!isForeground && !dontNotify && !goProcessingSucceeded) { NativeLogger.info("KeybasePushNotificationListenerService attempting fallback notification display") try { val chatNotif = keybase.ChatNotification() @@ -197,20 +157,12 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { chatNotif.uid = targetUID notifier.displayChatNotification(chatNotif) - seenChatNotifications.add(n.convID + n.messageId) NativeLogger.info("KeybasePushNotificationListenerService fallback notification displayed successfully") } catch (e: Exception) { NativeLogger.error("Failed to display notification fallback: " + e.message) } - } else if (dontNotify) { - } - if (type == "chat.newmessage") { - val emitBundle = bundle.clone() as Bundle - emitBundle.putBoolean("userInteraction", false) - KbModule.emitPushNotification(emitBundle) - } } "follow" -> { @@ -218,18 +170,11 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { val m = bundle.getString("message") if (username != null && m != null) { notifier.followNotification(username, m) - val emitBundle = bundle.clone() as Bundle - emitBundle.putBoolean("userInteraction", false) - KbModule.emitPushNotification(emitBundle) - } else { } } "device.revoked", "device.new" -> { notifier.deviceNotification() - val emitBundle = bundle.clone() as Bundle - emitBundle.putBoolean("userInteraction", false) - KbModule.emitPushNotification(emitBundle) } "chat.readmessage" -> { @@ -246,15 +191,10 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { val notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager.cancelAll() } - val emitBundle = bundle.clone() as Bundle - KbModule.emitPushNotification(emitBundle) } else -> { notifier.generalNotification() - val emitBundle = bundle.clone() as Bundle - emitBundle.putBoolean("userInteraction", false) - KbModule.emitPushNotification(emitBundle) } } } catch (ex: Exception) { @@ -275,6 +215,7 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { } companion object { + private const val SEEN_CHAT_NOTIFICATIONS_MAX = 100 const val CHAT_CHANNEL_ID = "kb_chat_channel" const val FOLLOW_CHANNEL_ID = "kb_follow_channel" const val DEVICE_CHANNEL_ID = "kb_device_channel" @@ -388,50 +329,3 @@ internal class NotificationData(type: String, bundle: Bundle) { } } } - -// Interface to run some task while in backgroundActive. -// If already foreground, ignore -internal interface WithBackgroundActive { - @Throws(Exception::class) - fun task() - - @Throws(Exception::class) - fun whileActive(context: Context?) { - try { - // We are foreground don't show anything - val isForeground = Keybase.isAppStateForeground() - NativeLogger.info("WithBackgroundActive.whileActive isForeground: $isForeground") - 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() - 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() - } - } - } catch (ex: Exception) { - NativeLogger.error("WithBackgroundActive.whileActive exception: " + ex.message) - throw ex - } - } -} 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..613d5e4be6fe 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 @@ -10,19 +10,15 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore -import android.provider.Settings import android.util.Log import android.view.KeyEvent import androidx.core.content.IntentCompat import android.webkit.MimeTypeMap import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate -import com.facebook.react.ReactApplication import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.ReactContext import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate -import com.facebook.react.modules.core.PermissionListener import com.reactnativekb.DarkModePreference import com.reactnativekb.IncomingShareCache import com.reactnativekb.KbModule @@ -40,7 +36,6 @@ import java.security.cert.CertificateException import java.util.UUID class MainActivity : ReactActivity() { - private val listener: PermissionListener? = null private var isUsingHardwareKeyboard = false override fun invokeDefaultOnBackPressed() { @@ -75,8 +70,6 @@ class MainActivity : ReactActivity() { super.onCreate(null) KeybasePushNotificationListenerService.createNotificationChannel(this) updateIsUsingHardwareKeyboard() - - scheduleHandleIntent() } override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { @@ -85,19 +78,9 @@ class MainActivity : ReactActivity() { } else super.onKeyUp(keyCode, event) } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - listener?.onRequestPermissionsResult(requestCode, permissions, grantResults) - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - } - 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 { @@ -116,10 +99,10 @@ class MainActivity : ReactActivity() { return filename } - private fun saveFileToCache(reactContext: ReactContext?, uri: Uri, filename: String): File { - val file = IncomingShareCache.file(reactContext!!, filename) + private fun saveFileToCache(context: Context, uri: Uri, filename: String): File { + val file = IncomingShareCache.file(context, filename) try { - reactContext.contentResolver.openInputStream(uri).use { istream -> + context.contentResolver.openInputStream(uri).use { istream -> FileOutputStream(file).use { ostream -> val buf = ByteArray(64 * 1024) var len: Int @@ -134,11 +117,11 @@ class MainActivity : ReactActivity() { return file } - private fun readFileFromUri(reactContext: ReactContext?, uri: Uri?): String? { + private fun readFileFromUri(context: Context, uri: Uri?): String? { if (uri == null) return null var filePath: String? filePath = if (uri.scheme == "content") { - val resolver = reactContext!!.contentResolver + val resolver = context.contentResolver val mimeType = resolver.getType(uri) val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) @@ -146,7 +129,7 @@ class MainActivity : ReactActivity() { val filename = getFileNameFromResolver(resolver, uri, extension) // Now load the file itself. - val file = saveFileToCache(reactContext, uri, filename) + val file = saveFileToCache(context, uri, filename) file.path } else { uri.path @@ -157,60 +140,58 @@ class MainActivity : ReactActivity() { override fun onResume() { NativeLogger.info("Activity onResume") super.onResume() - Keybase.setAppStateForeground() handleIntent() } override fun onStart() { NativeLogger.info("Activity onStart") super.onStart() - Keybase.setAppStateForeground() } override fun onDestroy() { NativeLogger.info("Activity onDestroy") super.onDestroy() - Keybase.appWillExit(KBPushNotifier(this, Bundle())) + (application as MainApplication).lifecycleReporter.onMainActivityDestroy(isFinishing, isChangingConfigurations) } + // A share intent parks here until JS asks for it. Nothing else is parked: deep links go + // through super.onNewIntent -> RCTLinkingManager, and a notification tap goes to the + // service, so a plain launch leaves this null. private var cachedIntent: Intent? = null private var pendingShareUris: List? = null private var pendingShareSubject: String? = null private var pendingShareText: String? = null - // Snapshot share/notification data out of the intent right away: share URI - // permission grants and clip data are tied to the delivered intent, and JS may - // not be ready to consume them until much later (see tryHandleIntentWithRetry). + // Snapshot share data out of the intent right away: share URI permission grants and clip + // data are tied to the delivered intent, and JS may not be ready to route them until much + // later (see shareListenersRegistered). private fun captureIntent(intent: Intent) { - cachedIntent = intent - if (Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action) { - pendingShareUris = extractSharedUris(intent) - pendingShareSubject = intent.getStringExtra(Intent.EXTRA_SUBJECT) - pendingShareText = intent.getStringExtra(Intent.EXTRA_TEXT) - } - val bundleFromNotification = intent.getBundleExtra("notification") - if (bundleFromNotification != null) { - KbModule.setInitialNotification(bundleFromNotification.clone() as Bundle) + if (Intent.ACTION_SEND != intent.action && Intent.ACTION_SEND_MULTIPLE != intent.action) { + return } + cachedIntent = intent + pendingShareUris = extractSharedUris(intent) + pendingShareSubject = intent.getStringExtra(Intent.EXTRA_SUBJECT) + pendingShareText = intent.getStringExtra(Intent.EXTRA_TEXT) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) captureIntent(intent) - NativeLogger.info("MainActivity.onNewIntent: action=${intent.action}, uriCount=${pendingShareUris?.size ?: 0}, hasNotification=${intent.getBundleExtra("notification") != null}") + NativeLogger.info("MainActivity.onNewIntent: action=${intent.action}, uriCount=${pendingShareUris?.size ?: 0}") } private var jsIsListening = false + // JS calls this once it is ready to route a share. That is the only signal the parked + // intent waits on, so it replaces any native-side polling for a live JS runtime. public fun shareListenersRegistered() { jsIsListening = true - tryHandleIntentWithRetry() + handleIntent() } - private var handledIntentHash: String? = null - private fun extractSharedUris(intent: Intent): List { val action = intent.action if (Intent.ACTION_SEND != action && Intent.ACTION_SEND_MULTIPLE != action) { @@ -242,108 +223,56 @@ class MainActivity : ReactActivity() { return uris.distinct() } - private var handleIntentRetryCount = 0 - private val maxHandleIntentRetries = 20 // 20 * 500ms = 10s max - - private fun scheduleHandleIntent() { - if (cachedIntent == null) return - handleIntentRetryCount = 0 - tryHandleIntentWithRetry() - } - - private fun tryHandleIntentWithRetry() { - if (cachedIntent == null) return - if (handleIntent()) return - handleIntentRetryCount++ - if (handleIntentRetryCount >= maxHandleIntentRetries) { - NativeLogger.info("MainActivity: giving up on handleIntent after $maxHandleIntentRetries retries") - return - } - NativeLogger.info("MainActivity: scheduling handleIntent retry #$handleIntentRetryCount") - Handler(Looper.getMainLooper()).postDelayed({ tryHandleIntentWithRetry() }, 500) - } - - private fun handleIntent(): Boolean { - val intent = cachedIntent ?: return true - val rc = reactActivityDelegate?.getCurrentReactContext() ?: run { - NativeLogger.info("MainActivity.handleIntent: no react context, will retry") - return false - } - if (!jsIsListening) { - NativeLogger.info("MainActivity.handleIntent: JS not listening yet, will retry") - return false - } + private fun handleIntent() { + val intent = cachedIntent ?: return + if (!jsIsListening) return NativeLogger.info("MainActivity.handleIntent: processing intent action=${intent.action}") - // Here we are just reading from the notification bundle. - // If other sources start the app, we can get their intent data the same way. - val bundleFromNotification = intent.getBundleExtra("notification") - - if (bundleFromNotification != null) { - // Prevent duplicate handling of the same notification - val convID = bundleFromNotification.getString("convID") ?: bundleFromNotification.getString("c") - val messageId = bundleFromNotification.getString("msgID") ?: bundleFromNotification.getString("d") ?: "" - val intentHash = "${convID}_${messageId}" - if (handledIntentHash == intentHash) { - NativeLogger.info("MainActivity.handleIntent skipping duplicate notification: $intentHash") - } else { - handledIntentHash = intentHash - NativeLogger.info("MainActivity.handleIntent processing notification: $intentHash") - - KbModule.emitPushNotification(bundleFromNotification) + val uris = pendingShareUris.orEmpty().also { pendingShareUris = null } + val subject = pendingShareSubject.also { pendingShareSubject = null } + val text = pendingShareText.also { pendingShareText = null } + + // Strip consumed extras so an activity recreation (which redelivers this + // same intent instance) doesn't re-share. + intent.removeExtra(Intent.EXTRA_STREAM) + intent.removeExtra(Intent.EXTRA_SUBJECT) + intent.removeExtra(Intent.EXTRA_TEXT) + intent.setClipData(null) + + val textPayload = listOfNotNull(subject, text).joinToString(" ") + val isTextMime = intent.type?.startsWith("text/") == true + + if (isTextMime && textPayload.isNotEmpty()) { + // Text-type intent (e.g. URL from Chrome): prefer text over any preview images + emitShareText(text ?: textPayload) + } else if (uris.isEmpty()) { + if (textPayload.isNotEmpty()) { + emitShareText(textPayload) } - - intent.removeExtra("notification") - } - - val action = intent.action - if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) { - val uris = pendingShareUris.orEmpty().also { pendingShareUris = null } - val subject = pendingShareSubject.also { pendingShareSubject = null } - val text = pendingShareText.also { pendingShareText = null } - - // Strip consumed extras so an activity recreation (which redelivers this - // same intent instance) doesn't re-share. - intent.removeExtra(Intent.EXTRA_STREAM) - intent.removeExtra(Intent.EXTRA_SUBJECT) - intent.removeExtra(Intent.EXTRA_TEXT) - intent.setClipData(null) - - val textPayload = listOfNotNull(subject, text).joinToString(" ") - val isTextMime = intent.type?.startsWith("text/") == true - - if (isTextMime && textPayload.isNotEmpty()) { - // Text-type intent (e.g. URL from Chrome): prefer text over any preview images - emitShareText(text ?: textPayload) - } else if (uris.isEmpty()) { - if (textPayload.isNotEmpty()) { + } else { + // Copying out of the content providers can be slow for big files; don't + // block the main thread on it. + val context: Context = this + Thread { + val filePaths = uris.mapNotNull { uri -> + try { + readFileFromUri(context, uri) + } catch (e: SecurityException) { + null + } + } + if (filePaths.isNotEmpty()) { + emitShareFiles(filePaths) + } else if (textPayload.isNotEmpty()) { + // Fallback: non-text MIME but no files resolved, send text emitShareText(textPayload) + } else { + emitShareFiles(emptyList()) } - } else { - // Copying out of the content providers can be slow for big files; don't - // block the main thread on it. - Thread { - val filePaths = uris.mapNotNull { uri -> - try { - readFileFromUri(rc, uri) - } catch (e: SecurityException) { - null - } - } - if (filePaths.isNotEmpty()) { - emitShareFiles(filePaths) - } else if (textPayload.isNotEmpty()) { - // Fallback: non-text MIME but no files resolved, send text - emitShareText(textPayload) - } else { - emitShareFiles(emptyList()) - } - }.start() - } + }.start() } cachedIntent = null - return true } private fun emitShareText(text: String) { @@ -439,12 +368,6 @@ class MainActivity : ReactActivity() { } } - // Is this a robot controlled test device? (i.e. pre-launch report?) - fun isTestDevice(context: Context): Boolean { - val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab") - return "true" == testLabSetting - } - @JvmStatic fun setupKBRuntime(context: Context, shouldCreateDummyFile: Boolean) { try { @@ -465,7 +388,7 @@ class MainActivity : ReactActivity() { val isIPad = false val isIOS = false Keybase.initOnce(context.filesDir.path, "", context.getFileStreamPath("service.log").absolutePath, "prod", false, - DNSNSFetcher(), VideoHelper(), mobileOsVersion, isIPad, KBInstallReferrerListener(context), isIOS, null) + DNSNSFetcher(), VideoHelper(), mobileOsVersion, isIPad, KBInstallReferrerListener(context), isIOS, null, null) } } } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt index 65de3e828c1c..f5398a9449e8 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt @@ -6,9 +6,11 @@ import android.content.res.Configuration import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.Operation import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager -import androidx.work.WorkRequest +import androidx.work.await import com.bumptech.glide.Glide import com.facebook.react.PackageList import com.facebook.react.ReactApplication @@ -22,9 +24,15 @@ import com.reactnativekb.IncomingShareCache import expo.modules.ApplicationLifecycleDispatcher.onApplicationCreate import expo.modules.ApplicationLifecycleDispatcher.onConfigurationChanged import expo.modules.ExpoReactHostFactory +import io.keybase.ossifrage.modules.BackgroundSyncJobs import io.keybase.ossifrage.modules.BackgroundSyncWorker +import io.keybase.ossifrage.modules.LegacyJobsCleanupFlag import io.keybase.ossifrage.modules.NativeLogger +import io.keybase.ossifrage.modules.scheduleBackgroundSync import keybase.Keybase +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import java.util.concurrent.TimeUnit internal class AppLifecycleListener(private val context: Context?) : @@ -52,9 +60,15 @@ class MainApplication : Application(), ReactApplication { } + internal val lifecycleReporter by lazy { + AppLifecycleReporter(KeybaseLifecycleBind(this)) { NativeLogger.info(it) } + } + override fun onCreate() { NativeLogger.info("MainApplication created") super.onCreate() + // Before any activity or service starts, so no process event is missed. + ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleReporter) try { DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) } catch (e: IllegalArgumentException) { @@ -73,15 +87,13 @@ class MainApplication : Application(), ReactApplication { } }.start() - val backgroundSyncRequest: WorkRequest = PeriodicWorkRequest.Builder( - BackgroundSyncWorker::class.java, - 1, TimeUnit.HOURS, - 15, TimeUnit.MINUTES - ) - .build() - WorkManager - .getInstance(this) - .enqueue(backgroundSyncRequest) + Thread { + try { + scheduleBackgroundSync(WorkManagerBackgroundSyncJobs(this), SharedPrefsCleanupFlag(this)) + } catch (e: Exception) { + NativeLogger.warn("MainApplication: error scheduling background sync", e) + } + }.start() } fun onReactContextInitialized(context: ReactContext?) { @@ -100,3 +112,49 @@ class MainApplication : Application(), ReactApplication { super.onLowMemory() } } + +private class WorkManagerBackgroundSyncJobs(context: Context) : BackgroundSyncJobs { + private val workManager = WorkManager.getInstance(context) + + // WorkManager tags every request with its worker's class name. + override fun cancelAll() { + workManager.cancelAllWorkByTag(BackgroundSyncWorker::class.java.name).awaitDone("cancel") + } + + override fun enqueueUnique() { + val request = PeriodicWorkRequest.Builder( + BackgroundSyncWorker::class.java, + 1, TimeUnit.HOURS, + 15, TimeUnit.MINUTES + ).build() + workManager.enqueueUniquePeriodicWork("background_sync", ExistingPeriodicWorkPolicy.KEEP, request).awaitDone("enqueue") + } + + // A stalled WorkManager must not park the scheduling thread forever. + private fun Operation.awaitDone(what: String) { + try { + runBlocking { withTimeout(OPERATION_TIMEOUT_MS) { await() } } + } catch (e: TimeoutCancellationException) { + NativeLogger.warn("MainApplication: background sync $what timed out after ${OPERATION_TIMEOUT_MS}ms") + throw e + } + } + + companion object { + private const val OPERATION_TIMEOUT_MS = 30_000L + } +} + +private class SharedPrefsCleanupFlag(context: Context) : LegacyJobsCleanupFlag { + private val prefs = context.getSharedPreferences("background_sync", Context.MODE_PRIVATE) + + override fun isDone() = prefs.getBoolean(KEY, false) + + override fun markDone() { + prefs.edit().putBoolean(KEY, true).commit() + } + + companion object { + private const val KEY = "legacy_jobs_cancelled" + } +} diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt new file mode 100644 index 000000000000..806aef117b72 --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt @@ -0,0 +1,53 @@ +package io.keybase.ossifrage + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime +import io.keybase.ossifrage.modules.NativeLogger +import keybase.Keybase +import kotlin.concurrent.thread +import org.json.JSONObject + +// Opens the app for a tapped notification. Not exported, so only this app's own notification +// PendingIntents can start it: the payload it hands the service, which may name an account to +// switch to, can't come from another app. MainActivity, which any app can start, never reads it. +class PushTapActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Read the Intent here and deliver off the main thread: a tap can be what starts this + // process, and the initOnce below is a known slow path (leveldb, keychain) while this + // activity is Theme.NoDisplay and must finish before onResume. Nothing is racing the app + // coming up: a delivery that lands after the client connected is picked up by the service's + // nudge, one that lands before it by the peek the client does on connect. + val payload = runCatching { payloadJSON(intent.extras) }.getOrElse { + // An empty payload still opens the app, but it opens it nowhere in particular, so the + // tap has to leave a trace rather than vanish. + NativeLogger.error("PushTapActivity: could not read a tap payload", it) + "{}" + } + val context = applicationContext + thread(start = true) { + runCatching { + setupKBRuntime(context, false) + Keybase.deliverPushTap(payload) + }.onFailure { NativeLogger.error("PushTapActivity: failed to deliver a tap", it) } + } + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + ) + finish() + } + + // The push as it arrived, as JSON, which is the shape the service parses. Nothing is picked + // out of it here: which fields matter is the service's business. + private fun payloadJSON(extras: Bundle?): String { + val json = JSONObject() + extras?.keySet()?.forEach { key -> + @Suppress("DEPRECATION") + json.put(key, extras.get(key)?.toString() ?: "") + } + return json.toString() + } +} diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/modules/BackgroundSyncSchedule.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/modules/BackgroundSyncSchedule.kt new file mode 100644 index 000000000000..930c305053f6 --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/modules/BackgroundSyncSchedule.kt @@ -0,0 +1,29 @@ +package io.keybase.ossifrage.modules + +// WorkManager and the persisted flag, behind interfaces so the scheduling +// decision runs in JVM tests. Each call returns once its operation is done and +// throws if it failed. +internal interface BackgroundSyncJobs { + // Cancels every BackgroundSyncWorker job, including ones enqueued without + // a unique name by older versions. + fun cancelAll() + fun enqueueUnique() +} + +internal interface LegacyJobsCleanupFlag { + fun isDone(): Boolean + fun markDone() +} + +// Older versions enqueued a new periodic job on every process start, so +// existing installs can carry many. Clear them once, then keep one unique job +// whose period isn't reset on each launch. +internal fun scheduleBackgroundSync(jobs: BackgroundSyncJobs, cleanup: LegacyJobsCleanupFlag) { + if (cleanup.isDone()) { + jobs.enqueueUnique() + return + } + jobs.cancelAll() + jobs.enqueueUnique() + cleanup.markDone() +} diff --git a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt new file mode 100644 index 000000000000..0bc3013d0490 --- /dev/null +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -0,0 +1,202 @@ +package io.keybase.ossifrage + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private class FakeBind : LifecycleBind { + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + val threads: MutableSet = Collections.synchronizedSet(mutableSetOf()) + + private fun record(call: String) { + threads.add(Thread.currentThread()) + calls.add(call) + } + + override fun uiActive() = record("uiActive") + + override fun uiInactive() = record("uiInactive") + + override fun uiBackground() = record("uiBackground") + + override fun willExit() = record("willExit") +} + +private object Owner : LifecycleOwner { + override val lifecycle: Lifecycle + get() = throw UnsupportedOperationException() +} + +class AppLifecycleReporterTest { + private val bind = FakeBind() + private val reporter = AppLifecycleReporter(bind) {} + + private fun launch() { + reporter.onCreate(Owner) + reporter.onStart(Owner) + reporter.onResume(Owner) + } + + private fun stop() { + reporter.onPause(Owner) + reporter.onStop(Owner) + } + + private fun calls(): List = bind.calls.toList() + + @Test + fun processStartAndStopReportEventsInOrder() { + launch() + stop() + reporter.onStart(Owner) + reporter.onResume(Owner) + assertEquals( + listOf( + "uiInactive", "uiActive", + "uiBackground", + "uiInactive", "uiActive", + ), + calls(), + ) + } + + @Test + fun eventsReachGoOnTheCallingThreadBeforeTheCallbackReturns() { + reporter.onStart(Owner) + assertEquals(listOf("uiInactive"), calls()) + reporter.onResume(Owner) + assertEquals(listOf("uiInactive", "uiActive"), calls()) + reporter.onStop(Owner) + assertEquals(listOf("uiInactive", "uiActive", "uiBackground"), calls()) + assertEquals(setOf(Thread.currentThread()), bind.threads.toSet()) + } + + @Test + fun dialogOrPermissionPromptPauseNeverBackgrounds() { + launch() + reporter.onPause(Owner) + reporter.onResume(Owner) + reporter.onPause(Owner) + assertEquals(listOf("uiInactive", "uiActive", "uiActive"), calls()) + } + + // A full-screen picker or camera stops the process like any other exit. + @Test + fun fullScreenPickerBackgroundsAndReturningForegrounds() { + launch() + stop() + reporter.onStart(Owner) + reporter.onResume(Owner) + assertEquals( + listOf( + "uiInactive", "uiActive", + "uiBackground", + "uiInactive", "uiActive", + ), + calls(), + ) + } + + @Test + fun onlyAFinishingActivityExits() { + launch() + reporter.onMainActivityDestroy(isFinishing = false, isChangingConfigurations = false) + reporter.onMainActivityDestroy(isFinishing = true, isChangingConfigurations = true) + assertEquals(listOf("uiInactive", "uiActive"), calls()) + reporter.onMainActivityDestroy(isFinishing = true, isChangingConfigurations = false) + stop() + reporter.onStart(Owner) + reporter.onResume(Owner) + assertEquals( + listOf( + "uiInactive", "uiActive", + "willExit", "uiBackground", + "uiInactive", "uiActive", + ), + calls(), + ) + } +} + +class SendQuickReplyTest { + private val errors = mutableListOf>() + + private fun send(send: () -> Unit) = sendQuickReply({ msg, e -> errors.add(msg to e) }, send) + + @Test + fun replySends() { + var sent = false + assertEquals(QUICK_REPLY_SENT, send { sent = true }) + assertTrue(sent) + assertTrue(errors.isEmpty()) + } + + @Test + fun failedReplyIsNotReportedAsRepliedAndLogsTheException() { + val failure = IllegalStateException("outbox full") + assertEquals(QUICK_REPLY_FAILED, send { throw failure }) + assertEquals(listOf("Failed to send quick reply" to failure), errors.toList()) + } +} + +class RunReceiverWorkTest { + private val finishes = AtomicInteger() + private val finished = CountDownLatch(1) + private val warnings = Collections.synchronizedList(mutableListOf()) + private val errors = Collections.synchronizedList(mutableListOf()) + + private fun run(budgetMs: Long, work: () -> Unit) = runReceiverWork( + budgetMs, + { r -> Thread(r).start() }, + { warnings.add(it) }, + { _, e -> errors.add(e) }, + { + finishes.incrementAndGet() + finished.countDown() + }, + work, + ) + + @Test(timeout = 10_000) + fun finishesAfterTheWorkOffTheCallingThread() { + val ranOn = AtomicReference() + run(10_000) { ranOn.set(Thread.currentThread()) } + assertTrue(finished.await(5, TimeUnit.SECONDS)) + assertTrue(ranOn.get() != Thread.currentThread()) + Thread.sleep(50) + assertEquals(1, finishes.get()) + assertTrue(warnings.isEmpty()) + } + + @Test(timeout = 10_000) + fun finishesAndLogsWhenTheWorkThrows() { + val failure = IllegalStateException("boom") + run(10_000) { throw failure } + assertTrue(finished.await(5, TimeUnit.SECONDS)) + assertEquals(listOf(failure), errors.toList()) + assertEquals(1, finishes.get()) + } + + @Test(timeout = 10_000) + fun finishesAtTheBudgetWhileTheWorkIsStillRunning() { + val release = CountDownLatch(1) + val workDone = CountDownLatch(1) + run(100) { + release.await(5, TimeUnit.SECONDS) + workDone.countDown() + } + assertTrue(finished.await(5, TimeUnit.SECONDS)) + assertEquals(1, warnings.size) + release.countDown() + assertTrue(workDone.await(5, TimeUnit.SECONDS)) + Thread.sleep(50) + assertEquals("finishes once", 1, finishes.get()) + } +} diff --git a/shared/android/app/src/test/java/io/keybase/ossifrage/modules/BackgroundSyncScheduleTest.kt b/shared/android/app/src/test/java/io/keybase/ossifrage/modules/BackgroundSyncScheduleTest.kt new file mode 100644 index 000000000000..42e7f560956f --- /dev/null +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/modules/BackgroundSyncScheduleTest.kt @@ -0,0 +1,64 @@ +package io.keybase.ossifrage.modules + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class BackgroundSyncScheduleTest { + private val calls = mutableListOf() + private var enqueueFails = false + private var done = false + + private val jobs = object : BackgroundSyncJobs { + override fun cancelAll() { + calls.add("cancelAll") + } + + override fun enqueueUnique() { + if (enqueueFails) throw IllegalStateException("enqueue failed") + calls.add("enqueueUnique") + } + } + + private val flag = object : LegacyJobsCleanupFlag { + override fun isDone() = done + + override fun markDone() { + done = true + } + } + + @Test + fun firstRunCancelsLegacyJobsThenEnqueues() { + scheduleBackgroundSync(jobs, flag) + assertEquals(listOf("cancelAll", "enqueueUnique"), calls) + assertTrue(done) + } + + @Test + fun laterRunsOnlyEnqueue() { + scheduleBackgroundSync(jobs, flag) + calls.clear() + scheduleBackgroundSync(jobs, flag) + scheduleBackgroundSync(jobs, flag) + assertEquals(listOf("enqueueUnique", "enqueueUnique"), calls) + } + + @Test + fun failedEnqueueRetriesTheCleanupNextRun() { + enqueueFails = true + try { + scheduleBackgroundSync(jobs, flag) + fail("the failure propagates") + } catch (e: IllegalStateException) { + } + assertFalse(done) + enqueueFails = false + calls.clear() + scheduleBackgroundSync(jobs, flag) + assertEquals(listOf("cancelAll", "enqueueUnique"), calls) + assertTrue(done) + } +} 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/chat/conversation/thread-message-state.test.tsx b/shared/chat/conversation/thread-message-state.test.tsx index 8ba2bfb62629..76def478788f 100644 --- a/shared/chat/conversation/thread-message-state.test.tsx +++ b/shared/chat/conversation/thread-message-state.test.tsx @@ -673,3 +673,74 @@ describe('addMessagesToThreadState', () => { expect(merged?.type === 'text' && merged.text.stringValue()).toBe('edited') }) }) + +// The service keeps its last-bound address in Info()/getURL once it has bound (go/kbhttp/manager +// Srv.Info), so an update takes whatever URL the service sent. +describe('local server urls', () => { + const textAt = (ord: number, override?: Omit, 'text'>) => + makeTextMessage({ + id: T.Chat.numberToMessageID(ord), + ordinal: T.Chat.numberToOrdinal(ord), + outboxID: undefined, + ...override, + }) + const attachmentOrdinal = T.Chat.numberToOrdinal(201) + + test('a new non-empty url replaces the old one', () => { + const state = makeThreadState([]) + addMessagesToThreadState(state, [makeAttachmentMessage({fileURL: 'http://127.0.0.1:5000/f'})], {}) + addMessagesToThreadState(state, [makeAttachmentMessage({fileURL: 'http://127.0.0.1:6000/f'})], {}) + expect((state.messageMap.get(attachmentOrdinal) as T.Chat.MessageAttachment).fileURL).toBe( + 'http://127.0.0.1:6000/f' + ) + }) + + test('an empty url in an update overwrites an existing one', () => { + const state = makeThreadState([]) + addMessagesToThreadState( + state, + [makeAttachmentMessage({fileURL: 'http://127.0.0.1:5000/f', previewURL: 'http://127.0.0.1:5000/p'})], + {} + ) + addMessagesToThreadState( + state, + [makeAttachmentMessage({fileURL: '', previewURL: '', title: 'renamed'})], + {} + ) + const m = state.messageMap.get(attachmentOrdinal) as T.Chat.MessageAttachment + expect(m.fileURL).toBe('') + expect(m.previewURL).toBe('') + expect(m.title).toBe('renamed') + }) + + test('reactions take the incoming decoration on a merge and on a reaction update', () => { + const reaction = (decorated: string, users: Array): T.Chat.ReactionDesc => ({ + decorated, + users: users.map((username, i) => ({timestamp: i + 1, username})), + }) + const state = makeThreadState([]) + addMessagesToThreadState( + state, + [textAt(10, {reactions: new Map([[':party:', reaction(':party:', ['testuser'])]])})], + {} + ) + addMessagesToThreadState( + state, + [textAt(10, {reactions: new Map([[':party:', reaction('', ['testuser', 'testuser-mac'])]])})], + {} + ) + const merged = (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).reactions?.get(':party:') + expect(merged?.decorated).toBe('') + expect(merged?.users.map(u => u.username)).toEqual(['testuser', 'testuser-mac']) + + updateReactionsInThreadState(state, [ + { + reactions: new Map([[':party:', reaction(':party:', ['testuser'])]]), + targetMsgID: T.Chat.numberToMessageID(10), + }, + ]) + const updated = (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).reactions?.get(':party:') + expect(updated?.decorated).toBe(':party:') + expect(updated?.users.map(u => u.username)).toEqual(['testuser']) + }) +}) diff --git a/shared/chat/conversation/thread-message-state.tsx b/shared/chat/conversation/thread-message-state.tsx index a2c575bb253b..bcda97f9e89b 100644 --- a/shared/chat/conversation/thread-message-state.tsx +++ b/shared/chat/conversation/thread-message-state.tsx @@ -573,8 +573,9 @@ export const updateReactionsInThreadState = ( ) const newReactions = new Map() for (const emoji of existingOrder) { - if (reactions.has(emoji)) { - newReactions.set(emoji, reactions.get(emoji)!) + const incoming = reactions.get(emoji) + if (incoming) { + newReactions.set(emoji, incoming) } } const remainingEmojis = [...reactions.keys()].filter(emoji => !newReactions.has(emoji)) diff --git a/shared/common-adapters/image.tsx b/shared/common-adapters/image.tsx index 26d22748be6b..3004aed5151e 100644 --- a/shared/common-adapters/image.tsx +++ b/shared/common-adapters/image.tsx @@ -3,6 +3,7 @@ import * as Styles from '@/styles' import type {ImageLoadEventData, ImageErrorEventData} from 'expo-image' import {Image as ExpoImage} from 'expo-image' import LoadingStateView from './loading-state-view' +import {isLocalhostSrc, retryLocalhostSrc} from './localhost-src' import type {StylesCrossPlatform} from '@/styles' import {useConfigState} from '@/stores/config' import {useShellState} from '@/stores/shell' @@ -48,13 +49,9 @@ const DesktopImage = (p: Props) => { ) } -// Srcs served by the local service http server can fail transiently: iOS stops that server -// on background/inactive and restarts it (new token, possibly new port) on foreground, so a -// load racing the restart gets connection refused. Those are worth retrying; remote srcs keep -// the old fail-once behavior. -const isLocalhostSrc = (src: Props['src']): src is string => - typeof src === 'string' && src.startsWith('http://127.0.0.1:') - +// Srcs served by the local service http server can fail transiently: iOS stops that server in +// the background and restarts it, possibly on a new port, so a load racing the restart gets +// connection refused. Those are worth retrying; remote srcs keep the old fail-once behavior. const maxRetries = 3 const NativeImage = (p: Props) => { @@ -63,6 +60,7 @@ const NativeImage = (p: Props) => { const [lastSrc, setLastSrc] = React.useState(src) const [attempt, setAttempt] = React.useState(0) const retryable = isLocalhostSrc(src) + const httpSrv = useConfigState(s => s.httpSrv) const failedRef = React.useRef(false) const triesRef = React.useRef(0) const timerRef = React.useRef>(undefined) @@ -106,8 +104,8 @@ const NativeImage = (p: Props) => { if (!retryable) return const maybeHeal = () => { if (!failedRef.current) return - // server is stopped while inactive/backgrounded; the active flip will land here again - if (useShellState.getState().mobileAppState !== 'active') return + // the server is stopped in the background; becoming active lands here again + if (useShellState.getState().mobileAppState === 'background') return failedRef.current = false triesRef.current = 0 setLoading(true) @@ -130,9 +128,8 @@ const NativeImage = (p: Props) => { } }, [retryable]) - // cache-buster forces expo-image to actually refetch; recyclingKey stays on the original - // src so the view isn't blanked by retries - const srcToUse = retryable && attempt > 0 ? `${src}${src.includes('?') ? '&' : '?'}kbRetry=${attempt}` : src + // recyclingKey stays on the original src so the view isn't blanked by retries + const srcToUse = retryable && attempt > 0 ? retryLocalhostSrc(src, attempt, httpSrv) : src const recyclingKey = typeof src === 'string' ? src : Array.isArray(src) ? src[0]?.uri : String(src) return ( diff --git a/shared/common-adapters/localhost-src.test.ts b/shared/common-adapters/localhost-src.test.ts new file mode 100644 index 000000000000..0f22e90ece6d --- /dev/null +++ b/shared/common-adapters/localhost-src.test.ts @@ -0,0 +1,38 @@ +/// +import {isLocalhostSrc, retryLocalhostSrc} from './localhost-src' + +const httpSrv = {address: '127.0.0.1:61234', token: 'newtoken'} + +test('only local service srcs are retryable', () => { + expect(isLocalhostSrc('http://127.0.0.1:5000/av?name=testuser')).toBe(true) + expect(isLocalhostSrc('https://keybase.io/images/testuser.png')).toBe(false) + expect(isLocalhostSrc(3)).toBe(false) +}) + +test('a retry points a baked attachment url at the current server port', () => { + const src = 'http://127.0.0.1:5000/att?key=abc&prev=true&noanim=false&isemoji=false' + expect(retryLocalhostSrc(src, 1, httpSrv)).toBe( + 'http://127.0.0.1:61234/att?key=abc&prev=true&noanim=false&isemoji=false&kbRetry=1' + ) +}) + +test('a retry replaces the token param when there is one', () => { + const src = 'http://127.0.0.1:5000/av?typ=user&name=testuser&token=oldtoken&count=0' + expect(retryLocalhostSrc(src, 2, httpSrv)).toBe( + 'http://127.0.0.1:61234/av?typ=user&name=testuser&token=newtoken&count=0&kbRetry=2' + ) +}) + +test('a service restart on the same port still carries the new token', () => { + const src = 'http://127.0.0.1:61234/av?typ=user&name=testuser&token=oldtoken&count=0' + expect(retryLocalhostSrc(src, 1, {address: '127.0.0.1:61234', token: 'newtoken'})).toBe( + 'http://127.0.0.1:61234/av?typ=user&name=testuser&token=newtoken&count=0&kbRetry=1' + ) +}) + +test('a retry keeps the baked address when the current one is unknown', () => { + const src = 'http://127.0.0.1:5000/att?key=abc' + expect(retryLocalhostSrc(src, 1, {address: '', token: ''})).toBe( + 'http://127.0.0.1:5000/att?key=abc&kbRetry=1' + ) +}) diff --git a/shared/common-adapters/localhost-src.tsx b/shared/common-adapters/localhost-src.tsx new file mode 100644 index 000000000000..390f5f24c8f0 --- /dev/null +++ b/shared/common-adapters/localhost-src.tsx @@ -0,0 +1,22 @@ +const localhostPrefix = /^http:\/\/127\.0\.0\.1:\d+/ + +export const isLocalhostSrc = (src: unknown): src is string => + typeof src === 'string' && localhostPrefix.test(src) + +// The service can restart its http server on a new port, but chat bakes the address into +// attachment and emoji URLs, so a retry points the src at wherever the server is now. A bare +// service-process restart keeps the port but mints a new per-process token (see +// go/kbhttp/manager/manager.go), and a reconnect alone doesn't refetch already-rendered thread +// data, so the token also needs rewriting or a stale token= keeps failing forever. The +// cache-buster forces expo-image to actually refetch. +export const retryLocalhostSrc = ( + src: string, + attempt: number, + httpSrv: {address: string; token: string} +) => { + let next = httpSrv.address ? src.replace(localhostPrefix, `http://${httpSrv.address}`) : src + if (httpSrv.token) { + next = next.replace(/([?&]token=)[^&#]*/, `$1${httpSrv.token}`) + } + return `${next}${next.includes('?') ? '&' : '?'}kbRetry=${attempt}` +} diff --git a/shared/constants/deeplinks.test.ts b/shared/constants/deeplinks.test.ts new file mode 100644 index 000000000000..d8c6b83c8f9b --- /dev/null +++ b/shared/constants/deeplinks.test.ts @@ -0,0 +1,61 @@ +/// +jest.mock('./router', () => ({ + navUpToScreen: jest.fn(), + navigateAppend: jest.fn(), + navigateToThread: jest.fn(), + navToProfile: jest.fn(), + previewConversation: jest.fn(), + switchTab: jest.fn(), +})) +jest.mock('@/teams/team-page-actions', () => ({showTeamByName: jest.fn()})) +import * as Router from './router' +import * as Tabs from './tabs' +import {settingsDevicesTab} from './settings' +import {handleAppLink} from './deeplinks' + +const withIsMobile = (isMobile: boolean, f: () => void) => { + const was = global.isMobile + global.isMobile = isMobile + try { + f() + } finally { + global.isMobile = was + } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +// On desktop handleAppLink IS the linking subscription's listener (router.tsx passes it as +// both listener and fallback), so this case is the whole implementation there. +test('a devices link opens the devices tab on desktop', () => { + withIsMobile(false, () => { + handleAppLink('keybase://devices') + + expect(Router.switchTab).toHaveBeenCalledWith(Tabs.devicesTab) + expect(Router.navUpToScreen).toHaveBeenCalledWith('devicesRoot') + expect(Router.navigateAppend).not.toHaveBeenCalled() + }) +}) + +test('a devices link opens the devices screen under settings on mobile', () => { + withIsMobile(true, () => { + handleAppLink('keybase://devices') + + expect(Router.switchTab).toHaveBeenCalledWith(Tabs.settingsTab) + expect(Router.navUpToScreen).toHaveBeenCalledWith(settingsDevicesTab) + expect(Router.navigateAppend).not.toHaveBeenCalled() + }) +}) + +// The invite install link normalizes to this; the linking config handles it on mobile, but +// desktop routes every URL through here, so both have to agree on where it goes. +test('an add-phone link opens the add-phone modal over settings', () => { + withIsMobile(false, () => { + handleAppLink('keybase://settingsAddPhone') + + expect(Router.switchTab).toHaveBeenCalledWith(Tabs.settingsTab) + expect(Router.navigateAppend).toHaveBeenCalledWith({name: 'settingsAddPhone', params: {}}) + }) +}) diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index df62b988a6c1..06f27fb0d213 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -1,7 +1,15 @@ import logger from '@/logger' import * as T from '@/constants/types' -import {navigateAppend, navigateToThread, navToProfile, previewConversation, switchTab} from './router' +import { + navigateAppend, + navigateToThread, + navToProfile, + navUpToScreen, + previewConversation, + switchTab, +} from './router' import * as Tabs from './tabs' +import {settingsDevicesTab} from './settings' import {showTeamByName} from '@/teams/team-page-actions' const prefix = 'keybase://' @@ -75,6 +83,17 @@ const handleKeybaseLink = (link: string) => { return } break + case 'devices': + // Devices live under Settings on phone/tablet and in their own tab on desktop. + switchTab(isMobile ? Tabs.settingsTab : Tabs.devicesTab) + navUpToScreen(isMobile ? settingsDevicesTab : 'devicesRoot') + return + case 'settingsAddPhone': + // Where the invite install link (https://keybase.io/phone-app) lands. The linking config + // also handles it; desktop routes every URL here, so this must agree with it. + switchTab(Tabs.settingsTab) + navigateAppend({name: 'settingsAddPhone', params: {}}) + return case 'private': case 'public': try { diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts new file mode 100644 index 000000000000..89127cf7cbe5 --- /dev/null +++ b/shared/constants/init/app-state.test.ts @@ -0,0 +1,71 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useShellState} from '@/stores/shell' +import {applyClientState, 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('arrives in the clientState, which is what catches a late-started JS up', () => { + _onEngineIncoming({ + payload: {params: {state: {appState: T.RPCGen.MobileAppState.background}}}, + type: 'keybase.1.NotifyApp.clientState', + } as never) + expect(useShellState.getState().mobileAppState).toBe('background') + }) + + test('a notification after the clientState replaces it', () => { + applyClientState({appState: T.RPCGen.MobileAppState.background}) + applyMobileAppState(T.RPCGen.MobileAppState.inactive) + expect(useShellState.getState().mobileAppState).toBe('inactive') + }) + + 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 51c81055da2d..af9c148bef3e 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -66,6 +66,23 @@ const ensureBackgroundTask = (ExpoTaskManager: ExpoTaskManagerModule) => { }) } +// Builds from before native iOS location left this expo task registered, and expo restores it +// into a second CLLocationManager on every launch. JS is early enough to remove it: with no +// UMAppLoader registered, expo can never start JS for a restored task on a background launch +// (expo-task-manager EXTaskService.m `_loadAppWithId:appUrl:`). +export const unregisterLegacyIOSLocationTask = async () => { + if (!isIOS) return + const {ExpoTaskManager} = _getNative() + try { + if (await ExpoTaskManager.isTaskRegisteredAsync(locationTaskName)) { + await ExpoTaskManager.unregisterTaskAsync(locationTaskName) + logger.info('[location] removed the legacy iOS background location task') + } + } catch (error) { + logger.info('[location] failed to remove the legacy iOS background location task: ' + String(error)) + } +} + const setPermissionDeniedCommandStatus = (conversationIDKey: T.Chat.ConversationIDKey, text: string) => { setThreadInputCommandStatus(conversationIDKey, { actions: [T.RPCChat.UICommandStatusActionTyp.appsettings], @@ -92,6 +109,9 @@ const onChatWatchPosition = async ( ) } + // iOS watches location natively (ios/Keybase/LocationWatcher.swift), so JS only asks for permission. + if (isIOS) return + locationRefs++ if (locationRefs === 1) { @@ -112,6 +132,7 @@ const onChatWatchPosition = async ( } const onChatClearWatch = async () => { + if (isIOS) return const {ExpoLocation, ExpoTaskManager} = _getNative() locationRefs-- if (locationRefs <= 0) { @@ -131,7 +152,6 @@ const onChatClearWatch = async () => { const loadStartupDetails = async () => { logger.info('[Startup] loadStartupDetails: starting') const {guiConfig, Linking} = _getNative() - const {getStartupDetailsFromInitialPush} = await import('./push-listener.native') let routeState = '' try { @@ -139,37 +159,28 @@ const loadStartupDetails = async () => { routeState = config?.ui?.routeState2 ?? '' } catch {} - const [initialUrl, push] = await Promise.all([ - neverThrowPromiseFunc(async () => { - const linkingStart = Date.now() - logger.info('[Startup] loadStartupDetails: calling Linking.getInitialURL') - const url = await Linking.getInitialURL() - const elapsed = Date.now() - linkingStart - if (url === null) { - logger.warn(`[Startup] loadStartupDetails: Linking.getInitialURL returned null in ${elapsed}ms`) - } else { - logger.info(`[Startup] loadStartupDetails: Linking.getInitialURL returned in ${elapsed}ms: ${url}`) - } - return url - }), - neverThrowPromiseFunc(getStartupDetailsFromInitialPush), - ] as const) + // A tapped push doesn't pass through here: the service resolves it and constants/init/shared + // takes it, queuing it as a navigation intent. + const initialUrl = await neverThrowPromiseFunc(async () => { + const linkingStart = Date.now() + logger.info('[Startup] loadStartupDetails: calling Linking.getInitialURL') + const url = await Linking.getInitialURL() + const elapsed = Date.now() - linkingStart + if (url === null) { + logger.warn(`[Startup] loadStartupDetails: Linking.getInitialURL returned null in ${elapsed}ms`) + } else { + logger.info(`[Startup] loadStartupDetails: Linking.getInitialURL returned in ${elapsed}ms: ${url}`) + } + return url + }) let conversation: T.Chat.ConversationIDKey | undefined let conversationUid = '' - let followUser = '' - let link = '' let tab = '' - // Top priority, push - if (push) { - logger.info('initialState: push', push.startupConversation, push.startupFollowUser) - conversation = push.startupConversation - followUser = push.startupFollowUser ?? '' - } else if (initialUrl) { - // Second priority, deep link - link = initialUrl - } else if (routeState) { + // The linking config reads the launch URL itself; this read only decides whether the + // saved route may be restored, since a launch URL outranks it. + if (!initialUrl && routeState) { // Last priority, saved from last session try { const item = JSON.parse(routeState) as @@ -202,8 +213,6 @@ const loadStartupDetails = async () => { useConfigState.getState().dispatch.setStartupDetails({ conversation: conversation ?? noConversationIDKey, conversationUid, - followUser, - link, tab: tab as Tabs.Tab, }) @@ -343,28 +352,26 @@ export const initPlatformListener = () => { } const _initNativePlatformListener = () => { - useShellState.subscribe((s, old) => { + // HMR cleanup: unsubscribe old store subscriptions before re-subscribing + for (const unsub of _platformUnsubs) unsub() + _platformUnsubs.length = 0 + + _platformUnsubs.push(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 = () => { const {fsCacheDir, fsDownloadDir} = _getNativeSync() @@ -387,7 +394,7 @@ const _initNativePlatformListener = () => { } } - useConfigState.subscribe((s, old) => { + _platformUnsubs.push(useConfigState.subscribe((s, old) => { if (s.loggedIn === old.loggedIn) return const f = async () => { const {NetInfo} = _getNative() @@ -399,9 +406,9 @@ const _initNativePlatformListener = () => { ) } ignorePromise(f()) - }) + })) - useShellState.subscribe((s, old) => { + _platformUnsubs.push(useShellState.subscribe((s, old) => { if (s.networkStatus === old.networkStatus) return const type = s.networkStatus?.type if (!type) return @@ -413,27 +420,19 @@ 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) => { + _platformUnsubs.push(useDarkModeState.subscribe((s, old) => { if (s.darkModePreference === old.darkModePreference) return const {androidAppColorSchemeChanged} = _getNativeSync() androidAppColorSchemeChanged(s.darkModePreference) - }) + })) } // we call this when we're logged in. let calledShareListenersRegistered = false - useRouterState.subscribe((s, old) => { + _platformUnsubs.push(useRouterState.subscribe((s, old) => { const next = s.navState const prev = old.navState if (next === prev) return @@ -444,13 +443,13 @@ const _initNativePlatformListener = () => { const {shareListenersRegistered} = _getNativeSync() shareListenersRegistered() } - }) + })) // Default to screen capture prevention on Android (matches native default of secure). // Once daemon is ready, sync with the user's saved preference. if (isAndroid) { ignorePromise(ScreenCapture.preventScreenCaptureAsync('screenprotector')) - useDaemonState.subscribe((s, old) => { + _platformUnsubs.push(useDaemonState.subscribe((s, old) => { if (s.handshakeState !== 'done' || old.handshakeState === 'done') return const f = async () => { const {getSecureFlagSetting} = await import('@/constants/platform') @@ -461,18 +460,22 @@ const _initNativePlatformListener = () => { } } ignorePromise(f()) - }) + })) } // Start this immediately instead of waiting so we can do more things in parallel ignorePromise(loadStartupDetails()) - initPushListener() + _platformUnsubs.push(...initPushListener()) + + ignorePromise(unregisterLegacyIOSLocationTask()) const {NetInfo} = _getNative() - NetInfo.addEventListener(({type}) => { - useShellState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type) - }) + _platformUnsubs.push( + NetInfo.addEventListener(({type}) => { + useShellState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type) + }) + ) const {setupAudioMode} = _getNative() ignorePromise(setupAudioMode(false)) @@ -591,8 +594,6 @@ const _initDesktopPlatformListener = () => { if (s.handshakeState !== old.handshakeState && s.handshakeState === 'done') { useConfigState.getState().dispatch.setStartupDetails({ conversation: Chat.noConversationIDKey, - followUser: '', - link: '', tab: undefined, }) } diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts new file mode 100644 index 000000000000..55868d1fd195 --- /dev/null +++ b/shared/constants/init/location-watch.test.ts @@ -0,0 +1,129 @@ +/// +import type * as Init from './index' +import type * as EngineGen from '@/constants/rpc' + +// The init module picks its mobile behavior from the platform globals, so each test loads it +// fresh with them set and the native modules mocked. + +const calls = new Array() +const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} + +const load = (platform: 'ios' | 'android'): typeof Init => { + global.isMobile = true + global.isIOS = platform === 'ios' + global.isAndroid = platform === 'android' + jest.resetModules() + jest.doMock('./platform', () => ({ + getNative: () => ({ + ExpoLocation: { + startLocationUpdatesAsync: async () => { + calls.push('startLocationUpdates') + return Promise.resolve() + }, + stopLocationUpdatesAsync: async () => { + calls.push('stopLocationUpdates') + return Promise.resolve() + }, + }, + ExpoTaskManager: { + defineTask: () => { + calls.push('defineTask') + }, + // Registered until it is unregistered, like the real task store. + isTaskRegisteredAsync: async () => { + calls.push('isTaskRegistered') + return Promise.resolve(!calls.includes('unregisterTask')) + }, + unregisterTaskAsync: async () => { + calls.push('unregisterTask') + return Promise.resolve() + }, + }, + requestLocationPermission: async (perm: unknown) => { + calls.push(`requestPermission:${String(perm)}`) + return Promise.resolve() + }, + }), + })) + jest.doMock('./shared', () => ({ + _onEngineIncoming: () => {}, + })) + // pulls in the mobile theme, which needs more of react-native than the test mock has + jest.doMock('@/fs/common/lifecycle', () => ({})) + return require('./index') as typeof Init +} + +const watchPosition = () => + ({ + payload: { + params: {convID: new Uint8Array([0xaa, 0xbb]), perm: 1}, + response: { + result: () => { + calls.push('result') + }, + }, + }, + type: 'chat.1.chatUi.chatWatchPosition', + }) as unknown as EngineGen.Actions + +const clearWatch = () => + ({ + payload: {params: {id: 1}, response: {result: () => {}}}, + type: 'chat.1.chatUi.chatClearWatch', + }) as unknown as EngineGen.Actions + +const flush = async () => new Promise(resolve => setTimeout(resolve, 0)) + +afterEach(() => { + calls.length = 0 + jest.dontMock('./platform') + jest.dontMock('./shared') + jest.dontMock('@/fs/common/lifecycle') + jest.resetModules() + global.isMobile = originalGlobals.isMobile + global.isIOS = originalGlobals.isIOS + global.isAndroid = originalGlobals.isAndroid +}) + +test('iOS only asks for permission; the native watcher runs location', async () => { + const init = load('ios') + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(clearWatch()) + await flush() + + expect(calls).toEqual(['result', 'requestPermission:1']) +}) + +test('Android asks for permission and runs the expo location task', async () => { + const init = load('android') + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(clearWatch()) + await flush() + + expect(calls).toEqual([ + 'result', + 'requestPermission:1', + 'defineTask', + 'startLocationUpdates', + 'stopLocationUpdates', + ]) +}) + +// The second run stands for every launch after the cleanup: unregistering a task that is gone +// throws E_TASK_NOT_FOUND, so it must not be asked for again. +test('iOS removes the legacy expo background location task once', async () => { + const init = load('ios') + await init.unregisterLegacyIOSLocationTask() + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual(['isTaskRegistered', 'unregisterTask', 'isTaskRegistered']) +}) + +test('Android keeps its expo background location task', async () => { + const init = load('android') + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual([]) +}) diff --git a/shared/constants/init/platform-types.ts b/shared/constants/init/platform-types.ts index 8b44511da42a..8831418f3305 100644 --- a/shared/constants/init/platform-types.ts +++ b/shared/constants/init/platform-types.ts @@ -14,6 +14,8 @@ export type NetInfoModule = { } export type ExpoTaskManagerModule = { defineTask: (taskName: string, cb: (params: {data: unknown; error: unknown}) => Promise) => void + isTaskRegisteredAsync: (taskName: string) => Promise + unregisterTaskAsync: (taskName: string) => Promise } export type DesktopModules = { diff --git a/shared/constants/init/platform.desktop.tsx b/shared/constants/init/platform.desktop.tsx index 9bd3e66f119f..f3787c83d33c 100644 --- a/shared/constants/init/platform.desktop.tsx +++ b/shared/constants/init/platform.desktop.tsx @@ -17,7 +17,7 @@ export const getDesktop = (): DesktopModules => export {maybePauseVideos, setupWindowEventListeners} from './desktop-dom-helpers.desktop' // push notifications are native-only. -export const initPushListener = (): void => {} +export const initPushListener = (): Array<() => void> => [] const notOnDesktop = (name: string): never => { throw new Error(`init/${name} called on desktop`) diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index 5d1d83bd6346..2313646d3ed8 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -1,14 +1,13 @@ import * as T from '@/constants/types' -import {ignorePromise, timeoutPromise} from '@/constants/utils' +import {ignorePromise} from '@/constants/utils' import logger from '@/logger' -import {emitDeepLink} from '@/router-v2/linking' +import {emitDeepLink} from '@/router-v2/deep-link-emitter' +import {subscribeIntentAccountSwitch} from '@/router-v2/account-link-switch' import { getRegistrationToken, setApplicationIconBadgeNumber, - onPushNotification, onPushToken, onShareData, - getInitialNotification, removeAllPendingNotificationRequests, } from 'react-native-kb' import {useConfigState} from '@/stores/config' @@ -16,339 +15,98 @@ import {useCurrentUserState} from '@/stores/current-user' import {usePushState} from '@/stores/push' import {useShellState} from '@/stores/shell' -type DataCommon = { - userInteraction: boolean -} -type DataReadMessage = DataCommon & { - type: 'chat.readmessage' - b: string | number - i?: string -} -type DataNewMessage = DataCommon & { - type: 'chat.newmessage' - convID?: string - t: string | number - m: string -} -type DataNewMessageSilent2 = DataCommon & { - type: 'chat.newmessageSilent_2' - t: string | number - c?: string - m: string -} -type DataFollow = DataCommon & { - type: 'follow' - targetUID?: string - username?: string -} -type DataChatExtension = DataCommon & { - type: 'chat.extension' - convID?: string -} -type DataDeviceRevoked = DataCommon & { - type: 'device.revoked' - device_id?: string -} -type DataDeviceNew = DataCommon & { - type: 'device.new' - device_id?: string -} -type DataAutoreset = DataCommon & { - type: 'autoreset' -} -type Data = - | DataReadMessage - | DataNewMessage - | DataNewMessageSilent2 - | DataFollow - | DataChatExtension - | DataDeviceRevoked - | DataDeviceNew - | DataAutoreset - -type PushN = Data & { - message?: string -} - -const anyToConversationMembersType = (a: string | number): T.RPCChat.ConversationMembersType | undefined => { - const membersTypeNumber: T.RPCChat.ConversationMembersType = - typeof a === 'string' ? parseInt(a, 10) : a || -1 - switch (membersTypeNumber) { - case T.RPCChat.ConversationMembersType.kbfs: - return T.RPCChat.ConversationMembersType.kbfs - case T.RPCChat.ConversationMembersType.team: - return T.RPCChat.ConversationMembersType.team - case T.RPCChat.ConversationMembersType.impteamnative: - return T.RPCChat.ConversationMembersType.impteamnative - case T.RPCChat.ConversationMembersType.impteamupgrade: - return T.RPCChat.ConversationMembersType.impteamupgrade - default: - return undefined - } -} -const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { - try { - if (!_n) { - return undefined - } - - const data = _n as PushN - const userInteraction = !!data.userInteraction - const dataUid = data as {uid?: string; targetUID?: string} - const forUid = dataUid.uid - - switch (data.type) { - case 'chat.readmessage': { - const badges = typeof data.b === 'string' ? parseInt(data.b) : data.b - return { - badges, - forUid: data.i, - type: 'chat.readmessage', - } as const - } - case 'chat.newmessage': - return data.convID - ? { - conversationIDKey: T.Chat.stringToConversationIDKey(data.convID), - forUid, - membersType: anyToConversationMembersType(data.t), - type: 'chat.newmessage', - unboxPayload: data.m || '', - userInteraction, - } - : undefined - case 'chat.newmessageSilent_2': - if (data.c) { - const membersType = anyToConversationMembersType(data.t) - if (membersType) { - return { - conversationIDKey: T.Chat.stringToConversationIDKey(data.c), - membersType, - type: 'chat.newmessageSilent_2', - unboxPayload: data.m || '', - } - } - } - return undefined - case 'follow': - return data.username - ? { - forUid: forUid ?? dataUid.targetUID, - type: 'follow', - userInteraction, - username: data.username, - } - : undefined - case 'device.revoked': - return forUid - ? { - forUid, - type: 'device.revoked', - userInteraction, - } - : undefined - case 'device.new': - return forUid - ? { - forUid, - type: 'device.new', - userInteraction, - } - : undefined - case 'autoreset': - return forUid - ? { - forUid, - type: 'autoreset', - userInteraction, - } - : undefined - case 'chat.extension': - return data.convID - ? { - conversationIDKey: T.Chat.stringToConversationIDKey(data.convID), - forUid, - type: 'chat.extension', - } - : undefined - default: - { - const unk = data as any - if (typeof unk.message === 'string' && unk.message.startsWith('Your contact') && userInteraction) { - return { - type: 'settings.contacts', - } - } - } - - return undefined - } - } catch (e) { - logger.error('Error handling push', e) - return undefined - } -} - -const getInitialPush = async () => { - const n = await getInitialNotification() - return n ? normalizePush(n) : undefined -} -const getStartupDetailsFromInitialPush = async () => { - const notification = await Promise.race([getInitialPush(), timeoutPromise(10)]) - if (!notification) { - return - } - - if (notification.type === 'follow') { - if (notification.username) { - return {startupFollowUser: notification.username} - } - } else if (notification.type === 'chat.newmessage' || notification.type === 'chat.newmessageSilent_2') { - if (notification.conversationIDKey) { - // For chat.newmessage with forUid, route through the pending-notification - // subscribers so account-switching logic runs if the notification is for a - // different account. Returning startupConversation here would navigate to a - // conversation in the wrong account before the switch can happen. - if (notification.type === 'chat.newmessage' && notification.forUid) { - usePushState.getState().dispatch.setPendingPushNotification(notification) - return - } - return { - startupConversation: notification.conversationIDKey, - startupPushPayload: notification.unboxPayload, - } - } - } - - return -} - export const initPushListener = () => { + const unsubs: Array<() => void> = [] // Permissions - useShellState.subscribe((s, old) => { - if (s.mobileAppState === old.mobileAppState) return - // Only recheck on foreground, not background - if (s.mobileAppState !== 'active') { - logger.info('[PushCheck] skip on backgrounding') - return - } - logger.debug(`[PushCheck] checking on foreground`) - usePushState - .getState() - .dispatch.checkPermissions() - .then(() => {}) - .catch(() => {}) - }) + unsubs.push( + useShellState.subscribe((s, old) => { + if (s.mobileAppState === old.mobileAppState) return + // Only recheck on foreground, not background + if (s.mobileAppState !== 'active') { + logger.info('[PushCheck] skip on backgrounding') + return + } + logger.debug(`[PushCheck] checking on foreground`) + usePushState + .getState() + .dispatch.checkPermissions() + .then(() => {}) + .catch(() => {}) + }) + ) let lastCount = -1 - useConfigState.subscribe((s, old) => { - if (s.badgeState === old.badgeState) return - if (!s.badgeState) return - const count = s.badgeState.bigTeamBadgeCount + s.badgeState.smallTeamBadgeCount - setApplicationIconBadgeNumber(count) - // Only do this native call if the count actually changed, not over and over if its zero - if (count === 0 && lastCount !== 0) { - removeAllPendingNotificationRequests() - } - lastCount = count - }) - - // Retry token upload when user state becomes available. - // The FCM token often arrives before username/deviceID are loaded, - // so the initial upload silently bails. This retries once user state is ready. - useCurrentUserState.subscribe((s, old) => { - if (s.username === old.username && s.deviceID === old.deviceID) return - const token = usePushState.getState().token - if (token && s.username && s.deviceID) { - usePushState.getState().dispatch.setPushToken(token) - } - }) + unsubs.push( + useConfigState.subscribe((s, old) => { + if (s.badgeState === old.badgeState) return + if (!s.badgeState) return + const count = s.badgeState.bigTeamBadgeCount + s.badgeState.smallTeamBadgeCount + setApplicationIconBadgeNumber(count) + // Only do this native call if the count actually changed, not over and over if its zero + if (count === 0 && lastCount !== 0) { + removeAllPendingNotificationRequests() + } + lastCount = count + }) + ) + + // Not a native-readiness retry: native parks the token and getRegistrationToken reads it + // back, so the token itself is never lost. What the upload waits on is username/deviceID, + // which the token routinely beats, so setPushToken's upload bails. Re-run it once the + // account it has to be filed under exists. + unsubs.push( + useCurrentUserState.subscribe((s, old) => { + if (s.username === old.username && s.deviceID === old.deviceID) return + const token = usePushState.getState().token + if (token && s.username && s.deviceID) { + usePushState.getState().dispatch.setPushToken(token) + } + }) + ) usePushState.getState().dispatch.initialPermissionsCheck() - // When current-user.uid changes, run pending push if it was for this account. - useCurrentUserState.subscribe((s, old) => { - if (s.uid === old.uid) return - const pushState = usePushState.getState() - const pending = pushState.pendingPushNotification - if (!pending || !('forUid' in pending)) return - const forUid = (pending as {forUid?: string}).forUid - if (!forUid || forUid !== s.uid) return - pushState.dispatch.clearPendingPushNotification() - // Replay while switching remains true. The replacement NavigationContainer - // clears it from onReady, so the intent cannot be consumed by the old router. - pushState.dispatch.handlePush(pending) - }) - - useConfigState.subscribe((s, old) => { - if (s.configuredAccounts === old.configuredAccounts || s.userSwitching) return - const pushState = usePushState.getState() - const pending = pushState.pendingPushNotification - if (!pending || !('forUid' in pending)) return - const forUid = (pending as {forUid?: string}).forUid - if (!forUid || forUid === useCurrentUserState.getState().uid) return - const account = s.configuredAccounts.find(acc => acc.uid === forUid) - if (!account?.hasStoredSecret) return - pushState.dispatch.handlePush(pending) - }) + // Taps are taken from the service in constants/init/shared; this only has to be watching the + // intent store by the time one lands, and its own first check covers anything already queued. + unsubs.push(subscribeIntentAccountSwitch()) - useConfigState.subscribe((s, old) => { - if (s.loggedIn === old.loggedIn) return - if (!s.loggedIn && !s.userSwitching) { - usePushState.getState().dispatch.clearPendingPushNotification() - } - }) - - const listenNative = async () => { - // Set up listener immediately, before waiting for token - // This ensures notifications aren't lost if they arrive before token is ready - const onNotification = (n: object) => { - logger.debug('[onNotification]: ', n) - const notification = normalizePush(n) - if (!notification) { - logger.warn('[onNotification]: normalized notification is null/undefined') - return - } - usePushState.getState().dispatch.handlePush(notification) + try { + // Token and share listeners + if (isIOS) { + const tokenSub = onPushToken(token => { + logger.debug('[PushToken] received token via onPushToken event: ', token) + usePushState.getState().dispatch.setPushToken(token) + }) + unsubs.push(() => tokenSub.remove()) } - try { - // Unified push notification handling for both iOS and Android - // Silent notifications (chat.newmessageSilent_2) are handled entirely natively - // Other notification types are handled natively first, then emitted to JS via onPushNotification - onPushNotification(onNotification) - - if (isIOS) { - onPushToken(token => { - logger.debug('[PushToken] received token via onPushToken event: ', token) - usePushState.getState().dispatch.setPushToken(token) - }) - } - - if (isAndroid) { - onShareData(evt => { - const {setAndroidShare} = useConfigState.getState().dispatch + if (isAndroid) { + const shareSub = onShareData(evt => { + const {setAndroidShare} = useConfigState.getState().dispatch - const text = evt.text - const urls = evt.localPaths + const text = evt.text + const urls = evt.localPaths - if (urls) { - setAndroidShare({type: T.RPCGen.IncomingShareType.file, urls}) - } else if (text) { - setAndroidShare({text, type: T.RPCGen.IncomingShareType.text}) - } else { - return - } - emitDeepLink('keybase://incoming-share') - }) - // shareListenersRegistered() is deliberately NOT called here: the init/index.tsx - // router subscriber controls when native flushes pending share intents. - } - } catch (e) { - logger.error('[Push] failed to set up listeners: ', e) + if (urls) { + setAndroidShare({type: T.RPCGen.IncomingShareType.file, urls}) + } else if (text) { + setAndroidShare({text, type: T.RPCGen.IncomingShareType.text}) + } else { + return + } + emitDeepLink('keybase://incoming-share') + }) + unsubs.push(() => shareSub.remove()) + // shareListenersRegistered() is deliberately NOT called here: a parked share intent + // waits for JS to be able to route it, which is the router subscriber in init/index.tsx, + // not merely for this listener to exist. } + } catch (e) { + logger.error('[Push] failed to set up listeners: ', e) + } - // Get token after listener is set up (may fail if not ready yet, but listener is already active) + // Get token after listener is set up (may fail if not ready yet, but listener is already active) + const fetchToken = async () => { try { const pushToken = await getRegistrationToken() logger.debug('[PushToken] received new token: ', pushToken) @@ -358,7 +116,7 @@ export const initPushListener = () => { // Token will be retrieved later when permissions are checked } } - ignorePromise(listenNative()) -} + ignorePromise(fetchToken()) -export {getStartupDetailsFromInitialPush} + return unsubs +} diff --git a/shared/constants/init/push-tap.test.ts b/shared/constants/init/push-tap.test.ts new file mode 100644 index 000000000000..b0be49f5f06e --- /dev/null +++ b/shared/constants/init/push-tap.test.ts @@ -0,0 +1,272 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '@/stores/config' +import {useNavigationIntentsState} from '@/stores/navigation-intents' +import {onEngineConnected, _onEngineIncoming} from './shared' + +const g = globalThis as unknown as {isMobile: boolean} + +// The intent store remembers a push tap id for the life of the module, so ids must not repeat +// across tests any more than they do across taps. +let nextRouteID = 100 +const chatRoute = (): T.RPCGen.PushTapRoute => ({ + id: ++nextRouteID, + targetUID: 'uid-other', + url: 'keybase://convid/0000ab', +}) + +const nudge = () => + _onEngineIncoming({ + payload: {params: undefined}, + type: 'keybase.1.NotifyApp.pushTapRouteAvailable', + } as never) + +// The service's holder, as far as these tests are concerned: a peek reports what is armed, an ack +// retires it only if it is still the same tap. drainPushTapRoute only peeks and enqueues; the ack +// belongs to whichever consumer -- navigation, or account-link-switch dropping a tap it cannot act +// on -- actually resolves the intent. These tests exercise the service holder only to confirm that. +const serviceHolding = (route?: T.RPCGen.PushTapRoute) => { + let armed = route + // Both answer a microtask late, as a real RPC would: nothing here should depend on a reply + // landing in the same tick as the call. + const peek = jest + .spyOn(T.RPCGen, 'appStatePeekPushTapRouteRpcPromise') + .mockImplementation(async () => { + await Promise.resolve() + return armed ?? null + }) + const ack = jest + .spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise') + .mockImplementation(async (params?: {id: number}) => { + await Promise.resolve() + if (armed && params?.id === armed.id) { + armed = undefined + } + }) + return {ack, arm: (next: T.RPCGen.PushTapRoute) => (armed = next), isArmed: () => !!armed, peek} +} + +const settle = async () => new Promise(resolve => setImmediate(resolve)) + +// Wedges the store the route is queued into, which is the one thing between the peek and the +// enqueue that can throw. +const withEnqueueThrowing = () => { + const original = useNavigationIntentsState.getState().dispatch + useNavigationIntentsState.setState(state => { + state.dispatch = { + ...original, + enqueue: () => { + throw new Error('the store is wedged') + }, + } + }) + return () => + useNavigationIntentsState.setState(state => { + state.dispatch = original + }) +} + +const originalConfigDispatch = useConfigState.getState().dispatch + +// onEngineConnected's other work is not what is under test here; this is the same stubbing +// shared.test.ts does for it. +const stubConnect = () => { + for (const rpc of [ + 'delegateUiCtlRegisterChatUIRpcPromise', + 'delegateUiCtlRegisterLogUIRpcPromise', + 'delegateUiCtlRegisterHomeUIRpcPromise', + 'delegateUiCtlRegisterSecretUIRpcPromise', + 'delegateUiCtlRegisterIdentify3UIRpcPromise', + 'delegateUiCtlRegisterRekeyUIRpcPromise', + ] as const) { + jest.spyOn(T.RPCGen, rpc).mockResolvedValue(undefined) + } + useConfigState.setState(s => { + s.dispatch = {...originalConfigDispatch, onEngineConnected: () => {}} + }) + jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockRejectedValue(new Error('not under test')) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + loggedIn: true, + } as T.RPCGen.BootstrapStatus) +} + +beforeEach(() => { + g.isMobile = true + resetAllStores() +}) + +afterEach(() => { + g.isMobile = false + useConfigState.setState({dispatch: originalConfigDispatch}) + // Acknowledge any leftover intent while the service mock is still installed, so cleanup's own + // ack (if the intent carries one) hits the mock instead of a real, unmocked RPC call. + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) + jest.restoreAllMocks() + resetAllStores() +}) + +test('the nudge queues the armed route without acking it', async () => { + const route = chatRoute() + const service = serviceHolding(route) + + nudge() + await settle() + + expect(service.peek).toHaveBeenCalledTimes(1) + expect(service.ack).not.toHaveBeenCalled() + expect(service.isArmed()).toBe(true) + expect(useNavigationIntentsState.getState().intent).toMatchObject({ + targetUid: 'uid-other', + url: 'keybase://convid/0000ab', + }) +}) + +test('a tap waiting from before this connection is taken on connect', async () => { + stubConnect() + const route = chatRoute() + const service = serviceHolding(route) + + onEngineConnected() + await settle() + + expect(service.peek).toHaveBeenCalledTimes(1) + expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') + expect(service.ack).not.toHaveBeenCalled() +}) + +// The point of never clearing on read: a reply that never arrives must cost a repeat, not the tap. +// Nothing else in the app would say the tap had happened. +test('a peek whose reply is lost leaves the route armed for the next one', async () => { + const route = chatRoute() + const service = serviceHolding(route) + service.peek.mockRejectedValueOnce(new Error('disconnected')) + + nudge() + await settle() + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(service.isArmed()).toBe(true) + + // the next connection picks it up + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') +}) + +// Reading a route never retires it, so a second peek of the same still-armed id reaches enqueue +// again; the intent store, not this layer, turns it away because that id is already queued. +// drainPushTapRoute itself tracks nothing about what it has already seen. +test('a second peek of the same still-armed id does not enqueue a second intent', async () => { + const service = serviceHolding(chatRoute()) + + nudge() + await settle() + const first = useNavigationIntentsState.getState().intent + nudge() + await settle() + + expect(service.peek).toHaveBeenCalledTimes(2) + expect(useNavigationIntentsState.getState().intent).toBe(first) +}) + +// The older tap is replaced outright, not merged (a different URL), so it is given up on for +// good here: the store acks it even though the service already discarded that route itself when +// it armed the newer one. Acking a route the service no longer holds is a harmless no-op there. +test('a newer tap queued while the older one is still pending upgrades nothing away', async () => { + const route = chatRoute() + const service = serviceHolding(route) + + nudge() + await settle() + const devices = {id: ++nextRouteID, targetUID: 'uid-other', url: 'keybase://devices'} + service.arm(devices) + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://devices') + expect(service.ack).toHaveBeenCalledWith({id: route.id}) + expect(service.isArmed()).toBe(true) +}) + +// Leaving the route armed is what saves a lost peek, but it means a lost ack shows the same tap +// again. The intent store absorbs that by retrying only the ack, never the navigation, once the +// duplicate window has passed and the router has already consumed the intent. +test('a lost ack retries the ack without navigating again', async () => { + const route = chatRoute() + const service = serviceHolding(route) + service.ack.mockRejectedValueOnce(new Error('disconnected')) + + nudge() + await settle() + const first = useNavigationIntentsState.getState().intent + expect(first?.url).toBe('keybase://convid/0000ab') + expect(service.isArmed()).toBe(true) + + // the router consumes it and navigates, and time moves past the store's duplicate window + useNavigationIntentsState.getState().dispatch.acknowledge(first!.id) + const realNow = Date.now() + jest.spyOn(Date, 'now').mockReturnValue(realNow + 60_000) + + // the route is still armed, so the next peek sees it again + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(service.ack).toHaveBeenCalledTimes(2) + expect(service.isArmed()).toBe(false) +}) + +test('no waiting tap queues nothing', async () => { + const service = serviceHolding() + + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(service.ack).not.toHaveBeenCalled() +}) + +test('a route with no account is not a targeted intent', async () => { + serviceHolding({id: ++nextRouteID, targetUID: '', url: 'keybase://tabs.peopleTab'}) + + nudge() + await settle() + + const {intent} = useNavigationIntentsState.getState() + expect(intent?.url).toBe('keybase://tabs.peopleTab') + expect(intent?.targetUid).toBeUndefined() +}) + +test('desktop never asks for a tap', async () => { + g.isMobile = false + const service = serviceHolding(chatRoute()) + + nudge() + await settle() + + expect(service.peek).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +// A throw between the peek and the queue must not crash the notification handler, and since +// nothing here ever acks, the route is still armed for the next peek regardless. +test('an enqueue that throws leaves the route armed for the next peek', async () => { + const route = chatRoute() + const service = serviceHolding(route) + const restore = withEnqueueThrowing() + + nudge() + await settle() + + expect(service.ack).not.toHaveBeenCalled() + expect(service.isArmed()).toBe(true) + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + + restore() + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') +}) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index e09a104c6ff6..f275686779e7 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -1,9 +1,19 @@ /// import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' +import {ignorePromise} from '@/constants/utils' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' -import {loadAccountsStep} from './shared' +import {useRouterState} from '@/stores/router' +import {useShellState} from '@/stores/shell' +import { + applyClientState, + initSharedSubscriptions, + loadAccountsStep, + onEngineConnected, + onNetworkOnlineChanged, + sessionSettledStep, +} from './shared' describe('loadAccountsStep', () => { const originalDispatch = useConfigState.getState().dispatch @@ -33,7 +43,7 @@ describe('loadAccountsStep', () => { withDeferredRefreshAccounts() useConfigState.getState().dispatch.setUserSwitching(true) useDaemonState.setState(s => { - s.bootstrapStatus = {loggedIn: false} as any + s.bootstrapStatus = {loggedIn: false} as never }) await expect(loadAccountsStep()).resolves.toBeUndefined() @@ -42,7 +52,7 @@ describe('loadAccountsStep', () => { test('does not wait for accounts when already logged in', async () => { withDeferredRefreshAccounts() useDaemonState.setState(s => { - s.bootstrapStatus = {loggedIn: true} as any + s.bootstrapStatus = {loggedIn: true} as never }) await expect(loadAccountsStep()).resolves.toBeUndefined() @@ -65,3 +75,268 @@ describe('loadAccountsStep', () => { expect(useConfigState.getState().configuredAccounts.map(a => a.username)).toEqual(['testuser']) }) }) + +describe('onEngineConnected', () => { + const originalConfigDispatch = useConfigState.getState().dispatch + const originalDaemonDispatch = useDaemonState.getState().dispatch + + afterEach(() => { + jest.restoreAllMocks() + useConfigState.setState({dispatch: originalConfigDispatch}) + useDaemonState.setState({dispatch: originalDaemonDispatch}) + resetAllStores() + }) + + const stubRegistrations = () => { + for (const rpc of [ + 'delegateUiCtlRegisterChatUIRpcPromise', + 'delegateUiCtlRegisterLogUIRpcPromise', + 'delegateUiCtlRegisterHomeUIRpcPromise', + 'delegateUiCtlRegisterSecretUIRpcPromise', + 'delegateUiCtlRegisterIdentify3UIRpcPromise', + 'delegateUiCtlRegisterRekeyUIRpcPromise', + ] as const) { + jest.spyOn(T.RPCGen, rpc).mockResolvedValue(undefined) + } + useConfigState.setState(s => { + s.dispatch = {...originalConfigDispatch, onEngineConnected: () => {}} + }) + } + + const deferredSubscription = () => { + let subscribed!: () => void + jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockReturnValue( + new Promise(resolve => { + subscribed = resolve + }) + ) + return subscribed + } + const spyOnBootstrap = () => + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + loggedIn: true, + } as T.RPCGen.BootstrapStatus) + + test('a reconnect clears the disconnect state at once, before the subscription resolves', () => { + stubRegistrations() + useDaemonState.setState({error: new Error('Disconnected'), handshakeState: 'failed'}) + deferredSubscription() + spyOnBootstrap() + + onEngineConnected() + + expect(useDaemonState.getState().error).toBe(undefined) + expect(useDaemonState.getState().handshakeState).toBe('loading') + }) + + test('the bootstrap read does not wait for the subscription', async () => { + stubRegistrations() + deferredSubscription() + const bootstrap = spyOnBootstrap() + + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + + expect(bootstrap).toHaveBeenCalledTimes(1) + }) + + test('the bootstrap read still runs when the subscription fails', async () => { + stubRegistrations() + jest + .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') + .mockRejectedValue(new Error('no notifications')) + const bootstrap = spyOnBootstrap() + + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + + expect(bootstrap).toHaveBeenCalledTimes(1) + }) + + test('the bootstrap status is not a session source', async () => { + stubRegistrations() + jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockResolvedValue(undefined) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, + loggedIn: true, + uid: 'u1', + username: 'testuser', + } as never) + + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + + expect(useDaemonState.getState().bootstrapStatus?.loggedIn).toBe(true) + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().httpSrv.address).toBe('') + }) +}) + +describe('sessionSettledStep', () => { + const originalConfigDispatch = useConfigState.getState().dispatch + + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + useConfigState.setState({dispatch: originalConfigDispatch}) + resetAllStores() + }) + + const connect = (subscribe: () => Promise) => { + for (const rpc of [ + 'delegateUiCtlRegisterChatUIRpcPromise', + 'delegateUiCtlRegisterLogUIRpcPromise', + 'delegateUiCtlRegisterHomeUIRpcPromise', + 'delegateUiCtlRegisterSecretUIRpcPromise', + 'delegateUiCtlRegisterIdentify3UIRpcPromise', + 'delegateUiCtlRegisterRekeyUIRpcPromise', + ] as const) { + jest.spyOn(T.RPCGen, rpc).mockResolvedValue(undefined) + } + useConfigState.setState(s => { + s.dispatch = {...originalConfigDispatch, onEngineConnected: () => {}} + }) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockReturnValue(new Promise(() => {})) + const setNotifications = jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockImplementation(subscribe) + onEngineConnected() + return setNotifications + } + const session = {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: false, uid: '', username: ''} + const settled = async (p: Promise) => { + let done = false + const watch = async () => { + try { + await p + } catch {} + done = true + } + ignorePromise(watch()) + await new Promise(resolve => setImmediate(resolve)) + return done + } + + test('waits for a clientState that carries a session, which may say logged out', async () => { + connect(async () => Promise.resolve()) + const step = sessionSettledStep() + + applyClientState({appState: T.RPCGen.MobileAppState.foreground}) + expect(await settled(step)).toBe(false) + + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + expect(await settled(step)).toBe(true) + await expect(step).resolves.toBeUndefined() + }) + + test('a new connection waits afresh', async () => { + connect(async () => Promise.resolve()) + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + await sessionSettledStep() + + connect(async () => Promise.resolve()) + const step = sessionSettledStep() + expect(await settled(step)).toBe(false) + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + await expect(step).resolves.toBeUndefined() + }) + + test('re-subscribes when the subscription failed, since no clientState is coming otherwise', async () => { + let calls = 0 + const setNotifications = connect(async () => { + calls++ + return calls === 1 ? Promise.reject(new Error('no notifications')) : Promise.resolve() + }) + await new Promise(resolve => setImmediate(resolve)) + + const step = sessionSettledStep() + await new Promise(resolve => setImmediate(resolve)) + expect(setNotifications).toHaveBeenCalledTimes(2) + + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + await expect(step).resolves.toBeUndefined() + }) + + test('is one of the handshake steps', () => { + // Nothing here tears the subscriptions down, so none may outlive the test. + for (const store of [useConfigState, useShellState, useRouterState]) { + jest.spyOn(store, 'subscribe').mockReturnValue(() => {}) + } + const originalDaemonDispatch = useDaemonState.getState().dispatch + let steps: ReadonlyArray = [] + useDaemonState.setState({ + dispatch: { + ...originalDaemonDispatch, + initBootstrapSteps: s => { + steps = s + }, + }, + }) + try { + initSharedSubscriptions() + } finally { + useDaemonState.setState({dispatch: originalDaemonDispatch}) + } + expect(steps).toContain(sessionSettledStep) + }) + + test('fails the handshake attempt when the session never comes', async () => { + jest.useFakeTimers() + connect(async () => Promise.resolve()) + const step = sessionSettledStep() + const failed = expect(step).rejects.toThrow("The service hasn't said who is logged in") + await jest.advanceTimersByTimeAsync(30_000) + await failed + }) +}) + +describe('onNetworkOnlineChanged', () => { + // replaces the gregor-reachability trigger: re-read the bootstrap status after an offline stretch + afterEach(() => { + jest.restoreAllMocks() + useDaemonState.setState({dispatch: originalDaemonDispatch}) + resetAllStores() + }) + + const originalDaemonDispatch = useDaemonState.getState().dispatch + const spyOnReRead = () => { + // userSwitching survives resetAllStores on purpose, and an earlier test in this file sets it + useConfigState.getState().dispatch.setUserSwitching(false) + const reRead = jest.fn(async () => {}) + useDaemonState.setState({ + dispatch: {...originalDaemonDispatch, loadDaemonBootstrapStatus: reRead}, + handshakeState: 'done', + }) + return reRead + } + + test('re-reads the bootstrap status when the network comes back', () => { + const reRead = spyOnReRead() + onNetworkOnlineChanged(true, false) + expect(reRead).toHaveBeenCalledTimes(1) + }) + + test('does not re-read on the first reading of the network at startup', () => { + const reRead = spyOnReRead() + onNetworkOnlineChanged(true, undefined) + expect(reRead).not.toHaveBeenCalled() + }) + + test('does not re-read when going offline', () => { + const reRead = spyOnReRead() + onNetworkOnlineChanged(false, true) + expect(reRead).not.toHaveBeenCalled() + }) + + test('does not re-read during an account switch', () => { + const reRead = spyOnReRead() + useConfigState.getState().dispatch.setUserSwitching(true) + onNetworkOnlineChanged(true, false) + expect(reRead).not.toHaveBeenCalled() + }) + + test('does not re-read before the handshake is done', () => { + const reRead = spyOnReRead() + useDaemonState.setState({handshakeState: 'loading'}) + onNetworkOnlineChanged(true, false) + expect(reRead).not.toHaveBeenCalled() + }) +}) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 2517164b6470..f3648e963cfa 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -18,6 +18,7 @@ import {useNotifState} from '@/stores/notifications' import {notifyEngineActionListeners} from '@/engine/action-listener' import {serviceStaticConfigToStaticConfig} from '@/constants/chat/static-config' import {emitDeepLink} from '@/router-v2/linking' +import {enqueuePushTapRoute} from '@/router-v2/deep-link-emitter' import {ignorePromise, timeoutPromise} from '../utils' import {isPhone, serverConfigFileName} from '../platform' import {useAvatarState} from '@/common-adapters/avatar/store' @@ -69,7 +70,6 @@ const subscribeValue = ( }) type ConfigState = ReturnType -type DaemonState = ReturnType type RouterState = ReturnType // ─── Bootstrap steps ────────────────────────────────────────────────────────── @@ -160,10 +160,14 @@ const scheduleStartupOrReloginWork = () => { ignorePromise(f()) } -const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable']) => { - // Re-get info about our account if you log in/we're done handshaking/became reachable +// The bootstrap read the old gregor-reachability trigger did: after an offline stretch, pick up +// what the service learned while we could not reach it. `previous === undefined` is the first +// reading of the network at startup, which the handshake's own read already covers. +export const onNetworkOnlineChanged = (online?: boolean, previous?: boolean) => { + if (!online || previous !== false) { + return + } if ( - gregorReachable === T.RPCGen.Reachable.yes && useDaemonState.getState().handshakeState === 'done' && !useConfigState.getState().userSwitching ) { @@ -171,7 +175,7 @@ const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable' } } -const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { +export const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { if (loggedIn) { // runtime login: refresh bootstrap status. During the handshake this is already in // flight, and the store dedupes it. @@ -199,26 +203,156 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { - if (!bootstrap) { + +// 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 {deviceID, deviceName, loggedIn, uid, username} = bootstrap - useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) +// Peek and queue. Reading the route does not retire it: the service acks only when navigation (or +// account-link-switch, dropping a tap it cannot act on) has actually consumed the intent this +// enqueues, which is what makes a tap exactly-once end to end. A peek whose reply is lost, or one +// that repeats a tap already queued or consumed this run, is handled by enqueuePushTapRoute/the +// intent store and costs nothing here. +// +// Run on connect, for a tap from before this connection (on iOS a background launch never starts a +// client at all, so a tap can be arbitrarily older than the socket), and on pushTapRouteAvailable +// for a tap during it. Both reach the same armed route, so neither can act on a tap the other +// already did. +const drainPushTapRoute = async () => { + if (!isMobile) { + return + } + try { + const route = await T.RPCGen.appStatePeekPushTapRouteRpcPromise() + if (!route) { + return + } + enqueuePushTapRoute(route) + } catch (error) { + logger.warn('[PushTap] failed to peek a tap route, leaving it armed: ', error) + } +} + +// The splash waits for the service to say who is logged in. A clientState with no session means +// its startup login attempt has not settled yet -- not known, rather than logged out -- and the +// attempt settling sends another that has one. Each connection waits afresh. +const sessionWaitMs = 30_000 +let settleSession = () => {} +let sessionSettled = new Promise(resolve => { + settleSession = resolve +}) +const awaitSessionAgain = () => { + sessionSettled = new Promise(resolve => { + settleSession = resolve + }) +} +// The service's clientState: the session, the http server address and the app state, read when it +// was sent. It rides the same ordered stream as every notification that changes them, and for each +// of them the last message to arrive carries the latest value, so everything is applied in arrival +// order. It comes first on subscribing, after every session change, and once the service's startup +// login attempt settles. +export const applyClientState = (clientState: T.RPCGen.ClientState) => { + const {appState, httpSrvInfo, session} = clientState + // On iOS JS never starts on a background launch, so it can have missed every change since the + // process started: this is what catches it up. + applyMobileAppState(appState) const configDispatch = useConfigState.getState().dispatch - if (username) { - configDispatch.setDefaultUsername(username) + if (httpSrvInfo) { + configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token) } - if (!loggedIn && useConfigState.getState().userSwitching) { - logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') + if (!session) { + logger.info('[Bootstrap] the service has not settled its startup login yet') return } - configDispatch.setLoggedIn(loggedIn) + settleSession() + const {deviceID, deviceName, loggedIn, uid, username} = session + if (!loggedIn) { + // Session first: logging out resets the stores, the current user among them. Writing the empty + // identity first would leave a moment where we are logged in with no user. + configDispatch.setLoggedIn(false) + return + } + // A logged-in clientState for another user than the one we are logged in as is a logout and then + // a login, however it reached us -- with or without a logged-out clientState before it. Logging + // out is what clears the previous account's stores. Logged in with no current user is no switch. + const currentUid = useCurrentUserState.getState().uid + if (useConfigState.getState().loggedIn && currentUid && uid !== currentUid) { + configDispatch.setLoggedIn(false) + } + // identity before the session: setLoggedIn fans out synchronously, and every subscriber of a + // login has always been able to read the current user by the time it runs + useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) + if (username) { + configDispatch.setDefaultUsername(username) + } + configDispatch.setLoggedIn(true) +} - if (bootstrap.httpSrvInfo) { - configDispatch.setHTTPSrvInfo(bootstrap.httpSrvInfo.address, bootstrap.httpSrvInfo.token) +const subscribe = async () => { + try { + // prettier-ignore + await T.RPCGen.notifyCtlSetNotificationsRpcPromise({ + channels: { + allowChatNotifySkips: true, app: true, audit: true, badges: true, chat: true, chatarchive: true, + chatattachments: true, chatdev: false, chatemoji: false, chatemojicross: false, chatkbfsedits: false, + deviceclone: false, ephemeral: false, favorites: false, featuredBots: false, kbfs: true, kbfsdesktop: !isMobile, + devicehistory: true, kbfslegacy: false, kbfsrequest: false, kbfssubscription: true, keyfamily: false, notifysimplefs: true, + paperkeys: false, pgp: true, reachability: false, runtimestats: true, saltpack: true, service: true, session: true, + team: true, teambot: false, tracking: true, users: true, wallet: false, + }, + }) + return true + } catch (error) { + logger.warn('error in toggling notifications: ', error) + return false + } +} +let subscription = Promise.resolve(false) + +// A handshake step: the session is what decides between the login screen and the app. A failed +// subscribe is retried here, since without it no clientState is coming. +export const sessionSettledStep = async () => { + if (!(await subscription)) { + subscription = subscribe() + if (!(await subscription)) { + throw new Error("Can't subscribe to the service's notifications") + } + } + let timer: ReturnType | undefined + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("The service hasn't said who is logged in")), sessionWaitMs) + }) + try { + await Promise.race([sessionSettled, timedOut]) + } finally { + clearTimeout(timer) } } @@ -247,50 +381,31 @@ const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavStat } export const onEngineConnected = () => { - { - const registerUIs = async () => { - try { - await T.RPCGen.delegateUiCtlRegisterChatUIRpcPromise() - await T.RPCGen.delegateUiCtlRegisterLogUIRpcPromise() - logger.info('Registered Chat UI') - await T.RPCGen.delegateUiCtlRegisterHomeUIRpcPromise() - logger.info('Registered home UI') - await T.RPCGen.delegateUiCtlRegisterSecretUIRpcPromise() - logger.info('Registered secret ui') - await T.RPCGen.delegateUiCtlRegisterIdentify3UIRpcPromise() - logger.info('Registered identify ui') - await T.RPCGen.delegateUiCtlRegisterRekeyUIRpcPromise() - logger.info('Registered rekey ui') - } catch (error) { - logger.error('Error in registering UIs:', error) - } + const registerUIs = async () => { + try { + await T.RPCGen.delegateUiCtlRegisterChatUIRpcPromise() + await T.RPCGen.delegateUiCtlRegisterLogUIRpcPromise() + logger.info('Registered Chat UI') + await T.RPCGen.delegateUiCtlRegisterHomeUIRpcPromise() + logger.info('Registered home UI') + await T.RPCGen.delegateUiCtlRegisterSecretUIRpcPromise() + logger.info('Registered secret ui') + await T.RPCGen.delegateUiCtlRegisterIdentify3UIRpcPromise() + logger.info('Registered identify ui') + await T.RPCGen.delegateUiCtlRegisterRekeyUIRpcPromise() + logger.info('Registered rekey ui') + } catch (error) { + logger.error('Error in registering UIs:', error) } - ignorePromise(registerUIs()) } + ignorePromise(registerUIs()) + useConfigState.getState().dispatch.onEngineConnected() + + awaitSessionAgain() + subscription = subscribe() + ignorePromise(drainPushTapRoute()) useDaemonState.getState().dispatch.startHandshake() - { - const notifyCtl = async () => { - try { - // prettier-ignore - await T.RPCGen.notifyCtlSetNotificationsRpcPromise({ - channels: { - allowChatNotifySkips: true, app: true, audit: true, badges: true, chat: true, chatarchive: true, - chatattachments: true, chatdev: false, chatemoji: false, chatemojicross: false, chatkbfsedits: false, - deviceclone: false, ephemeral: false, favorites: false, featuredBots: false, kbfs: true, kbfsdesktop: !isMobile, - devicehistory: true, kbfslegacy: false, kbfsrequest: false, kbfssubscription: true, keyfamily: false, notifysimplefs: true, - paperkeys: false, pgp: true, reachability: true, runtimestats: true, saltpack: true, service: true, session: true, - team: true, teambot: false, tracking: true, users: true, wallet: false, - }, - }) - } catch (error) { - if (error) { - logger.warn('error in toggling notifications: ', error) - } - } - } - ignorePromise(notifyCtl()) - } } export const onEngineDisconnected = () => { @@ -308,6 +423,7 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.gregorReachable, onGregorReachableChanged), subscribeValue(useConfigState, s => s.loggedIn, onLoggedInChanged), subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged) ) - _sharedUnsubs.push(subscribeValue(useDaemonState, s => s.bootstrapStatus, onBootstrapStatusChanged)) + _sharedUnsubs.push(subscribeValue(useShellState, s => s.networkStatus?.online, onNetworkOnlineChanged)) _sharedUnsubs.push( subscribeValue(useRouterState, s => s.navState, onNavStateChanged) @@ -341,6 +456,15 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { } switch (action.type) { + case 'keybase.1.NotifyApp.pushTapRouteAvailable': + ignorePromise(drainPushTapRoute()) + break + case 'keybase.1.NotifyApp.mobileAppStateChanged': + applyMobileAppState(action.payload.params.state) + break + case 'keybase.1.NotifyApp.clientState': + applyClientState(action.payload.params.state) + break case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params diff --git a/shared/constants/rpc/index.tsx b/shared/constants/rpc/index.tsx index 639df9e16838..c9671c4222ea 100644 --- a/shared/constants/rpc/index.tsx +++ b/shared/constants/rpc/index.tsx @@ -71,6 +71,9 @@ type Chat1ResponseActionMap = { } type Keybase1IncomingAction = + 'keybase.1.NotifyApp.clientState' | + 'keybase.1.NotifyApp.mobileAppStateChanged' | + 'keybase.1.NotifyApp.pushTapRouteAvailable' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | @@ -78,8 +81,7 @@ type Keybase1IncomingAction = 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | - 'keybase.1.NotifyUsers.userChanged' | - 'keybase.1.reachability.reachabilityChanged' + 'keybase.1.NotifyUsers.userChanged' type Keybase1IncomingActionMap = { [P in K]: {readonly params: keybase1Types.RpcIn

} diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index c1792d8d0bad..d3297ee69744 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -11,10 +11,22 @@ export type IncomingErrorCallback = (err?: SimpleError | null) => void export type MessageTypes = { + 'keybase.1.NotifyApp.clientState': { + inParam: {readonly state: ClientState}, + outParam: void, + }, 'keybase.1.NotifyApp.exit': { inParam: undefined, outParam: void, }, + 'keybase.1.NotifyApp.mobileAppStateChanged': { + inParam: {readonly state: MobileAppState}, + outParam: void, + }, + 'keybase.1.NotifyApp.pushTapRouteAvailable': { + inParam: undefined, + outParam: void, + }, 'keybase.1.NotifyAudit.boxAuditError': { inParam: {readonly message: string}, outParam: void, @@ -407,6 +419,14 @@ export type MessageTypes = { inParam: {readonly endpoint: string,readonly args?: ReadonlyArray | null,readonly JSONPayload?: ReadonlyArray | null,readonly httpStatus?: ReadonlyArray | null,readonly appStatusCode?: ReadonlyArray | null}, outParam: APIRes, }, + 'keybase.1.appState.ackPushTapRoute': { + inParam: {readonly id: number}, + outParam: void, + }, + 'keybase.1.appState.peekPushTapRoute': { + inParam: undefined, + outParam: PushTapRoute | null, + }, 'keybase.1.appState.powerMonitorEvent': { inParam: {readonly event: string}, outParam: void, @@ -963,14 +983,6 @@ export type MessageTypes = { inParam: undefined, outParam: Reachability, }, - 'keybase.1.reachability.reachabilityChanged': { - inParam: {readonly reachability: Reachability}, - outParam: void, - }, - 'keybase.1.reachability.startReachability': { - inParam: undefined, - outParam: Reachability, - }, 'keybase.1.rekey.getRevokeWarning': { inParam: {readonly actingDevice: DeviceID,readonly targetDevice: DeviceID}, outParam: RevokeWarning, @@ -1280,7 +1292,7 @@ export type MessageKey = keyof MessageTypes export type RpcIn = MessageTypes[M]['inParam'] export type RpcOut = MessageTypes[M]['outParam'] export type RpcResponse = {error: IncomingErrorCallback, result: (res: RpcOut) => void} -type PromiseMethod = 'keybase.1.account.cancelReset' | 'keybase.1.account.getLockdownMode' | 'keybase.1.account.guessCurrentLocation' | 'keybase.1.account.hasServerKeys' | 'keybase.1.account.passphraseChange' | 'keybase.1.account.passphraseCheck' | 'keybase.1.account.recoverUsernameWithEmail' | 'keybase.1.account.recoverUsernameWithPhone' | 'keybase.1.account.setLockdownMode' | 'keybase.1.account.userGetContactSettings' | 'keybase.1.account.userSetContactSettings' | 'keybase.1.apiserver.Delete' | 'keybase.1.apiserver.GetWithSession' | 'keybase.1.apiserver.Post' | 'keybase.1.apiserver.PostJSON' | 'keybase.1.appState.powerMonitorEvent' | 'keybase.1.appState.updateMobileNetState' | 'keybase.1.config.appendGUILogs' | 'keybase.1.config.generateWebAuthToken' | 'keybase.1.config.getBootstrapStatus' | 'keybase.1.config.getProxyData' | 'keybase.1.config.getRememberPassphrase' | 'keybase.1.config.getUpdateInfo' | 'keybase.1.config.getUpdateInfo2' | 'keybase.1.config.guiGetValue' | 'keybase.1.config.guiSetValue' | 'keybase.1.config.helloIAm' | 'keybase.1.config.logSend' | 'keybase.1.config.requestFollowingAndUnverifiedFollowers' | 'keybase.1.config.setProxyData' | 'keybase.1.config.setRememberPassphrase' | 'keybase.1.config.startUpdateIfNeeded' | 'keybase.1.config.toggleRuntimeStats' | 'keybase.1.config.updateLastLoggedInAndServerConfig' | 'keybase.1.config.waitForClient' | 'keybase.1.contacts.getContactsForUserRecommendations' | 'keybase.1.contacts.saveContactList' | 'keybase.1.cryptocurrency.registerAddress' | 'keybase.1.ctl.dbNuke' | 'keybase.1.ctl.getOnLoginStartup' | 'keybase.1.ctl.setOnLoginStartup' | 'keybase.1.ctl.stop' | 'keybase.1.delegateUiCtl.registerChatUI' | 'keybase.1.delegateUiCtl.registerGregorFirehoseFiltered' | 'keybase.1.delegateUiCtl.registerHomeUI' | 'keybase.1.delegateUiCtl.registerIdentify3UI' | 'keybase.1.delegateUiCtl.registerLogUI' | 'keybase.1.delegateUiCtl.registerRekeyUI' | 'keybase.1.delegateUiCtl.registerSecretUI' | 'keybase.1.device.checkDeviceNameFormat' | 'keybase.1.device.deviceHistoryList' | 'keybase.1.device.dismissDeviceChangeNotifications' | 'keybase.1.emails.addEmail' | 'keybase.1.emails.deleteEmail' | 'keybase.1.emails.sendVerificationEmail' | 'keybase.1.emails.setPrimaryEmail' | 'keybase.1.emails.setVisibilityEmail' | 'keybase.1.favorite.favoriteIgnore' | 'keybase.1.featuredBot.featuredBots' | 'keybase.1.featuredBot.search' | 'keybase.1.git.createPersonalRepo' | 'keybase.1.git.createTeamRepo' | 'keybase.1.git.deletePersonalRepo' | 'keybase.1.git.deleteTeamRepo' | 'keybase.1.git.getAllGitMetadata' | 'keybase.1.git.getTeamRepoSettings' | 'keybase.1.git.setTeamRepoSettings' | 'keybase.1.gregor.dismissCategory' | 'keybase.1.gregor.getState' | 'keybase.1.gregor.updateCategory' | 'keybase.1.home.homeDismissAnnouncement' | 'keybase.1.home.homeGetScreen' | 'keybase.1.home.homeMarkViewed' | 'keybase.1.home.homeSkipTodoType' | 'keybase.1.identify3.identify3FollowUser' | 'keybase.1.identify3.identify3IgnoreUser' | 'keybase.1.incomingShare.getIncomingShareItems' | 'keybase.1.incomingShare.getPreference' | 'keybase.1.incomingShare.setPreference' | 'keybase.1.install.fuseStatus' | 'keybase.1.install.installFuse' | 'keybase.1.install.installKBFS' | 'keybase.1.install.uninstallKBFS' | 'keybase.1.kbfsMount.GetCurrentMountDir' | 'keybase.1.kbfsMount.GetKBFSPathInfo' | 'keybase.1.kbfsMount.GetPreferredMountDirs' | 'keybase.1.kbfsMount.WaitForMounts' | 'keybase.1.log.perfLogPoint' | 'keybase.1.login.accountDelete' | 'keybase.1.login.deprovision' | 'keybase.1.login.getConfiguredAccounts' | 'keybase.1.login.isOnline' | 'keybase.1.login.logout' | 'keybase.1.login.paperKeySubmit' | 'keybase.1.notifyCtl.setNotifications' | 'keybase.1.pgp.pgpStorageDismiss' | 'keybase.1.phoneNumbers.addPhoneNumber' | 'keybase.1.phoneNumbers.deletePhoneNumber' | 'keybase.1.phoneNumbers.resendVerificationForPhoneNumber' | 'keybase.1.phoneNumbers.setVisibilityPhoneNumber' | 'keybase.1.phoneNumbers.verifyPhoneNumber' | 'keybase.1.pprof.logProcessorProfile' | 'keybase.1.pprof.logTrace' | 'keybase.1.prove.checkProof' | 'keybase.1.reachability.checkReachability' | 'keybase.1.reachability.startReachability' | 'keybase.1.rekey.getRevokeWarning' | 'keybase.1.rekey.rekeyStatusFinish' | 'keybase.1.rekey.showPendingRekeyStatus' | 'keybase.1.revoke.revokeDevice' | 'keybase.1.revoke.revokeKey' | 'keybase.1.revoke.revokeSigs' | 'keybase.1.saltpack.saltpackDecryptFile' | 'keybase.1.saltpack.saltpackDecryptString' | 'keybase.1.saltpack.saltpackEncryptFile' | 'keybase.1.saltpack.saltpackEncryptString' | 'keybase.1.saltpack.saltpackSaveCiphertextToFile' | 'keybase.1.saltpack.saltpackSaveSignedMsgToFile' | 'keybase.1.saltpack.saltpackSignFile' | 'keybase.1.saltpack.saltpackSignString' | 'keybase.1.saltpack.saltpackVerifyFile' | 'keybase.1.saltpack.saltpackVerifyString' | 'keybase.1.signup.checkUsernameAvailable' | 'keybase.1.signup.getInvitationCode' | 'keybase.1.SimpleFS.simpleFSArchiveAllFiles' | 'keybase.1.SimpleFS.simpleFSArchiveAllGitRepos' | 'keybase.1.SimpleFS.simpleFSArchiveCancelOrDismissJob' | 'keybase.1.SimpleFS.simpleFSArchiveStart' | 'keybase.1.SimpleFS.simpleFSCancelDownload' | 'keybase.1.SimpleFS.simpleFSCheckReachability' | 'keybase.1.SimpleFS.simpleFSClearConflictState' | 'keybase.1.SimpleFS.simpleFSConfigureDownload' | 'keybase.1.SimpleFS.simpleFSCopyRecursive' | 'keybase.1.SimpleFS.simpleFSDismissDownload' | 'keybase.1.SimpleFS.simpleFSDismissUpload' | 'keybase.1.SimpleFS.simpleFSFinishResolvingConflict' | 'keybase.1.SimpleFS.simpleFSFolderSyncConfigAndStatus' | 'keybase.1.SimpleFS.simpleFSGetArchiveJobFreshness' | 'keybase.1.SimpleFS.simpleFSGetArchiveStatus' | 'keybase.1.SimpleFS.simpleFSGetDownloadInfo' | 'keybase.1.SimpleFS.simpleFSGetDownloadStatus' | 'keybase.1.SimpleFS.simpleFSGetFilesTabBadge' | 'keybase.1.SimpleFS.simpleFSGetFolder' | 'keybase.1.SimpleFS.simpleFSGetGUIFileContext' | 'keybase.1.SimpleFS.simpleFSGetOnlineStatus' | 'keybase.1.SimpleFS.simpleFSGetUploadStatus' | 'keybase.1.SimpleFS.simpleFSList' | 'keybase.1.SimpleFS.simpleFSListFavorites' | 'keybase.1.SimpleFS.simpleFSListRecursiveToDepth' | 'keybase.1.SimpleFS.simpleFSMakeTempDirForUpload' | 'keybase.1.SimpleFS.simpleFSMove' | 'keybase.1.SimpleFS.simpleFSOpen' | 'keybase.1.SimpleFS.simpleFSReadList' | 'keybase.1.SimpleFS.simpleFSRemove' | 'keybase.1.SimpleFS.simpleFSSetDebugLevel' | 'keybase.1.SimpleFS.simpleFSSetFolderSyncConfig' | 'keybase.1.SimpleFS.simpleFSSetNotificationThreshold' | 'keybase.1.SimpleFS.simpleFSSetSfmiBannerDismissed' | 'keybase.1.SimpleFS.simpleFSSetSyncOnCellular' | 'keybase.1.SimpleFS.simpleFSSettings' | 'keybase.1.SimpleFS.simpleFSStartDownload' | 'keybase.1.SimpleFS.simpleFSStartUpload' | 'keybase.1.SimpleFS.simpleFSStat' | 'keybase.1.SimpleFS.simpleFSSubscribeNonPath' | 'keybase.1.SimpleFS.simpleFSSubscribePath' | 'keybase.1.SimpleFS.simpleFSSyncStatus' | 'keybase.1.SimpleFS.simpleFSUnsubscribe' | 'keybase.1.SimpleFS.simpleFSUserEditHistory' | 'keybase.1.SimpleFS.simpleFSUserIn' | 'keybase.1.SimpleFS.simpleFSUserOut' | 'keybase.1.SimpleFS.simpleFSWait' | 'keybase.1.teams.findAssertionsInTeamNoResolve' | 'keybase.1.teams.getAnnotatedTeam' | 'keybase.1.teams.getInviteLinkDetails' | 'keybase.1.teams.getTeamID' | 'keybase.1.teams.getTeamRoleMap' | 'keybase.1.teams.getUntrustedTeamInfo' | 'keybase.1.teams.loadTeamTreeMembershipsAsync' | 'keybase.1.teams.setTarsDisabled' | 'keybase.1.teams.setTeamMemberShowcase' | 'keybase.1.teams.setTeamShowcase' | 'keybase.1.teams.teamAddEmailsBulk' | 'keybase.1.teams.teamAddMember' | 'keybase.1.teams.teamAddMembersMultiRole' | 'keybase.1.teams.teamCreate' | 'keybase.1.teams.teamCreateFancy' | 'keybase.1.teams.teamCreateSeitanTokenV2' | 'keybase.1.teams.teamEditMembers' | 'keybase.1.teams.teamGetMembersByID' | 'keybase.1.teams.teamIgnoreRequest' | 'keybase.1.teams.teamLeave' | 'keybase.1.teams.teamListMyAccessRequests' | 'keybase.1.teams.teamListUnverified' | 'keybase.1.teams.teamProfileAddList' | 'keybase.1.teams.teamReAddMemberAfterReset' | 'keybase.1.teams.teamRemoveMember' | 'keybase.1.teams.teamRename' | 'keybase.1.teams.teamSetSettings' | 'keybase.1.teams.untrustedTeamExists' | 'keybase.1.teams.uploadTeamAvatar' | 'keybase.1.user.blockUser' | 'keybase.1.user.canLogout' | 'keybase.1.user.dismissBlockButtons' | 'keybase.1.user.getUserBlocks' | 'keybase.1.user.interestingPeople' | 'keybase.1.user.listTrackersUnverified' | 'keybase.1.user.listTracking' | 'keybase.1.user.loadMySettings' | 'keybase.1.user.loadPassphraseState' | 'keybase.1.user.profileEdit' | 'keybase.1.user.proofSuggestions' | 'keybase.1.user.reportUser' | 'keybase.1.user.setUserBlocks' | 'keybase.1.user.unblockUser' | 'keybase.1.user.uploadUserAvatar' | 'keybase.1.user.userCard' | 'keybase.1.userSearch.bulkEmailOrPhoneSearch' | 'keybase.1.userSearch.getNonUserDetails' | 'keybase.1.userSearch.userSearch' +type PromiseMethod = 'keybase.1.account.cancelReset' | 'keybase.1.account.getLockdownMode' | 'keybase.1.account.guessCurrentLocation' | 'keybase.1.account.hasServerKeys' | 'keybase.1.account.passphraseChange' | 'keybase.1.account.passphraseCheck' | 'keybase.1.account.recoverUsernameWithEmail' | 'keybase.1.account.recoverUsernameWithPhone' | 'keybase.1.account.setLockdownMode' | 'keybase.1.account.userGetContactSettings' | 'keybase.1.account.userSetContactSettings' | 'keybase.1.apiserver.Delete' | 'keybase.1.apiserver.GetWithSession' | 'keybase.1.apiserver.Post' | 'keybase.1.apiserver.PostJSON' | 'keybase.1.appState.ackPushTapRoute' | 'keybase.1.appState.peekPushTapRoute' | 'keybase.1.appState.powerMonitorEvent' | 'keybase.1.appState.updateMobileNetState' | 'keybase.1.config.appendGUILogs' | 'keybase.1.config.generateWebAuthToken' | 'keybase.1.config.getBootstrapStatus' | 'keybase.1.config.getProxyData' | 'keybase.1.config.getRememberPassphrase' | 'keybase.1.config.getUpdateInfo' | 'keybase.1.config.getUpdateInfo2' | 'keybase.1.config.guiGetValue' | 'keybase.1.config.guiSetValue' | 'keybase.1.config.helloIAm' | 'keybase.1.config.logSend' | 'keybase.1.config.requestFollowingAndUnverifiedFollowers' | 'keybase.1.config.setProxyData' | 'keybase.1.config.setRememberPassphrase' | 'keybase.1.config.startUpdateIfNeeded' | 'keybase.1.config.toggleRuntimeStats' | 'keybase.1.config.updateLastLoggedInAndServerConfig' | 'keybase.1.config.waitForClient' | 'keybase.1.contacts.getContactsForUserRecommendations' | 'keybase.1.contacts.saveContactList' | 'keybase.1.cryptocurrency.registerAddress' | 'keybase.1.ctl.dbNuke' | 'keybase.1.ctl.getOnLoginStartup' | 'keybase.1.ctl.setOnLoginStartup' | 'keybase.1.ctl.stop' | 'keybase.1.delegateUiCtl.registerChatUI' | 'keybase.1.delegateUiCtl.registerGregorFirehoseFiltered' | 'keybase.1.delegateUiCtl.registerHomeUI' | 'keybase.1.delegateUiCtl.registerIdentify3UI' | 'keybase.1.delegateUiCtl.registerLogUI' | 'keybase.1.delegateUiCtl.registerRekeyUI' | 'keybase.1.delegateUiCtl.registerSecretUI' | 'keybase.1.device.checkDeviceNameFormat' | 'keybase.1.device.deviceHistoryList' | 'keybase.1.device.dismissDeviceChangeNotifications' | 'keybase.1.emails.addEmail' | 'keybase.1.emails.deleteEmail' | 'keybase.1.emails.sendVerificationEmail' | 'keybase.1.emails.setPrimaryEmail' | 'keybase.1.emails.setVisibilityEmail' | 'keybase.1.favorite.favoriteIgnore' | 'keybase.1.featuredBot.featuredBots' | 'keybase.1.featuredBot.search' | 'keybase.1.git.createPersonalRepo' | 'keybase.1.git.createTeamRepo' | 'keybase.1.git.deletePersonalRepo' | 'keybase.1.git.deleteTeamRepo' | 'keybase.1.git.getAllGitMetadata' | 'keybase.1.git.getTeamRepoSettings' | 'keybase.1.git.setTeamRepoSettings' | 'keybase.1.gregor.dismissCategory' | 'keybase.1.gregor.getState' | 'keybase.1.gregor.updateCategory' | 'keybase.1.home.homeDismissAnnouncement' | 'keybase.1.home.homeGetScreen' | 'keybase.1.home.homeMarkViewed' | 'keybase.1.home.homeSkipTodoType' | 'keybase.1.identify3.identify3FollowUser' | 'keybase.1.identify3.identify3IgnoreUser' | 'keybase.1.incomingShare.getIncomingShareItems' | 'keybase.1.incomingShare.getPreference' | 'keybase.1.incomingShare.setPreference' | 'keybase.1.install.fuseStatus' | 'keybase.1.install.installFuse' | 'keybase.1.install.installKBFS' | 'keybase.1.install.uninstallKBFS' | 'keybase.1.kbfsMount.GetCurrentMountDir' | 'keybase.1.kbfsMount.GetKBFSPathInfo' | 'keybase.1.kbfsMount.GetPreferredMountDirs' | 'keybase.1.kbfsMount.WaitForMounts' | 'keybase.1.log.perfLogPoint' | 'keybase.1.login.accountDelete' | 'keybase.1.login.deprovision' | 'keybase.1.login.getConfiguredAccounts' | 'keybase.1.login.isOnline' | 'keybase.1.login.logout' | 'keybase.1.login.paperKeySubmit' | 'keybase.1.notifyCtl.setNotifications' | 'keybase.1.pgp.pgpStorageDismiss' | 'keybase.1.phoneNumbers.addPhoneNumber' | 'keybase.1.phoneNumbers.deletePhoneNumber' | 'keybase.1.phoneNumbers.resendVerificationForPhoneNumber' | 'keybase.1.phoneNumbers.setVisibilityPhoneNumber' | 'keybase.1.phoneNumbers.verifyPhoneNumber' | 'keybase.1.pprof.logProcessorProfile' | 'keybase.1.pprof.logTrace' | 'keybase.1.prove.checkProof' | 'keybase.1.reachability.checkReachability' | 'keybase.1.rekey.getRevokeWarning' | 'keybase.1.rekey.rekeyStatusFinish' | 'keybase.1.rekey.showPendingRekeyStatus' | 'keybase.1.revoke.revokeDevice' | 'keybase.1.revoke.revokeKey' | 'keybase.1.revoke.revokeSigs' | 'keybase.1.saltpack.saltpackDecryptFile' | 'keybase.1.saltpack.saltpackDecryptString' | 'keybase.1.saltpack.saltpackEncryptFile' | 'keybase.1.saltpack.saltpackEncryptString' | 'keybase.1.saltpack.saltpackSaveCiphertextToFile' | 'keybase.1.saltpack.saltpackSaveSignedMsgToFile' | 'keybase.1.saltpack.saltpackSignFile' | 'keybase.1.saltpack.saltpackSignString' | 'keybase.1.saltpack.saltpackVerifyFile' | 'keybase.1.saltpack.saltpackVerifyString' | 'keybase.1.signup.checkUsernameAvailable' | 'keybase.1.signup.getInvitationCode' | 'keybase.1.SimpleFS.simpleFSArchiveAllFiles' | 'keybase.1.SimpleFS.simpleFSArchiveAllGitRepos' | 'keybase.1.SimpleFS.simpleFSArchiveCancelOrDismissJob' | 'keybase.1.SimpleFS.simpleFSArchiveStart' | 'keybase.1.SimpleFS.simpleFSCancelDownload' | 'keybase.1.SimpleFS.simpleFSCheckReachability' | 'keybase.1.SimpleFS.simpleFSClearConflictState' | 'keybase.1.SimpleFS.simpleFSConfigureDownload' | 'keybase.1.SimpleFS.simpleFSCopyRecursive' | 'keybase.1.SimpleFS.simpleFSDismissDownload' | 'keybase.1.SimpleFS.simpleFSDismissUpload' | 'keybase.1.SimpleFS.simpleFSFinishResolvingConflict' | 'keybase.1.SimpleFS.simpleFSFolderSyncConfigAndStatus' | 'keybase.1.SimpleFS.simpleFSGetArchiveJobFreshness' | 'keybase.1.SimpleFS.simpleFSGetArchiveStatus' | 'keybase.1.SimpleFS.simpleFSGetDownloadInfo' | 'keybase.1.SimpleFS.simpleFSGetDownloadStatus' | 'keybase.1.SimpleFS.simpleFSGetFilesTabBadge' | 'keybase.1.SimpleFS.simpleFSGetFolder' | 'keybase.1.SimpleFS.simpleFSGetGUIFileContext' | 'keybase.1.SimpleFS.simpleFSGetOnlineStatus' | 'keybase.1.SimpleFS.simpleFSGetUploadStatus' | 'keybase.1.SimpleFS.simpleFSList' | 'keybase.1.SimpleFS.simpleFSListFavorites' | 'keybase.1.SimpleFS.simpleFSListRecursiveToDepth' | 'keybase.1.SimpleFS.simpleFSMakeTempDirForUpload' | 'keybase.1.SimpleFS.simpleFSMove' | 'keybase.1.SimpleFS.simpleFSOpen' | 'keybase.1.SimpleFS.simpleFSReadList' | 'keybase.1.SimpleFS.simpleFSRemove' | 'keybase.1.SimpleFS.simpleFSSetDebugLevel' | 'keybase.1.SimpleFS.simpleFSSetFolderSyncConfig' | 'keybase.1.SimpleFS.simpleFSSetNotificationThreshold' | 'keybase.1.SimpleFS.simpleFSSetSfmiBannerDismissed' | 'keybase.1.SimpleFS.simpleFSSetSyncOnCellular' | 'keybase.1.SimpleFS.simpleFSSettings' | 'keybase.1.SimpleFS.simpleFSStartDownload' | 'keybase.1.SimpleFS.simpleFSStartUpload' | 'keybase.1.SimpleFS.simpleFSStat' | 'keybase.1.SimpleFS.simpleFSSubscribeNonPath' | 'keybase.1.SimpleFS.simpleFSSubscribePath' | 'keybase.1.SimpleFS.simpleFSSyncStatus' | 'keybase.1.SimpleFS.simpleFSUnsubscribe' | 'keybase.1.SimpleFS.simpleFSUserEditHistory' | 'keybase.1.SimpleFS.simpleFSUserIn' | 'keybase.1.SimpleFS.simpleFSUserOut' | 'keybase.1.SimpleFS.simpleFSWait' | 'keybase.1.teams.findAssertionsInTeamNoResolve' | 'keybase.1.teams.getAnnotatedTeam' | 'keybase.1.teams.getInviteLinkDetails' | 'keybase.1.teams.getTeamID' | 'keybase.1.teams.getTeamRoleMap' | 'keybase.1.teams.getUntrustedTeamInfo' | 'keybase.1.teams.loadTeamTreeMembershipsAsync' | 'keybase.1.teams.setTarsDisabled' | 'keybase.1.teams.setTeamMemberShowcase' | 'keybase.1.teams.setTeamShowcase' | 'keybase.1.teams.teamAddEmailsBulk' | 'keybase.1.teams.teamAddMember' | 'keybase.1.teams.teamAddMembersMultiRole' | 'keybase.1.teams.teamCreate' | 'keybase.1.teams.teamCreateFancy' | 'keybase.1.teams.teamCreateSeitanTokenV2' | 'keybase.1.teams.teamEditMembers' | 'keybase.1.teams.teamGetMembersByID' | 'keybase.1.teams.teamIgnoreRequest' | 'keybase.1.teams.teamLeave' | 'keybase.1.teams.teamListMyAccessRequests' | 'keybase.1.teams.teamListUnverified' | 'keybase.1.teams.teamProfileAddList' | 'keybase.1.teams.teamReAddMemberAfterReset' | 'keybase.1.teams.teamRemoveMember' | 'keybase.1.teams.teamRename' | 'keybase.1.teams.teamSetSettings' | 'keybase.1.teams.untrustedTeamExists' | 'keybase.1.teams.uploadTeamAvatar' | 'keybase.1.user.blockUser' | 'keybase.1.user.canLogout' | 'keybase.1.user.dismissBlockButtons' | 'keybase.1.user.getUserBlocks' | 'keybase.1.user.interestingPeople' | 'keybase.1.user.listTrackersUnverified' | 'keybase.1.user.listTracking' | 'keybase.1.user.loadMySettings' | 'keybase.1.user.loadPassphraseState' | 'keybase.1.user.profileEdit' | 'keybase.1.user.proofSuggestions' | 'keybase.1.user.reportUser' | 'keybase.1.user.setUserBlocks' | 'keybase.1.user.unblockUser' | 'keybase.1.user.uploadUserAvatar' | 'keybase.1.user.userCard' | 'keybase.1.userSearch.bulkEmailOrPhoneSearch' | 'keybase.1.userSearch.getNonUserDetails' | 'keybase.1.userSearch.userSearch' export type RpcFn = [RpcIn] extends [undefined] ? (params?: undefined, waitingKey?: WaitingKey) => Promise> : (params: RpcIn, waitingKey?: WaitingKey) => Promise> @@ -2545,6 +2557,8 @@ export type CheckProofStatus = {readonly found: boolean,readonly status: ProofSt export type CheckResult = {readonly proofResult: ProofResult,readonly time: Time,readonly freshness: CheckResultFreshness,} export type CiphertextBundle = {readonly kid: KID,readonly ciphertext: EncryptedBytes32,readonly nonce: BoxNonce,readonly publicKey: BoxPublicKey,} export type ClientDetails = {readonly pid: number,readonly clientType: ClientType,readonly argv?: ReadonlyArray | null,readonly desc: string,readonly version: string,} +export type ClientSession = {readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,} +export type ClientState = {readonly session?: ClientSession | null,readonly httpSrvInfo?: HttpSrvInfo | null,readonly appState: MobileAppState,} export type ClientStatus = {readonly details: ClientDetails,readonly connectionID: number,readonly notificationChannels: NotificationChannels,} export type CompatibilityTeamID ={ typ: TeamType.legacy, legacy: TLFID } | { typ: TeamType.modern, modern: TeamID } | { typ: TeamType.none} export type ComponentResult = {readonly name: string,readonly status: Status,readonly exitCode: number,} @@ -2845,6 +2859,7 @@ export type PublicKeyV2 ={ keyType: KeyType.nacl, nacl: PublicKeyV2NaCl } | { ke export type PublicKeyV2Base = {readonly kid: KID,readonly isSibkey: boolean,readonly isEldest: boolean,readonly cTime: Time,readonly eTime: Time,readonly provisioning: SignatureMetadata,readonly revocation?: SignatureMetadata | null,} export type PublicKeyV2NaCl = {readonly base: PublicKeyV2Base,readonly parent?: KID | null,readonly deviceID: DeviceID,readonly deviceDescription: string,readonly deviceType: DeviceTypeV2,} export type PublicKeyV2PGPSummary = {readonly base: PublicKeyV2Base,readonly fingerprint: PGPFingerprint,readonly identities?: ReadonlyArray | null,} +export type PushTapRoute = {readonly url: string,readonly targetUID: string,readonly id: number,} export type RawPhoneNumber = string export type Reachability = {readonly reachable: Reachable,} export type ReadArgs = {readonly opID: OpID,readonly path: Path,readonly offset: number,readonly size: number,} @@ -3125,7 +3140,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.clientState' | 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyApp.pushTapRouteAvailable' | '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.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' @@ -3193,6 +3208,8 @@ export const apiserverDeleteRpcPromise = createRpc('keybase.1.apiserver.Delete') export const apiserverGetWithSessionRpcPromise = createRpc('keybase.1.apiserver.GetWithSession') export const apiserverPostJSONRpcPromise = createRpc('keybase.1.apiserver.PostJSON') export const apiserverPostRpcPromise = createRpc('keybase.1.apiserver.Post') +export const appStateAckPushTapRouteRpcPromise = createRpc('keybase.1.appState.ackPushTapRoute') +export const appStatePeekPushTapRouteRpcPromise = createRpc('keybase.1.appState.peekPushTapRoute') export const appStatePowerMonitorEventRpcPromise = createRpc('keybase.1.appState.powerMonitorEvent') export const appStateUpdateMobileNetStateRpcPromise = createRpc('keybase.1.appState.updateMobileNetState') export const configAppendGUILogsRpcPromise = createRpc('keybase.1.config.appendGUILogs') @@ -3290,7 +3307,6 @@ export const pprofLogTraceRpcPromise = createRpc('keybase.1.pprof.logTrace') export const proveCheckProofRpcPromise = createRpc('keybase.1.prove.checkProof') export const proveStartProofRpcListener = createListener('keybase.1.prove.startProof') export const reachabilityCheckReachabilityRpcPromise = createRpc('keybase.1.reachability.checkReachability') -export const reachabilityStartReachabilityRpcPromise = createRpc('keybase.1.reachability.startReachability') export const rekeyGetRevokeWarningRpcPromise = createRpc('keybase.1.rekey.getRevokeWarning') export const rekeyRekeyStatusFinishRpcPromise = createRpc('keybase.1.rekey.rekeyStatusFinish') export const rekeyShowPendingRekeyStatusRpcPromise = createRpc('keybase.1.rekey.showPendingRekeyStatus') @@ -3615,6 +3631,8 @@ export const userUserCardRpcPromise = createRpc('keybase.1.user.userCard') // 'keybase.1.prove.validateUsername' // 'keybase.1.provisionUi.chooseProvisioningMethod' // 'keybase.1.quota.verifySession' +// 'keybase.1.reachability.reachabilityChanged' +// 'keybase.1.reachability.startReachability' // 'keybase.1.rekey.getPendingRekeyStatus' // 'keybase.1.rekey.debugShowRekeyStatus' // 'keybase.1.rekey.rekeySync' diff --git a/shared/constants/types/index.tsx b/shared/constants/types/index.tsx index 3a18e25dc3f7..8e73d27e532d 100644 --- a/shared/constants/types/index.tsx +++ b/shared/constants/types/index.tsx @@ -6,7 +6,6 @@ export * as Devices from './devices' export type * as Git from './git' export * as More from './more' export type * as People from './people' -export type * as Push from './push' export * as RPCChat from '@/constants/rpc/rpc-chat-gen' export * as RPCGen from '@/constants/rpc/rpc-gen' export type * as RPCGregor from '@/constants/rpc/rpc-gregor-gen' diff --git a/shared/constants/types/push.tsx b/shared/constants/types/push.tsx deleted file mode 100644 index 244b1556dfbf..000000000000 --- a/shared/constants/types/push.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type * as ChatTypes from './chat' -import type * as RPCChatTypes from '@/constants/rpc/rpc-chat-gen' - -export type PushNotification = - | { - badges: number - forUid?: string - type: 'chat.readmessage' - } - | { - conversationIDKey: ChatTypes.ConversationIDKey - membersType: RPCChatTypes.ConversationMembersType - type: 'chat.newmessageSilent_2' - unboxPayload: string - } - | { - conversationIDKey: ChatTypes.ConversationIDKey - forUid?: string - membersType?: RPCChatTypes.ConversationMembersType - type: 'chat.newmessage' - unboxPayload: string - userInteraction: boolean - } - | { - forUid?: string - type: 'follow' - userInteraction: boolean - username: string - } - | { - forUid?: string - type: 'device.revoked' - userInteraction: boolean - } - | { - forUid?: string - type: 'device.new' - userInteraction: boolean - } - | { - forUid?: string - type: 'autoreset' - userInteraction: boolean - } - | { - conversationIDKey: ChatTypes.ConversationIDKey - forUid?: string - type: 'chat.extension' - } - | { - type: 'settings.contacts' - } diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index f0cf37f0ba68..8e64b8c50219 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -257,7 +257,8 @@ function createClient( // from a session cancel handler inside disconnectCallback must // not strand the UI on the disconnect banner by skipping // connectCallback (which synchronously clears the daemon error - // via startHandshake()). + // via startHandshake(), so nothing here may be moved behind an + // await). client.transport.reset() try { disconnectCallback() diff --git a/shared/eslint.config.mjs b/shared/eslint.config.mjs index da278a67355a..a58274791bf4 100644 --- a/shared/eslint.config.mjs +++ b/shared/eslint.config.mjs @@ -7,6 +7,7 @@ import tseslint from 'typescript-eslint' const ignores = [ '**/*.d.ts', + 'android/app/build/**', 'babel.config.js', 'common-adapters/icon.constants-gen.desktop.tsx', 'common-adapters/icon.constants-gen.native.tsx', diff --git a/shared/ios/Keybase.xcodeproj/project.pbxproj b/shared/ios/Keybase.xcodeproj/project.pbxproj index 7750bbcd19d1..5530d8122d57 100644 --- a/shared/ios/Keybase.xcodeproj/project.pbxproj +++ b/shared/ios/Keybase.xcodeproj/project.pbxproj @@ -41,7 +41,9 @@ DBDCF30E1B8D03DD00BA95D8 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DBDCF3081B8D03DD00BA95D8 /* Images.xcassets */; }; DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF89F52DF7779900EA18C2 /* Pusher.swift */; }; DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */; }; + DBLOCWATCH00270000000002 /* LocationWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBLOCWATCH00270000000001 /* LocationWatcher.swift */; }; DBPERF022600000002 /* PerfFPSMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBPERF022600000001 /* PerfFPSMonitor.swift */; }; + DBSCENE00270000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBSCENE00270000000000001 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -119,7 +121,9 @@ DBDCF3441B8D04FC00BA95D8 /* Keybase-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Keybase-Bridging-Header.h"; sourceTree = ""; }; DBDF89F52DF7779900EA18C2 /* Pusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pusher.swift; sourceTree = ""; }; DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareIntentDonatorImpl.swift; sourceTree = ""; }; + DBLOCWATCH00270000000001 /* LocationWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationWatcher.swift; sourceTree = ""; }; DBPERF022600000001 /* PerfFPSMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfFPSMonitor.swift; sourceTree = ""; }; + DBSCENE00270000000000001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; F68DC40B579A1F9AC0F34950 /* Pods_KeybaseShare.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KeybaseShare.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -270,6 +274,7 @@ children = ( 005BF9961BB9C6B000BD8953 /* Keybase.entitlements */, DBB8CC202DF336C200D43215 /* AppDelegate.swift */, + DBSCENE00270000000000001 /* SceneDelegate.swift */, DBDCF3081B8D03DD00BA95D8 /* Images.xcassets */, DBDCF3091B8D03DD00BA95D8 /* Info.plist */, DB07050422E21B8B002F273D /* KeepThisFile.swift */, @@ -278,6 +283,7 @@ DBD252972DF32D5C008A43FF /* Fs.swift */, DBDF89F52DF7779900EA18C2 /* Pusher.swift */, DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */, + DBLOCWATCH00270000000001 /* LocationWatcher.swift */, DBPERF022600000001 /* PerfFPSMonitor.swift */, 832341AE1AAA6A7D00B99B32 /* Libraries */, DAA243C38335068656BC36B1 /* PrivacyInfo.xcprivacy */, @@ -592,9 +598,11 @@ buildActionMask = 2147483647; files = ( DBB8CC212DF336C200D43215 /* AppDelegate.swift in Sources */, + DBSCENE00270000000000002 /* SceneDelegate.swift in Sources */, DB07050522E21B8B002F273D /* KeepThisFile.swift in Sources */, DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */, DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */, + DBLOCWATCH00270000000002 /* LocationWatcher.swift in Sources */, DBD252982DF32D5C008A43FF /* Fs.swift in Sources */, DBPERF022600000002 /* PerfFPSMonitor.swift in Sources */, 9E1460E4A90E8D73A7347FED /* ExpoModulesProvider.swift in Sources */, diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 53ae0007c74b..2d95db3543ff 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -11,40 +11,18 @@ import os private let log = Logger(subsystem: "com.keybase.app", category: "delegate") -class KeyboardWindow: UIWindow { - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let key = presses.first?.key else { - super.pressesBegan(presses, with: event) - return - } - - if key.keyCode == .keyboardReturnOrEnter { - if key.modifierFlags.contains(.shift) { - NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), - object: nil, - userInfo: ["pressedKey": "shift-enter"]) - } else { - NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), - object: nil, - userInfo: ["pressedKey": "enter"]) - } - return - } - - super.pressesBegan(presses, with: event) - } -} - @main -class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { +class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { var window: UIWindow? var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory? + var reactNativeFactoryModuleName: String { "Keybase" } var resignImageView: UIImageView? var fsPaths: [String: String] = [:] - var shutdownTask: UIBackgroundTaskIdentifier = .invalid + private let lifecycle = AppLifecycleForwarder() + private var locationWatcher: LocationWatcher? var iph: ItemProviderHelper? private var startupLogFileHandle: FileHandle? private let logQueue = DispatchQueue(label: "kb.startup.log", qos: .utility) @@ -60,17 +38,6 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte 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) - } - NotificationCenter.default.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main) { [weak self] notification in log.info("Memory warning received - deferring GC during React Native initialization") // see if this helps avoid this crash @@ -89,15 +56,6 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte reactNativeDelegate = delegate reactNativeFactory = factory -#if os(iOS) || os(tvOS) - let screenBounds = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen.bounds ?? UIScreen.main.bounds - window = KeyboardWindow(frame: screenBounds) - factory.startReactNative( - withModuleName: "Keybase", - in: window, - launchOptions: launchOptions) -#endif - self.writeStartupTimingLog("After RN init") self.closeStartupLogFile() @@ -106,31 +64,33 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte // Start FPS monitoring if launched with -PERF_FPS_MONITOR PerfFPSMonitor.startIfEnabled() - if let rootView = self.window?.rootViewController?.view { - self.addDrop(rootView) - self.didLaunchSetupAfter(application: application, rootView: rootView) - } + self.didLaunchSetupAfter() return true } - // Linking API - override func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) - } + // Hardware keyboard enter/shift-enter reaches the app delegate at the end of the + // responder chain (window -> scene -> application -> delegate). + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let key = presses.first?.key else { + super.pressesBegan(presses, with: event) + return + } - // Universal Links - override func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) - return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + if key.keyCode == .keyboardReturnOrEnter { + if key.modifierFlags.contains(.shift) { + NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), + object: nil, + userInfo: ["pressedKey": "shift-enter"]) + } else { + NotificationCenter.default.post(name: NSNotification.Name("hardwareKeyPressed"), + object: nil, + userInfo: ["pressedKey": "enter"]) + } + return + } + + super.pressesBegan(presses, with: event) } /////// KB specific @@ -161,20 +121,19 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte logQueue.async { [weak self] in guard let self else { return } if self.startupLogFileHandle == nil { - if !FileManager.default.fileExists(atPath: logFilePath) { - FileManager.default.createFile( - atPath: logFilePath, - contents: nil, - attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] - ) - } - if let fileHandle = FileHandle(forWritingAtPath: logFilePath) { - fileHandle.seekToEndOfFile() - self.startupLogFileHandle = fileHandle - } else { - NSLog("Error opening startup timing log file: \(logFilePath)") + // Go's logger opens this same file during KeybaseInit, so share it instead of replacing + // it: createFile swaps in a new file by renaming, which leaves Go logging the whole + // session to an unlinked file, and a non-append handle writes over Go's lines. + let fd = open(logFilePath, O_WRONLY | O_CREAT | O_APPEND, 0o600) + guard fd >= 0 else { + NSLog("Error opening startup timing log file: \(logFilePath) errno=\(errno)") return } + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: logFilePath + ) + self.startupLogFileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) } guard let fileHandle = self.startupLogFileHandle else { return } do { @@ -219,7 +178,9 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte log.info("Starting KeybaseInit (synchronous)...") var err: NSError? let shareIntentDonator = ShareIntentDonatorImpl() - Keybasego.KeybaseInit(self.fsPaths["homedir"], self.fsPaths["sharedHome"], self.fsPaths["logFile"], "prod", securityAccessGroupOverride, nil, nil, systemVer, isIPad, nil, isIOS, shareIntentDonator, &err) + let locationWatcher = LocationWatcher() + self.locationWatcher = locationWatcher + Keybasego.KeybaseInit(self.fsPaths["homedir"], self.fsPaths["sharedHome"], self.fsPaths["logFile"], "prod", securityAccessGroupOverride, nil, nil, systemVer, isIPad, nil, isIOS, shareIntentDonator, locationWatcher, &err) if let err { let initResult = "FAILED: \(err.localizedDescription) (code=\(err.code) domain=\(err.domain))" log.error("KeybaseInit FAILED: \(err.localizedDescription, privacy: .public)") @@ -232,46 +193,50 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte 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) UNUserNotificationCenter.current().delegate = self } - func didLaunchSetupAfter(application: UIApplication, rootView: UIView) { - notifyAppState(application) + // BGTaskScheduler.register must run before didFinishLaunching returns, so this + // can't wait for the scene to connect. + func didLaunchSetupAfter() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in + self.handleAppRefresh(task: task as! BGAppRefreshTask) + } + scheduleAppRefresh() + } + + // Called by SceneDelegate once the window exists and React Native has started in it. + func didStartReactNative(in window: UIWindow) { + guard let rootView = window.rootViewController?.view else { return } + addDrop(rootView) rootView.backgroundColor = .systemBackground // Snapshot resizing workaround for iPad - let screenBounds = self.window?.windowScene?.screen.bounds ?? UIScreen.main.bounds + let screenBounds = window.windowScene?.screen.bounds ?? window.bounds var dim = screenBounds.width if screenBounds.height > dim { 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 self.resignImageView?.backgroundColor = rootView.backgroundColor self.resignImageView?.image = UIImage(named: "LaunchImage") - if let view = self.resignImageView { self.window?.addSubview(view) } + if let view = self.resignImageView { window.addSubview(view) } + } - BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in - self.handleAppRefresh(task: task as! BGAppRefreshTask) - } - scheduleAppRefresh() + // 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) { @@ -295,7 +260,8 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte self.iph = ItemProviderHelper(forShare: false, withItems: [items]) { [weak self] in guard let self else { return } let url = URL(string: "keybase://incoming-share")! - _ = self.application(UIApplication.shared, open: url, options: [:]) + let app = UIApplication.shared + _ = self.application(app, open: url, options: [:]) || RCTLinkingManager.application(app, open: url, options: [:]) self.iph = nil } self.iph?.startProcessing() @@ -346,11 +312,8 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte } override func application(_ application: UIApplication, didReceiveRemoteNotification notification: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - guard let type = notification["type"] as? String else { - completionHandler(.noData) - return - } - if type == "chat.newmessageSilent_2" { + switch notification["type"] as? String { + case "chat.newmessageSilent_2": DispatchQueue.global(qos: .default).async { let convID = notification["c"] as? String let messageID = (notification["d"] as? NSNumber)?.intValue ?? 0 @@ -368,44 +331,57 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte var err: NSError? Keybasego.KeybaseHandleBackgroundNotification( convID, body, "", sender, membersType, displayPlaintext, messageID, pushID, badgeCount, - unixTime, soundName, pusher, false, targetUID, &err) + unixTime, soundName, pusher, false, targetUID, pusher, &err) if let err { log.error("Failed to handle in engine: \(err.localizedDescription, privacy: .public)") } completionHandler(.newData) log.info("Remote notification handle finished...") } - } else { - var notificationDict = Dictionary(uniqueKeysWithValues: notification.map { (String(describing: $0.key), $0.value) }) - notificationDict["userInteraction"] = false - KbEmitPushNotification(notificationDict) + case "chat.readmessage": + Self.clearPendingNotificationsIfAllRead(notification) completionHandler(.newData) + default: + completionHandler(.noData) } } + // A read receipt that leaves this account with nothing unread clears the notification + // requests still waiting to show. + private static func clearPendingNotificationsIfAllRead(_ notification: [AnyHashable: Any]) { + let badge = (notification["b"] as? NSNumber)?.intValue ?? Int(notification["b"] as? String ?? "") ?? -1 + guard badge == 0 else { return } + let target = notification["i"] as? String ?? "" + DispatchQueue.global(qos: .default).async { + guard target.isEmpty || target == Keybasego.KeybaseCurrentUID() else { return } + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + } + + // The only way a tap reaches the service. UIKit calls this only for a notification + // delivered to this app; URLs other apps open go through Linking instead, so only real + // taps can carry an account. The payload goes over unread: the service resolves where it + // opens, and nothing here or in JS parses a push. public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo - var notificationDict = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) - notificationDict["userInteraction"] = true - - // Store the notification so it can be processed when app becomes active - // This ensures navigation works even if React Native isn't ready yet - KbSetInitialNotification(notificationDict) - - // Also emit immediately in case React Native is ready - KbEmitPushNotification(notificationDict) + // uniquingKeysWith, not uniqueKeysWithValues: the latter traps on a duplicate key, and + // String(describing:) over [AnyHashable: Any] can in principle produce one. + let payload = Dictionary(userInfo.map { (String(describing: $0.key), $0.value) }, uniquingKeysWith: { first, _ in first }) + if JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) { + Keybasego.KeybaseDeliverPushTap(json) + } else { + log.error("Dropped a notification tap: its payload could not be serialized") + } completionHandler() } public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - let userInfo = notification.request.content.userInfo - var notificationDict = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) - notificationDict["userInteraction"] = false - KbEmitPushNotification(notificationDict) completionHandler([]) } override func applicationWillTerminate(_ application: UIApplication) { self.window?.rootViewController?.view.isHidden = true - Keybasego.KeybaseAppWillExit(PushNotifier()) + lifecycle.willTerminate() } func hideCover() { @@ -424,7 +400,7 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte } completion: { finished in log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") } - Keybasego.KeybaseSetAppStateInactive() + lifecycle.uiInactive() } override func applicationDidEnterBackground(_ application: UIApplication) { @@ -435,56 +411,20 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte 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.") - notifyAppState(application) - - // Re-emit a notification the user tapped while React Native wasn't ready yet. - KbEmitStoredNotificationOnBecomeActive() + lifecycle.didBecomeActive() } override func applicationWillEnterForeground(_ application: UIApplication) { 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) { @@ -493,6 +433,69 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte } +// 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/Info.plist b/shared/ios/Keybase/Info.plist index 4cf9da2c060b..2f56065bc0e3 100644 --- a/shared/ios/Keybase/Info.plist +++ b/shared/ios/Keybase/Info.plist @@ -87,6 +87,23 @@ SourceCodePro-Semibold.ttf kb.ttf + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + BGTaskSchedulerPermittedIdentifiers com.keybase.app.refresh diff --git a/shared/ios/Keybase/LocationWatcher.swift b/shared/ios/Keybase/LocationWatcher.swift new file mode 100644 index 000000000000..6db76692af97 --- /dev/null +++ b/shared/ios/Keybase/LocationWatcher.swift @@ -0,0 +1,88 @@ +import CoreLocation +import Keybasego +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "location") + +// Runs the OS location service for live location (go/chat/maps) without JS. Go +// starts and stops watching; every fix goes back to Go, which decides which to +// record. Created in didFinishLaunching, before Go restores its trackers, so an +// app relaunched by significant-change monitoring starts watching again. Its +// CLLocationManager options match expo-location's background task, which +// Android still uses. +final class LocationWatcher: NSObject, Keybasego.KeybaseNativeLocationWatcherProtocol, CLLocationManagerDelegate { + // Everything below is main thread only. + private let manager = CLLocationManager() + private var wanted = false + private var running = false + + // Go records a fix to disk, so fixes go to it off the main thread, in order. + private let goQueue = DispatchQueue(label: "com.keybase.app.location", qos: .utility) + + override init() { + super.init() + manager.delegate = self + } + + // Called by Go on a Go thread. + func startWatching() { + DispatchQueue.main.async { + self.wanted = true + self.apply() + } + } + + func stopWatching() { + DispatchQueue.main.async { + self.wanted = false + self.apply() + } + } + + // The prompt is asked for in JS when sharing starts; once the user answers, + // this starts watching if Go still wants it. + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + apply() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard running else { return } + let fixes = locations.filter { $0.horizontalAccuracy >= 0 }.map { + (coordinate: $0.coordinate, accuracy: Int($0.horizontalAccuracy)) + } + goQueue.async { + for fix in fixes { + Keybasego.KeybaseLocationUpdate(fix.coordinate.latitude, fix.coordinate.longitude, fix.accuracy) + } + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + log.error("location update failed: \(error.localizedDescription, privacy: .public)") + } + + private func apply() { + let status = manager.authorizationStatus + let authorized = status == .authorizedAlways || status == .authorizedWhenInUse + if wanted && !authorized { + log.warning("not watching location: not authorized (status \(status.rawValue))") + } + if wanted && authorized && !running { + log.info("starting location updates") + running = true + manager.allowsBackgroundLocationUpdates = true + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + manager.distanceFilter = kCLDistanceFilterNone + manager.activityType = .other + manager.pausesLocationUpdatesAutomatically = true + manager.showsBackgroundLocationIndicator = true + manager.startUpdatingLocation() + manager.startMonitoringSignificantLocationChanges() + } else if running && !(wanted && authorized) { + log.info("stopping location updates") + running = false + manager.stopUpdatingLocation() + manager.stopMonitoringSignificantLocationChanges() + } + } +} diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift new file mode 100644 index 000000000000..59b34b40f2f1 --- /dev/null +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -0,0 +1,22 @@ +internal import Expo +import UIKit + +@objc(SceneDelegate) +class SceneDelegate: ExpoAppSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + + 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() + } +} diff --git a/shared/login/loading.tsx b/shared/login/loading.tsx index bac0b0886a50..a95e7da4b2c2 100644 --- a/shared/login/loading.tsx +++ b/shared/login/loading.tsx @@ -26,7 +26,7 @@ const SplashContainer = () => { C.Router2.navigateAppend({name: 'feedback', params: {}}) } : undefined - const onRetry = handshakeFailed ? startHandshake : undefined + const onRetry = handshakeFailed ? () => startHandshake() : undefined return } diff --git a/shared/package.json b/shared/package.json index 81d76e25e73f..d32996bb1ab5 100644 --- a/shared/package.json +++ b/shared/package.json @@ -76,12 +76,13 @@ "test:e2e:ios:ipad": "bash tests/e2e/run-ios-appium.sh iPadTest", "test:e2e:ios:iphone-old": "bash tests/e2e/run-ios-appium.sh iPhoneTestOld", "test:e2e:ios:ipad-old": "bash tests/e2e/run-ios-appium.sh iPadTestOld", + "test:e2e:ios:lifecycle": "bash tests/e2e/run-ios-lifecycle.sh iPhoneTest", "test:e2e:ios:report": "node tests/e2e/generate-appium-report.mts && open tests/results/ios-appium-report.html", "test:e2e:android": "bash tests/e2e/run-android-appium.sh", "test:e2e:android:report": "node tests/e2e/generate-appium-report.mts android && open tests/results/android-appium-report.html", "test:unit": "napi-postinstall unrs-resolver 1.11.1 check; jest --runInBand", "test:unit:ios": "xcodebuild test -project ./ios/Keybase.xcodeproj -scheme 'Keybase For Test' -destination 'platform=iOS Simulator,name=iPhone 6s,OS=9.3'", - "tsc": "./node_modules/typescript-native/bin/tsc --project ./tsconfig.desktop.json && ./node_modules/typescript-native/bin/tsc --project ./tsconfig.native.json" + "tsc": "./node_modules/typescript-native/bin/tsc --project ./tsconfig.desktop.json && ./node_modules/typescript-native/bin/tsc --project ./tsconfig.native.json && ./node_modules/typescript-native/bin/tsc --project ./tests/e2e/ios-appium/tsconfig.json" }, "keywords": [], "author": "", diff --git a/shared/router-v2/account-link-switch.test.ts b/shared/router-v2/account-link-switch.test.ts new file mode 100644 index 000000000000..1cb85b304bf1 --- /dev/null +++ b/shared/router-v2/account-link-switch.test.ts @@ -0,0 +1,165 @@ +/// +import * as T from '@/constants/types' +import RPCError from '@/util/rpcerror' +import {resetAllStores} from '@/util/zustand' +import {subscribeIntentAccountSwitch} from './account-link-switch' +import {enqueuePushTapRoute, emitDeepLink} from './deep-link-emitter' +import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' +import {useDaemonState} from '@/stores/daemon' +import {useNavigationIntentsState} from '@/stores/navigation-intents' + +const currentAccount = {hasStoredSecret: true, uid: 'uid-current', username: 'testuser'} +const otherAccount = {hasStoredSecret: true, uid: 'uid-other', username: 'testuser-mac'} +const noSecretAccount = {hasStoredSecret: false, uid: 'uid-nosecret', username: 'testuser-nosecret'} +const allAccounts = [currentAccount, otherAccount, noSecretAccount] + +// A push tap's id must not repeat across tests any more than it does across taps, so every call +// here gets a fresh one; the ack RPC stays mocked until cleanup has acknowledged a still-pending +// intent, so that acknowledgement makes no real RPC call. +let nextTapID = 9000 +const tapFor = (uid: string) => + enqueuePushTapRoute({id: ++nextTapID, targetUID: uid, url: 'keybase://convid/0000ab'}) + +let login = jest.fn() +let unsub: (() => void) | undefined + +const setAccounts = (configuredAccounts: typeof allAccounts) => { + useConfigState.setState({configuredAccounts}) +} + +// navigation-intents' resetState deliberately keeps account-targeted intents. +const clearIntent = () => { + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) +} + +beforeEach(() => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + login = jest.fn() + useNavigationIntentsState.setState({lastHandledIntent: undefined}) + useDaemonState.setState({handshakeState: 'done'}) + useCurrentUserState.setState({uid: currentAccount.uid, username: currentAccount.username}) + // config's resetState deliberately keeps userSwitching, so clear it here. + useConfigState.setState({ + configuredAccounts: allAccounts, + dispatch: {...useConfigState.getState().dispatch, login}, + loggedIn: true, + loginError: undefined, + userSwitching: false, + }) + unsub = subscribeIntentAccountSwitch() +}) + +afterEach(() => { + unsub?.() + unsub = undefined + clearIntent() + resetAllStores() + jest.restoreAllMocks() +}) + +test('a tap for the current account does not switch', () => { + tapFor(currentAccount.uid) + + expect(login).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent?.targetUid).toBe(currentAccount.uid) +}) + +test('a tap for a stored account switches to it once', () => { + tapFor(otherAccount.uid) + + expect(login).toHaveBeenCalledTimes(1) + expect(login).toHaveBeenCalledWith(otherAccount.username, '') + expect(useConfigState.getState().userSwitching).toBe(true) + + setAccounts([...allAccounts]) + + expect(login).toHaveBeenCalledTimes(1) +}) + +test('a tap for an account not listed yet waits for the account list', () => { + setAccounts([currentAccount]) + tapFor(otherAccount.uid) + + expect(login).not.toHaveBeenCalled() + + setAccounts(allAccounts) + + expect(login).toHaveBeenCalledTimes(1) +}) + +test('a tap for an account without a stored secret is dropped', () => { + tapFor(noSecretAccount.uid) + + expect(login).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +// Dropped here means no navigation is ever coming for it, so this is where the tap's route must +// be acked -- there is no other consumption point left to do it. +test('a tap dropped for a missing stored secret acks its route', () => { + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const id = ++nextTapID + + enqueuePushTapRoute({id, targetUID: noSecretAccount.uid, url: 'keybase://convid/0000ab'}) + + expect(ack).toHaveBeenCalledWith({id}) +}) + +test('nothing switches before the handshake is done', () => { + useDaemonState.setState({handshakeState: 'loading'}) + tapFor(otherAccount.uid) + + expect(login).not.toHaveBeenCalled() + + useDaemonState.setState({handshakeState: 'done'}) + + expect(login).toHaveBeenCalledTimes(1) +}) + +test('a login error drops the tap', () => { + tapFor(otherAccount.uid) + expect(login).toHaveBeenCalledTimes(1) + + useConfigState.setState({loginError: new RPCError('bad', 1), userSwitching: false}) + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +test('a login error dropping the tap acks its route', () => { + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const id = ++nextTapID + enqueuePushTapRoute({id, targetUID: otherAccount.uid, url: 'keybase://convid/0000ab'}) + expect(ack).not.toHaveBeenCalled() + + useConfigState.setState({loginError: new RPCError('bad', 1), userSwitching: false}) + + expect(ack).toHaveBeenCalledWith({id}) +}) + +test('logging out drops a tap for another account', () => { + useConfigState.setState({configuredAccounts: [], loggedIn: true}) + tapFor(otherAccount.uid) + + useConfigState.setState({loggedIn: false, userSwitching: false}) + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +test('a foreign link naming a stored account never switches', () => { + emitDeepLink(`keybase://profile/show/${otherAccount.username}`) + + expect(login).not.toHaveBeenCalled() + expect(useConfigState.getState().userSwitching).toBe(false) +}) + +test('a switch already under way is not restarted when userSwitching clears early', () => { + tapFor(otherAccount.uid) + expect(login).toHaveBeenCalledTimes(1) + + // the replacement router's onReady clears userSwitching before the new uid lands + useConfigState.setState({userSwitching: false}) + + expect(login).toHaveBeenCalledTimes(1) +}) diff --git a/shared/router-v2/account-link-switch.tsx b/shared/router-v2/account-link-switch.tsx new file mode 100644 index 000000000000..f50289d52989 --- /dev/null +++ b/shared/router-v2/account-link-switch.tsx @@ -0,0 +1,66 @@ +import logger from '@/logger' +import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' +import {useDaemonState} from '@/stores/daemon' +import {useNavigationIntentsState} from '@/stores/navigation-intents' + +type ConfigState = ReturnType + +const tapForOtherAccount = () => { + const {intent} = useNavigationIntentsState.getState() + return intent?.targetUid && intent.targetUid !== useCurrentUserState.getState().uid ? intent : undefined +} + +// A tapped push for another account waits in the intent store until that account is current. This +// switches to it: to a stored account once, never to one without a stored secret, and it drops the +// tap when the switch fails or the user logs out. Only enqueuePushTapRoute sets targetUid, and only +// a route the service resolved from a real notification tap reaches it, so no link another app +// opens can switch accounts. +// +// Both drops below go through dispatch.acknowledge, which also acks the tap's route with the +// service -- there is no navigation coming for it, so this is where it is given up on for good. +export const subscribeIntentAccountSwitch = () => { + // userSwitching already gates a second login, but it is cleared by the replacement router's + // onReady, which can run before the new uid lands; keying on the intent makes the switch + // exactly-once without depending on that ordering. + let switchingFor: number | undefined + const check = () => { + const intent = tapForOtherAccount() + if (!intent || switchingFor === intent.id) return + const {configuredAccounts, dispatch, userSwitching} = useConfigState.getState() + if (userSwitching || useDaemonState.getState().handshakeState !== 'done') return + const account = configuredAccounts.find(a => a.uid === intent.targetUid) + if (!account) return + if (!account.hasStoredSecret) { + logger.info('[AccountLink] target account has no stored secret, dropping the tap') + useNavigationIntentsState.getState().dispatch.acknowledge(intent.id) + return + } + switchingFor = intent.id + logger.info('[AccountLink] switching accounts for a tapped push') + dispatch.setUserSwitching(true) + dispatch.login(account.username, '') + } + const dropOnFailure = (s: ConfigState, old: ConfigState) => { + const loginFailed = !!s.loginError && s.loginError !== old.loginError + const loggedOut = s.loggedIn !== old.loggedIn && !s.loggedIn && !s.userSwitching + if (!loginFailed && !loggedOut) return + const intent = tapForOtherAccount() + if (!intent) return + logger.info('[AccountLink] dropping a tap for another account after a failed switch or logout') + useNavigationIntentsState.getState().dispatch.acknowledge(intent.id) + } + const unsubs = [ + useNavigationIntentsState.subscribe(check), + useConfigState.subscribe((s, old) => { + dropOnFailure(s, old) + check() + }), + useCurrentUserState.subscribe(check), + useDaemonState.subscribe(check), + ] + check() + return () => { + for (const unsub of unsubs) unsub() + } +} diff --git a/shared/router-v2/deep-link-emitter.test.ts b/shared/router-v2/deep-link-emitter.test.ts index c2cec9b60962..8a25a45a35a0 100644 --- a/shared/router-v2/deep-link-emitter.test.ts +++ b/shared/router-v2/deep-link-emitter.test.ts @@ -1,6 +1,11 @@ /// +import * as T from '@/constants/types' import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {emitDeepLink, setInitialURLOnce} from './deep-link-emitter' +import {emitDeepLink, enqueuePushTapRoute, setInitialURLOnce} from './deep-link-emitter' + +// A push tap's id must not repeat across tests any more than it does across taps. +let nextTapID = 8000 +const tapID = () => ++nextTapID const resetNavigationIntents = () => { const {intent, dispatch} = useNavigationIntentsState.getState() @@ -10,8 +15,13 @@ const resetNavigationIntents = () => { dispatch.resetState() } +beforeEach(() => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) +}) + afterEach(() => { resetNavigationIntents() + jest.restoreAllMocks() }) test('normalizes and enqueues a deep link until navigation can consume it', () => { @@ -54,3 +64,36 @@ test('removes a queued deep link when the initial URL handles it', () => { expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) + +test('a foreign link never targets an account', () => { + emitDeepLink('keybase://convid/0000ab') + + const {intent} = useNavigationIntentsState.getState() + expect(intent?.url).toBe('keybase://convid/0000ab') + expect(intent?.targetUid).toBeUndefined() +}) + +test('a tap targets its account', () => { + enqueuePushTapRoute({id: tapID(), targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) + + const {intent} = useNavigationIntentsState.getState() + expect(intent?.url).toBe('keybase://convid/0000ab') + expect(intent?.targetUid).toBe('uid-other') +}) + +test('a tap for a link a foreign open already queued upgrades that intent', () => { + emitDeepLink('keybase://convid/0000ab') + enqueuePushTapRoute({id: tapID(), targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) + + expect(useNavigationIntentsState.getState().intent?.targetUid).toBe('uid-other') +}) + +// The service leaves targetUID empty for a route no account owns, and an empty one must not +// read as a target: an intent with one is what account-link-switch acts on. +test('a tap with no account is not a targeted intent', () => { + enqueuePushTapRoute({id: tapID(), targetUID: '', url: 'keybase://tabs.peopleTab'}) + + const {intent} = useNavigationIntentsState.getState() + expect(intent?.url).toBe('keybase://tabs.peopleTab') + expect(intent?.targetUid).toBeUndefined() +}) diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index e3a39b62f4f2..c54e38365def 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -1,11 +1,10 @@ -import { - type NavigationIntentOptions, - useNavigationIntentsState, -} from '@/stores/navigation-intents' +import logger from '@/logger' +import {useNavigationIntentsState} from '@/stores/navigation-intents' -// Deep-link emission + URL normalization. Kept separate from './linking' -// (which imports the config/push/current-user stores) so stores/push can enqueue -// navigation without importing the router's linking config. +// Deep-link emission + URL normalization. Kept separate from './linking' so +// stores/push can enqueue navigation without importing the router's linking config +// (which pulls in the config/push/current-user stores and the route tables). This +// leaf depends on the navigation-intents store and nothing else. // ---- URL normalization ---- @@ -40,6 +39,15 @@ const normalizeHttpUrl = (url: string): string | undefined => { : `keybase://team-page/${teamName}` } + // /phone-app — the install link our own chat invite banner texts to an unresolved @phone + // participant (chat/conversation/bottom-banner.tsx). It is not a username, so it has to be + // carved out ahead of the single-segment rule below, which would otherwise open a profile + // for a user that does not exist. It always opens Add Phone Number: the invitee's inviter + // wrote to a number, and nothing here knows (or waits to learn) whether they have one. + if (pathname === '/phone-app' || pathname === '/phone-app/') { + return 'keybase://settingsAddPhone' + } + // /username (single path segment) const userMatch = pathname.match(/^\/((?:[a-zA-Z0-9][a-zA-Z0-9_-]?)+)\/?$/) if (userMatch?.[1]) { @@ -66,8 +74,27 @@ export const setInitialURLOnce = (url: string) => { // Producers only enqueue navigation intent. The active router consumes it once // the intended account is active and its NavigationContainer is ready. -export const emitDeepLink = (url: string, options?: NavigationIntentOptions) => { +// +// A link here can come from any app, web page or typed URL, so it never carries +// a targetUid: only enqueuePushTapRoute may target (and so switch) an account. +export const emitDeepLink = (url: string) => { const normalized = normalizeUrl(url) if (!normalized) return - useNavigationIntentsState.getState().dispatch.enqueue(normalized, options) + useNavigationIntentsState.getState().dispatch.enqueue(normalized) +} + +// ---- Notification taps ---- + +// For routes read from the service's pending-tap holder only (see +// constants/init/shared). The service fills that holder from its push-tap bind +// verb and nothing else, so a targetUID here can only have come from a real +// notification tap, and no link another app opens can switch accounts. +// +// id is the Go route id: carried on the intent so whoever consumes it (or drops it for good) can +// ack it there instead of here, since here the tap isn't queued yet, let alone acted on. +export const enqueuePushTapRoute = (route: {url: string; targetUID: string; id: number}) => { + logger.info('[PushTap] queued a tap link:', route.url) + useNavigationIntentsState + .getState() + .dispatch.enqueue(route.url, {pushTapID: route.id, targetUid: route.targetUID || undefined}) } diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index 4223f19e0926..0b6ade323c21 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -1,9 +1,10 @@ /// +import * as T from '@/constants/types' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' import {resetAllStores} from '@/util/zustand' -import {emitDeepLink} from './deep-link-emitter' +import {emitDeepLink, enqueuePushTapRoute} from './deep-link-emitter' import {subscribeNavigationIntents} from './linking' const setCurrentUser = (uid: string) => { @@ -27,6 +28,7 @@ const clearIntent = () => { } beforeEach(() => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) useConfigState.getState().dispatch.setLoggedIn(true) useConfigState.getState().dispatch.setUserSwitching(false) setCurrentUser('current-uid') @@ -34,9 +36,40 @@ beforeEach(() => { }) afterEach(() => { - jest.restoreAllMocks() clearIntent() resetAllStores() + jest.restoreAllMocks() +}) + +test('consuming an intent acks the tap route it carries', () => { + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const listener = jest.fn() + // The store notifies subscribers synchronously, so a ready router consumes (and acks) an + // enqueued intent before enqueuePushTapRoute below returns. + const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) + + enqueuePushTapRoute({id: 4242, targetUID: 'current-uid', url: 'keybase://convid/tap-conversation'}) + + expect(listener).toHaveBeenCalledWith('keybase://convid/tap-conversation') + expect(ack).toHaveBeenCalledWith({id: 4242}) + unsubscribe() +}) + +test('a stale intent that is dropped without navigating still acks its tap route', () => { + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const now = jest.spyOn(Date, 'now') + now.mockReturnValue(1_000) + useConfigState.getState().dispatch.setUserSwitching(true) + const listener = jest.fn() + const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) + + enqueuePushTapRoute({id: 4343, targetUID: 'current-uid', url: 'keybase://convid/stale-tap'}) + now.mockReturnValue(1_000 + 5 * 60_000 + 1) + useConfigState.getState().dispatch.setUserSwitching(false) + + expect(listener).not.toHaveBeenCalled() + expect(ack).toHaveBeenCalledWith({id: 4343}) + unsubscribe() }) test('profile links route imperatively so their back stack is built', () => { @@ -139,7 +172,7 @@ test('an account-targeted intent survives the store reset an account switch perf const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) useConfigState.getState().dispatch.setUserSwitching(true) - emitDeepLink('keybase://convid/switch-target-conversation', {targetUid: 'target-uid'}) + enqueuePushTapRoute({id: 4444, targetUID: 'target-uid', url: 'keybase://convid/switch-target-conversation'}) expect(listener).not.toHaveBeenCalled() // the service's loggedOut notification lands mid-switch and resets every store diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts index 4930318e5da8..a25bfe617883 100644 --- a/shared/router-v2/linking-initial-url.test.ts +++ b/shared/router-v2/linking-initial-url.test.ts @@ -7,6 +7,7 @@ import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' import {usePushState} from '@/stores/push' import {createLinkingConfig} from './linking' +import {enqueuePushTapRoute} from './deep-link-emitter' const setCurrentUser = (uid: string) => { useCurrentUserState.getState().dispatch.setBootstrap({ @@ -20,8 +21,6 @@ const setCurrentUser = (uid: string) => { type Startup = { conversation: T.Chat.ConversationIDKey conversationUid?: string - followUser: string - link: string tab?: Tabs.Tab } @@ -31,8 +30,6 @@ const setStartup = (st: Partial) => { useConfigState.setState({ startup: { conversation: T.Chat.noConversationIDKey, - followUser: '', - link: '', loaded: true, ...st, }, @@ -46,13 +43,22 @@ const getInitialURL = async () => { const handleAppLink = jest.fn() +// A push tap's id must not repeat across tests any more than it does across taps. +let nextTapID = 5000 +const tapID = () => ++nextTapID + beforeEach(() => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) useConfigState.getState().dispatch.setLoggedIn(true) setCurrentUser('current-uid') }) afterEach(() => { handleAppLink.mockReset() + // resetAllStores deliberately keeps account-targeted intents; drop them here. + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) + jest.restoreAllMocks() resetAllStores() }) @@ -93,16 +99,32 @@ test('a conversation persisted by this account is kept', async () => { await expect(getInitialURL()).resolves.toBe('keybase://convid/conv-1') }) -test('a follow-user startup opens their profile when there is no conversation', async () => { - setStartup({followUser: 'testuser'}) +test('a cold tap for the current account is the startup route, ahead of saved state', async () => { + setStartup({conversation: 'conv-1'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) - await expect(getInitialURL()).resolves.toBe('keybase://profile/show/testuser') + await expect(getInitialURL()).resolves.toBe('keybase://convid/0000ab') + expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) -test('a saved conversation wins over a follow-user startup', async () => { - setStartup({conversation: 'conv-1', followUser: 'testuser'}) +test('getInitialURL taking a cold tap acks its route', async () => { + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const id = tapID() + setStartup({conversation: 'conv-1'}) + enqueuePushTapRoute({id, targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) + expect(ack).not.toHaveBeenCalled() + + await expect(getInitialURL()).resolves.toBe('keybase://convid/0000ab') + + expect(ack).toHaveBeenCalledWith({id}) +}) + +test('a cold tap for another account opens saved state and waits for the switch', async () => { + setStartup({conversation: 'conv-1'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'other-uid', url: 'keybase://convid/0000ab'}) await expect(getInitialURL()).resolves.toBe('keybase://convid/conv-1') + expect(useNavigationIntentsState.getState().intent?.targetUid).toBe('other-uid') }) test('the push prompt wins when there is nothing saved to restore', async () => { @@ -157,3 +179,12 @@ test('the returned initial url is recorded so the same deep link is not re-enque expect(useNavigationIntentsState.getState().lastHandledIntent?.url).toBe(`keybase://${Tabs.chatTab}`) }) + +test('a queued tap older than the intent lifetime is not the startup route', async () => { + setStartup({conversation: 'conv-1'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) + const intent = useNavigationIntentsState.getState().intent + useNavigationIntentsState.setState({intent: {...intent!, createdAt: Date.now() - 6 * 60_000}}) + + await expect(getInitialURL()).resolves.toBe('keybase://convid/conv-1') +}) diff --git a/shared/router-v2/linking-state.test.ts b/shared/router-v2/linking-state.test.ts index 72e211644714..ce2c3ad5e3a7 100644 --- a/shared/router-v2/linking-state.test.ts +++ b/shared/router-v2/linking-state.test.ts @@ -144,6 +144,17 @@ test('the push prompt is a modal with no tab parked underneath', () => { }) }) +test('add-phone is a modal over the settings tab', () => { + expect(isHandledByLinkingConfig('keybase://settingsAddPhone')).toBe(true) + expect(getStateFromPath('settingsAddPhone')).toEqual({ + index: 1, + routes: [ + {name: 'loggedIn', state: {index: 0, routes: [{name: Tabs.settingsTab}]}}, + {name: 'settingsAddPhone'}, + ], + }) +}) + test('every app tab name is a bare tab switch', () => { for (const tab of [ Tabs.chatTab, diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts index cb61c28fc083..950b35e02542 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -1,9 +1,12 @@ /// +import * as T from '@/constants/types' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {emitDeepLink} from './deep-link-emitter' -import {subscribeNavigationIntents} from './linking' +import {emitDeepLink, enqueuePushTapRoute} from './deep-link-emitter' +import * as Settings from '@/constants/settings' +import * as Tabs from '@/constants/tabs' +import {createLinkingConfig, isHandledByLinkingConfig, subscribeNavigationIntents} from './linking' const setCurrentUser = (uid: string) => { useCurrentUserState.getState().dispatch.setBootstrap({ @@ -14,6 +17,10 @@ const setCurrentUser = (uid: string) => { }) } +// A push tap's id must not repeat across tests any more than it does across taps. +let nextTapID = 10_000 +const tapID = () => ++nextTapID + const clearIntent = () => { const {intent, dispatch} = useNavigationIntentsState.getState() if (intent) { @@ -23,6 +30,7 @@ const clearIntent = () => { } beforeEach(() => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) useConfigState.getState().dispatch.setLoggedIn(true) useConfigState.getState().dispatch.setUserSwitching(false) setCurrentUser('current-uid') @@ -30,6 +38,7 @@ beforeEach(() => { afterEach(() => { clearIntent() + jest.restoreAllMocks() }) test('waits for navigation readiness before consuming an intent', () => { @@ -64,7 +73,7 @@ test('waits until the intended account is active', () => { const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - emitDeepLink('keybase://convid/target-account-conversation', {targetUid: 'target-uid'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'target-uid', url: 'keybase://convid/target-account-conversation'}) expect(listener).not.toHaveBeenCalled() setCurrentUser('target-uid') @@ -84,7 +93,7 @@ test('waits for an account switch to finish', () => { const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - emitDeepLink('keybase://convid/account-switch-conversation', {targetUid: 'current-uid'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'current-uid', url: 'keybase://convid/account-switch-conversation'}) expect(listener).not.toHaveBeenCalled() useConfigState.getState().dispatch.setUserSwitching(false) @@ -100,9 +109,7 @@ test('waits for the replacement router after the current account changes', () => const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - emitDeepLink('keybase://convid/replacement-router-conversation', { - targetUid: 'target-uid', - }) + enqueuePushTapRoute({id: tapID(), targetUID: 'target-uid', url: 'keybase://convid/replacement-router-conversation'}) setCurrentUser('target-uid') // The bootstrap UID can change before React commits the keyed router remount. @@ -148,3 +155,56 @@ test('consumes an intent after bootstrap fills in the uid the router readied wit expect(listener).toHaveBeenCalledWith('keybase://convid/post-bootstrap-conversation') unsubscribe() }) + +const getStateFromPath = (path: string) => + (createLinkingConfig(jest.fn()).getStateFromPath as (p: string) => unknown)(path) + +test('a devices link is consumed by the linking config, not by handleAppLink', () => { + useNavigationIntentsState.getState().dispatch.setNavigationReady(true, 'current-uid') + const listener = jest.fn() + const handleAppLink = jest.fn() + const unsubscribe = subscribeNavigationIntents(listener, handleAppLink) + + emitDeepLink('keybase://devices') + + expect(isHandledByLinkingConfig('keybase://devices')).toBe(true) + expect(listener).toHaveBeenCalledWith('keybase://devices') + expect(handleAppLink).not.toHaveBeenCalled() + unsubscribe() +}) + +test('a devices link opens the devices screen in the settings tab on mobile', () => { + const wasMobile = global.isMobile + global.isMobile = true + try { + expect(getStateFromPath('devices')).toEqual({ + index: 0, + routes: [ + { + name: 'loggedIn', + state: { + index: 0, + routes: [ + { + name: Tabs.settingsTab, + state: { + index: 1, + routes: [{name: 'settingsRoot'}, {name: Settings.settingsDevicesTab}], + }, + }, + ], + }, + }, + ], + }) + } finally { + global.isMobile = wasMobile + } +}) + +test('a devices link opens the devices tab on desktop', () => { + expect(getStateFromPath('devices')).toEqual({ + index: 0, + routes: [{name: 'loggedIn', state: {index: 0, routes: [{name: Tabs.devicesTab}]}}], + }) +}) diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 6f3442f00728..b73e35c4d421 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -1,4 +1,6 @@ +import * as Settings from '@/constants/settings' import * as Tabs from '@/constants/tabs' +import logger from '@/logger' import {isSplit} from '@/constants/chat/layout' import {isValidConversationIDKey, stringToConversationIDKey} from '@/constants/types/chat/common' import {useConfigState} from '@/stores/config' @@ -95,6 +97,8 @@ const navigationIntentLifetimeMs = 5 * 60_000 // The router owns consumption. Producers can enqueue before this subscription // exists, during an account switch, or before NavigationContainer is ready. +// Every dispatch.acknowledge below -- whether the intent is actually navigated or given up on as +// stale -- is also what acks a tap's route with the service, if the intent carries one. export const subscribeNavigationIntents = ( listener: (url: string) => void, handleAppLink: (link: string) => void @@ -124,6 +128,9 @@ export const subscribeNavigationIntents = ( try { // Profile links use imperative navigation to build their intermediate // back stack. Other known URLs can use React Navigation's linking state. + // This split only differs on mobile: desktop passes handleAppLink as both + // arguments (router.tsx), so every URL there lands in handleKeybaseLink, + // which must therefore stay correct for URLs the config also handles. if (intent.url.startsWith('keybase://profile/')) { handleAppLink(intent.url) } else if (isHandledByLinkingConfig(intent.url)) { @@ -183,6 +190,13 @@ const customGetStateFromPath = ( // profile/new-proof is handled by handleAppLink fallback for now break + // keybase://devices — a tap on a device push. Devices live in the Settings tab on phone and + // tablet, and in their own tab on desktop. + case 'devices': + return isMobile + ? makeTabState(Tabs.settingsTab, [{name: 'settingsRoot'}, {name: Settings.settingsDevicesTab}]) + : makeTabState(Tabs.devicesTab) + // KBFS paths: keybase://private/..., keybase://public/... case 'private': case 'public': { @@ -227,6 +241,11 @@ const customGetStateFromPath = ( case 'settingsPushPrompt': return makeModalState('settingsPushPrompt') + // keybase://settingsAddPhone — where https://keybase.io/phone-app lands. Settings sits + // under the modal so dismissing it leaves the invitee somewhere they can find it again. + case 'settingsAddPhone': + return makeModalState('settingsAddPhone', undefined, Tabs.settingsTab) + // Tab switches: keybase://tabs.chatTab, etc. case Tabs.chatTab: case Tabs.peopleTab: @@ -247,6 +266,17 @@ const customGetStateFromPath = ( // ---- Linking config ---- +// Known URLs become launch state; the rest open imperatively once the router is up. +// setInitialURLOnce also consumes: markInitialURLHandled clears a pending intent with the +// same URL, so subscribeNavigationIntents won't navigate to it a second time, and acks the +// intent's tap route with the service if it carried one. +const openInitialLink = (link: string, handleAppLink: (link: string) => void) => { + if (isHandledByLinkingConfig(link)) return setInitialURLOnce(link) + setInitialURLOnce(link) + setTimeout(() => handleAppLink(link), 1) + return null +} + export const createLinkingConfig = ( handleAppLink: (link: string) => void ): LinkingOptions => { @@ -255,7 +285,7 @@ export const createLinkingConfig = ( const {loggedIn, startup, androidShare} = useConfigState.getState() if (!loggedIn) return null - const {tab: startupTab, followUser: startupFollowUser} = startup + const {tab: startupTab} = startup let startupConversation = startup.conversation if (!isValidConversationIDKey(startupConversation)) { startupConversation = '' @@ -268,6 +298,18 @@ export const createLinkingConfig = ( startupConversation = '' } + // A tapped push picks where the app opens, once its account is current. A tap for + // another account stays queued until account-link-switch has switched to it. The same + // lifetime applies here as in subscribeNavigationIntents. + const {intent} = useNavigationIntentsState.getState() + if ( + intent && + Date.now() - intent.createdAt <= navigationIntentLifetimeMs && + (!intent.targetUid || intent.targetUid === currentUid) + ) { + return openInitialLink(intent.url, handleAppLink) + } + const pushState = usePushState.getState() const showMonster = !pushState.justSignedUp && pushState.showPushPrompt && !pushState.hasPermissions @@ -284,11 +326,7 @@ export const createLinkingConfig = ( if (deepLinkUrl) { const normalized = normalizeUrl(deepLinkUrl) if (normalized) { - if (isHandledByLinkingConfig(normalized)) return setInitialURLOnce(normalized) - // URL not handled by linking config; use imperative navigation as fallback - setInitialURLOnce(normalized) - setTimeout(() => handleAppLink(normalized), 1) - return null + return openInitialLink(normalized, handleAppLink) } } @@ -300,10 +338,6 @@ export const createLinkingConfig = ( return setInitialURLOnce('keybase://incoming-share') } - if (startupFollowUser && !startupConversation) { - return setInitialURLOnce(`keybase://profile/show/${startupFollowUser}`) - } - if (startupConversation) { return setInitialURLOnce(`keybase://convid/${startupConversation}`) } @@ -330,6 +364,7 @@ export const createLinkingConfig = ( let removeLinkingSub: (() => void) | undefined if (isMobile) { const sub = Linking.addEventListener('url', ({url}: {url: string}) => { + logger.info('[DeepLink] url event:', url) emitDeepLink(url) }) removeLinkingSub = () => sub.remove() diff --git a/shared/router-v2/url-normalize.test.ts b/shared/router-v2/url-normalize.test.ts index 6f7707c9cda5..5b857c4faf1a 100644 --- a/shared/router-v2/url-normalize.test.ts +++ b/shared/router-v2/url-normalize.test.ts @@ -1,5 +1,6 @@ /// import {normalizeUrl} from './deep-link-emitter' +import {useSettingsPhoneState} from '@/stores/settings-phone' test('keybase urls pass through untouched', () => { expect(normalizeUrl('keybase://convid/conv-1')).toBe('keybase://convid/conv-1') @@ -79,3 +80,15 @@ test('a slash-separated subteam path is not a team-page link', () => { // second segment and nothing matches expect(normalizeUrl('https://keybase.io/team/keybase/sub')).toBeUndefined() }) + +test('the invite install link opens add-phone, not a profile for a user named phone-app', () => { + expect(normalizeUrl('https://keybase.io/phone-app')).toBe('keybase://settingsAddPhone') + expect(normalizeUrl('https://keybase.io/phone-app/')).toBe('keybase://settingsAddPhone') + expect(normalizeUrl('https://keybase.io/phone-app?utm=x')).toBe('keybase://settingsAddPhone') +}) + +test('the invite install link opens add-phone even when the user already has a number', () => { + useSettingsPhoneState.setState({phones: new Map([['+15555555555', {} as never]])}) + expect(normalizeUrl('https://keybase.io/phone-app')).toBe('keybase://settingsAddPhone') + useSettingsPhoneState.getState().dispatch.resetState() +}) diff --git a/shared/settings/load-settings.tsx b/shared/settings/load-settings.tsx index 8770fd103a4a..9a082d7b4c4a 100644 --- a/shared/settings/load-settings.tsx +++ b/shared/settings/load-settings.tsx @@ -1,40 +1,39 @@ -import * as Tabs from '@/constants/tabs' import * as S from '@/constants/strings' import * as T from '@/constants/types' import {ignorePromise} from '@/constants/utils' import logger from '@/logger' -import {navigateAppend, switchTab} from '@/constants/router' import {RPCError} from '@/util/errors' import {useConfigState} from '@/stores/config' import {useSettingsEmailState} from '@/stores/settings-email' import {useSettingsPhoneState} from '@/stores/settings-phone' -let maybeLoadAppLinkOnce = false - export const loadSettings = () => { - const maybeLoadAppLink = () => { - const phones = useSettingsPhoneState.getState().phones - if (!phones || phones.size > 0) { - return - } - - if (maybeLoadAppLinkOnce || !useConfigState.getState().startup.link.endsWith('/phone-app')) { - return - } - maybeLoadAppLinkOnce = true - switchTab(Tabs.settingsTab) - navigateAppend({name: 'settingsAddPhone', params: {}}) - } - const f = async () => { if (!useConfigState.getState().loggedIn) { return } + // Anything that writes these two stores while this RPC is in flight knows something the + // reply does not, so the reply must not land on top of it. Apply each half only to the + // value it was read against. The racing writer is usually an emailsChanged/phoneNumbersChanged notification, but + // notifyEmailVerified and sentVerificationEmail trip it too -- so a resend-verification + // click mid-load drops that round's server list, by design. + const emailsBefore = useSettingsEmailState.getState().emails + const phonesBefore = useSettingsPhoneState.getState().phones try { const settings = await T.RPCGen.userLoadMySettingsRpcPromise(undefined, S.waitingKeySettingsLoadSettings) - useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(settings.emails ?? []) - useSettingsPhoneState.getState().dispatch.setNumbers(settings.phoneNumbers ?? undefined) - maybeLoadAppLink() + // A logout does NOT trip the identity checks below: Z.defaultReset restores the values + // captured at store creation, so on a cold start emails is the same initial Map and + // phones the same undefined. Without this, the reply would repopulate the stores for a + // logged-out app and the next account could read the previous one's settings. + if (!useConfigState.getState().loggedIn) { + return + } + if (useSettingsEmailState.getState().emails === emailsBefore) { + useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(settings.emails ?? []) + } + if (useSettingsPhoneState.getState().phones === phonesBefore) { + useSettingsPhoneState.getState().dispatch.setNumbers(settings.phoneNumbers ?? undefined) + } } catch (error) { if (!(error instanceof RPCError)) { return diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index f0ab46999359..d5393e3443be 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -24,7 +24,6 @@ type Store = T.Immutable<{ configuredAccounts: Array defaultUsername: string globalError?: Error | RPCError - gregorReachable?: T.RPCGen.Reachable gregorPushState: Array<{md: T.RPCGregor.Metadata; item: T.RPCGregor.Item}> loginError?: RPCError httpSrv: { @@ -45,8 +44,6 @@ type Store = T.Immutable<{ // uid of the account that persisted `conversation` (from ui.routeState2). // Used to avoid replaying a conversation under a different account. conversationUid?: string - followUser: string - link: string tab?: Tab } userSwitching: boolean @@ -63,7 +60,6 @@ const initialStore: Store = { defaultUsername: '', globalError: undefined, gregorPushState: [], - gregorReachable: undefined, httpSrv: { address: '', token: '', @@ -83,8 +79,6 @@ const initialStore: Store = { revokedTrigger: 0, startup: { conversation: noConversationIDKey, - followUser: '', - link: '', loaded: false, }, userSwitching: false, @@ -114,7 +108,6 @@ export type State = Store & { setChatStaticConfig: (s: T.Chat.StaticConfig) => void setDefaultUsername: (u: string) => void setGlobalError: (e?: unknown) => void - setGregorReachable: (r: Store['gregorReachable']) => void setHTTPSrvInfo: (address: string, token: string) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean) => void @@ -153,14 +146,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } } - const setGregorReachable = (r: Store['gregorReachable']) => { - const old = get().gregorReachable - if (old === r) return - set(s => { - s.gregorReachable = r - }) - } - const setGregorPushState = (state: T.RPCGen.Gregor1.State) => { const items = state.items || [] const goodState = items.reduce>( @@ -279,16 +264,14 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }, waitingKey: waitingKeyConfigLogin, }) + // The session arrives as a clientState, which can come before or after this reply. logger.info('login call succeeded') - get().dispatch.setLoggedIn(true) } catch (error) { if (!(error instanceof RPCError)) { return } - if (error.code === T.RPCGen.StatusCode.scalreadyloggedin) { - get().dispatch.setLoggedIn(true) - } else if (error.desc !== cancelDesc) { - // If we're canceling then ignore the error + // Already logged in: a clientState has said so, or will. Canceling: nothing to report. + if (error.code !== T.RPCGen.StatusCode.scalreadyloggedin && error.desc !== cancelDesc) { error.desc = niceError(error) get().dispatch.setLoginError(error) } @@ -318,21 +301,9 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(f()) }, onEngineConnected: () => { - // An engine reset drops in-flight RPCs without settling their promises; a refresh - // caught by that would poison the dedupe cache forever + // An engine reset fails the old connection's in-flight RPCs, but that failure reaches the + // dedupe cache a few microtasks later: a refresh started before then would join the dead one inflightRefreshAccounts = undefined - // The startReachability RPC call both starts and returns the current - // reachability state. Then we'll get updates of changes from this state via reachabilityChanged. - // This should be run on app start and service re-connect in case the service somehow crashed or was restarted manually. - const startReachability = async () => { - try { - const reachability = await T.RPCGen.reachabilityStartReachabilityRpcPromise() - get().dispatch.setGregorReachable(reachability.reachable) - } catch (err) { - logger.warn('error bootstrapping reachability: ', err) - } - } - ignorePromise(startReachability()) // If ever you want to get OOBMs for a different system, then you need to enter it here. const registerForGregorNotifications = async () => { @@ -377,29 +348,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { get().dispatch.setHTTPSrvInfo(action.payload.params.info.address, action.payload.params.info.token) break } - case 'keybase.1.NotifySession.loggedIn': { - logger.info('keybase.1.NotifySession.loggedIn') - // only send this if we think we're not logged in - const {loggedIn, dispatch} = get() - if (!loggedIn) { - dispatch.setLoggedIn(true) - } - break - } - case 'keybase.1.NotifySession.loggedOut': { - logger.info('keybase.1.NotifySession.loggedOut') - const {loggedIn, dispatch} = get() - // only send this if we think we're logged in (errors on provison can trigger this and mess things up) - if (loggedIn) { - dispatch.setLoggedIn(false) - } - break - } - case 'keybase.1.reachability.reachabilityChanged': - if (get().loggedIn) { - get().dispatch.setGregorReachable(action.payload.params.reachability.reachable) - } - break default: } }, @@ -461,6 +409,8 @@ export const useConfigState = Z.createZustand('config', (set, get) => { configuredAccounts: s.configuredAccounts, defaultUsername: s.defaultUsername, dispatch: s.dispatch, + // process-wide, not per account; nothing reloads it on logout + httpSrv: s.httpSrv, startup: {loaded: s.startup.loaded}, userSwitching: s.userSwitching, })) @@ -525,9 +475,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }) } }, - setGregorReachable: r => { - setGregorReachable(r) - }, setHTTPSrvInfo: (address, token) => { set(s => { s.httpSrv.address = address @@ -554,8 +501,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }) if (error) { get().dispatch.setUserSwitching(false) - // push store clears its own pendingPushNotification by subscribing to - // loginError (see stores/push) — keeps config from importing push. } }, setOutOfDate: outOfDate => { diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index 31c4371d35ff..b9e0c87a174d 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -66,7 +66,10 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'}` ) // a newer handshake owns the store now; don't write a potentially older status over its load - if (gen !== generation || isEqual(bs, get().bootstrapStatus)) { + if (gen !== generation) { + return + } + if (isEqual(bs, get().bootstrapStatus)) { return } set(s => { @@ -88,6 +91,10 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { ...s, ...initialStore, dispatch: s.dispatch, + // Both track the connection, not the account, and the closure counter behind the + // generation keeps climbing across a reset: zeroing the copy here would make the live + // connection's own in-flight work look superseded by a logout that happened under it. + handshakeGeneration: s.handshakeGeneration, handshakeState: s.handshakeState, })) }, @@ -101,8 +108,8 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { }, startHandshake: () => { const gen = ++generation - // startHandshake follows an engine reset, which drops in-flight RPCs without settling - // their promises; reusing one here would stall the handshake forever + // startHandshake follows an engine reset, which fails the old connection's in-flight RPCs; + // reusing one here would fail this handshake's first attempt with the old connection's error inflightBootstrapStatus = undefined set(s => { s.error = undefined diff --git a/shared/stores/navigation-intents.test.ts b/shared/stores/navigation-intents.test.ts index 855bd3914ca8..06f179e2b578 100644 --- a/shared/stores/navigation-intents.test.ts +++ b/shared/stores/navigation-intents.test.ts @@ -1,4 +1,5 @@ /// +import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useNavigationIntentsState} from './navigation-intents' @@ -10,10 +11,21 @@ const clearIntent = () => { dispatch.resetState() } +let ack: jest.SpyInstance +beforeEach(() => { + ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) +}) + afterEach(() => { clearIntent() + jest.restoreAllMocks() }) +// The module remembers a push tap id for the life of the file, the same as the service does for +// the process, so ids must not repeat across tests any more than they do across taps. +let nextPushTapID = 1000 +const pushTapID = () => ++nextPushTapID + test('acknowledges only the intent that was actually handled', () => { const dispatch = useNavigationIntentsState.getState().dispatch dispatch.enqueue('keybase://convid/first') @@ -106,3 +118,153 @@ test('clears duplicate history across the account store reset', () => { 'keybase://convid/new-session' ) }) + +// A tap route is not the same thing as its intent: the intent can be enqueued and even acked +// locally while the service still thinks the route is armed, so acking it is a distinct, explicit +// step -- never implied by enqueuing. +test('enqueuing a tap does not ack its route', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + + expect(ack).not.toHaveBeenCalled() +}) + +test('acknowledging a tapped intent acks its route', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + + dispatch.acknowledge(useNavigationIntentsState.getState().intent!.id) + + expect(ack).toHaveBeenCalledWith({id}) +}) + +test('acknowledging a plain deep link never calls the tap ack', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + dispatch.enqueue('keybase://convid/no-tap') + + dispatch.acknowledge(useNavigationIntentsState.getState().intent!.id) + + expect(ack).not.toHaveBeenCalled() +}) + +test('markInitialURLHandled acks the tapped route it clears', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/cold-start-tap', {pushTapID: id}) + + dispatch.markInitialURLHandled('keybase://convid/cold-start-tap') + + expect(ack).toHaveBeenCalledWith({id}) + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +// The route stays armed on a lost peek reply, so drainPushTapRoute's next peek re-delivers the +// same id. Re-enqueuing it must not queue (and so navigate) a second time. +test('re-enqueuing a still-pending tap id does not replace or duplicate the intent', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + const first = useNavigationIntentsState.getState().intent + + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + + expect(useNavigationIntentsState.getState().intent).toBe(first) +}) + +// A redelivery after the route has already been consumed -- the ack RPC itself failed, so the +// service never retired it -- must not navigate a second time, however long ago that was, but the +// ack itself is retried: nothing else will ever ask the service to retire that route again. +test('re-enqueuing an already-consumed tap id retries the ack without navigating again', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + dispatch.acknowledge(useNavigationIntentsState.getState().intent!.id) + expect(ack).toHaveBeenCalledTimes(1) + + const realNow = Date.now() + jest.spyOn(Date, 'now').mockReturnValue(realNow + 60_000) + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(ack).toHaveBeenCalledTimes(2) + expect(ack).toHaveBeenNthCalledWith(2, {id}) +}) + +// Every path that removes or replaces a pushTapID on s.intent must ack it. The four below are the +// ones enqueue and resetState can take that acknowledge/markInitialURLHandled do not cover. + +test('merging a newer tap into the same-URL pending intent adopts its id instead of acking the old one', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const older = pushTapID() + const newer = pushTapID() + dispatch.enqueue('keybase://convid/same-url', {pushTapID: older}) + + // The service replaces an unacked route outright on a new tap, so by the time this lands the + // older route is already gone on that side; acking it here would be a pointless extra call. + dispatch.enqueue('keybase://convid/same-url', {pushTapID: newer}) + + expect(ack).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent).toMatchObject({pushTapID: newer}) + + dispatch.acknowledge(useNavigationIntentsState.getState().intent!.id) + + expect(ack).toHaveBeenCalledTimes(1) + expect(ack).toHaveBeenCalledWith({id: newer}) +}) + +test('a tap enqueued again inside the duplicate window of its own navigation acks immediately', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const first = pushTapID() + dispatch.enqueue('keybase://convid/duplicate-window', {pushTapID: first}) + dispatch.acknowledge(useNavigationIntentsState.getState().intent!.id) + ack.mockClear() + + // A redelivery of the same URL (not the same tap id -- a fresh one, as a second real tap + // landing on the same conversation would carry) inside the duplicate window: navigation just + // happened, so this one has nothing left to wait for. + const second = pushTapID() + dispatch.enqueue('keybase://convid/duplicate-window', {pushTapID: second}) + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(ack).toHaveBeenCalledTimes(1) + expect(ack).toHaveBeenCalledWith({id: second}) +}) + +test('a pending tap superseded by an unrelated enqueue acks the route it loses', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/superseded-tap', {pushTapID: id}) + + // A plain deep link (emitDeepLink) for an unrelated URL: the service was never told this tap + // was acted on, so without an explicit ack here the next peek would hand the same route back. + dispatch.enqueue('keybase://convid/unrelated') + + expect(ack).toHaveBeenCalledWith({id}) + expect(useNavigationIntentsState.getState().intent).toMatchObject({url: 'keybase://convid/unrelated'}) +}) + +test('resetState acks the tap route of an unscoped intent it discards', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + // No targetUid: a contact-joined push tap, which never carries an account. + dispatch.enqueue('keybase://tabs.peopleTab', {pushTapID: id}) + + resetAllStores() + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(ack).toHaveBeenCalledWith({id}) +}) + +test('resetState does not ack a targeted intent it keeps', () => { + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/kept-across-reset', {pushTapID: id, targetUid: 'target-uid'}) + + resetAllStores() + + expect(useNavigationIntentsState.getState().intent).toMatchObject({pushTapID: id}) + expect(ack).not.toHaveBeenCalled() +}) diff --git a/shared/stores/navigation-intents.tsx b/shared/stores/navigation-intents.tsx index 63a06455fea9..0db269e15a8f 100644 --- a/shared/stores/navigation-intents.tsx +++ b/shared/stores/navigation-intents.tsx @@ -1,12 +1,16 @@ +import * as T from '@/constants/types' import * as Z from '@/util/zustand' +import logger from '@/logger' export type NavigationIntentOptions = { + pushTapID?: number targetUid?: string } type NavigationIntent = { createdAt: number id: number + pushTapID?: number targetUid?: string url: string } @@ -33,14 +37,32 @@ type Store = { const duplicateWindowMs = 1500 -const targetsCouldMatch = (first?: string, second?: string) => - !first || !second || first === second +// A push tap's Go-side route is retired by an explicit ack, not by anything here clearing the +// intent. Once an id has been queued, remembering it for the rest of the process is what keeps a +// lost-ack redelivery (the route stays armed; see constants/init/shared's drainPushTapRoute) from +// enqueuing -- and so navigating -- a second time. Module state, not store state: it must survive +// resetState, which runs on every account switch this process makes. +// +// Structural rule: every pushTapID that leaves s.intent -- consumed, merged away, superseded by a +// different pending intent, or discarded outright -- goes through ackPushTap exactly once. A route +// left dangling here is a route the service will hand back on the next peek, navigating (or +// failing to navigate) on a tap the app has already moved past. +const seenPushTapIDs = new Set() -// Once an unscoped URL has been handled, a later targeted URL carries new -// account-routing information and must not be discarded. The reverse ordering -// is safe: an unscoped event after a targeted one can be the duplicate source. -const handledTargetMatches = (handled?: string, incoming?: string) => - !incoming || handled === incoming +const sendPushTapAck = (pushTapID: number) => { + T.RPCGen.appStateAckPushTapRouteRpcPromise({id: pushTapID}).catch((error: unknown) => { + logger.warn('[PushTap] failed to ack a consumed tap route: ', error) + }) +} + +// Fires the ack once per id, regardless of how many times consumption is reported for it. A +// redelivery of an id already in the set (the route is still armed, so that first ack did not +// land) is retried directly by enqueue, not through here. +const ackPushTap = (pushTapID: number | undefined) => { + if (pushTapID === undefined || seenPushTapIDs.has(pushTapID)) return + seenPushTapIDs.add(pushTapID) + sendPushTapAck(pushTapID) +} export const useNavigationIntentsState = Z.createZustand( 'navigation-intents', @@ -48,9 +70,9 @@ export const useNavigationIntentsState = Z.createZustand( let nextIntentID = 0 const dispatch: Store['dispatch'] = { acknowledge: id => { + const intent = get().intent + if (intent?.id !== id) return set(s => { - const intent = s.intent - if (intent?.id !== id) return s.lastHandledIntent = { handledAt: Date.now(), targetUid: intent.targetUid, @@ -58,42 +80,85 @@ export const useNavigationIntentsState = Z.createZustand( } s.intent = undefined }) + ackPushTap(intent.pushTapID) }, enqueue: (url, options) => { const now = Date.now() - const targetUid = options?.targetUid + const {pushTapID, targetUid} = options ?? {} const {intent: pending, lastHandledIntent} = get() - if (pending?.url === url && targetsCouldMatch(pending.targetUid, targetUid)) { - if (!pending.targetUid && targetUid) { + + if (pushTapID !== undefined) { + if (pending?.pushTapID === pushTapID) { + // Still queued, waiting on the exact thing this call is asking for. + return + } + if (seenPushTapIDs.has(pushTapID)) { + // The route is still armed on the service, so the ack that was supposed to retire + // it did not land. Retry it; nothing here re-enqueues, since this id already left + // the store once and must not navigate a second time. + sendPushTapAck(pushTapID) + return + } + } + + if ( + pending?.url === url && + (!pending.targetUid || !targetUid || pending.targetUid === targetUid) + ) { + const targetUidChanged = !pending.targetUid && !!targetUid + // pushTapID is guaranteed different from pending.pushTapID here (equal is caught + // above), so this always means the service replaced the route this intent already + // carries with a newer one -- adopt its id so the eventual ack retires the route + // that is actually still armed, rather than one already gone. + const pushTapIDChanged = pushTapID !== undefined + if (targetUidChanged || pushTapIDChanged) { set(s => { - if (s.intent?.id === pending.id) { + if (s.intent?.id !== pending.id) return + if (targetUidChanged) { s.intent.targetUid = targetUid } + if (pushTapIDChanged) { + s.intent.pushTapID = pushTapID + } }) } return } + + // Once an unscoped URL has been handled, a later targeted URL carries new + // account-routing information and must not be discarded. The reverse ordering + // is safe: an unscoped event after a targeted one can be the duplicate source. if ( lastHandledIntent?.url === url && now - lastHandledIntent.handledAt < duplicateWindowMs && - handledTargetMatches(lastHandledIntent.targetUid, targetUid) + (!targetUid || lastHandledIntent.targetUid === targetUid) ) { + // Navigation for this URL just happened; a tap riding along has nothing left to wait + // for, so it acks immediately instead of waiting on a consumption that isn't coming. + ackPushTap(pushTapID) return } + + // A different pending intent is replaced outright rather than merged (see above), so + // its own tap -- if it carries one, and whether or not the service has already + // discarded that route for the one replacing it -- is given up on for good here. + ackPushTap(pending?.pushTapID) + const id = ++nextIntentID set(s => { s.intent = { createdAt: now, id, + pushTapID, targetUid, url, } }) }, markInitialURLHandled: url => { + const pending = get().intent + const matchingPending = pending?.url === url ? pending : undefined set(s => { - const pending = s.intent - const matchingPending = pending?.url === url ? pending : undefined if (matchingPending) { s.intent = undefined } @@ -103,10 +168,13 @@ export const useNavigationIntentsState = Z.createZustand( url, } }) + ackPushTap(matchingPending?.pushTapID) }, // Account changes call resetAllStores. Keep account-targeted navigation // across the reset, but discard unscoped work from the previous session. resetState: () => { + const intent = get().intent + const discarding = !intent?.targetUid set(s => { if (!s.intent?.targetUid) { s.intent = undefined @@ -115,6 +183,9 @@ export const useNavigationIntentsState = Z.createZustand( s.navigationReady = false s.navigationReadyForUid = undefined }) + if (discarding) { + ackPushTap(intent?.pushTapID) + } }, setNavigationReady: (ready, uid) => { set(s => { diff --git a/shared/stores/push.tsx b/shared/stores/push.tsx index fc0b539289b6..e71dd6d334fc 100644 --- a/shared/stores/push.tsx +++ b/shared/stores/push.tsx @@ -1,10 +1,8 @@ import * as S from '@/constants/strings' import * as T from '@/constants/types' -import * as Tabs from '@/constants/tabs' import * as Z from '@/util/zustand' import logger from '@/logger' import {ignorePromise, neverThrowPromiseFunc, timeoutPromise} from '@/constants/utils' -import {navUpToScreen, switchTab, getRootState} from '@/constants/router' import {emitDeepLink} from '@/router-v2/deep-link-emitter' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' @@ -14,7 +12,6 @@ import {openAppSettings} from '@/util/storeless-actions' type Store = { hasPermissions: boolean justSignedUp: boolean - pendingPushNotification?: T.Push.PushNotification showPushPrompt: boolean token: string } @@ -22,20 +19,17 @@ type Store = { type State = Store & { dispatch: { checkPermissions: () => Promise - clearPendingPushNotification: () => void deleteTokenForLogout: () => Promise - handlePush: (notification: T.Push.PushNotification) => void initialPermissionsCheck: () => void rejectPermissions: () => void requestPermissions: () => void resetState: () => void - setPendingPushNotification: (notification: T.Push.PushNotification) => void setPushToken: (token: string) => void showPermissionsPrompt: (p: {show?: boolean; persistSkip?: boolean; justSignedUp?: boolean}) => void } } import {isDevApplePushToken} from '@/local-debug' -import {checkPushPermissions, getRegistrationToken, iosGetHasShownPushPrompt, requestPushPermissions, removeAllPendingNotificationRequests} from 'react-native-kb' +import {checkPushPermissions, getRegistrationToken, iosGetHasShownPushPrompt, requestPushPermissions} from 'react-native-kb' export const tokenType = isMobile ? isIOS ? (isDevApplePushToken ? 'appledev' : 'apple') : 'androidplay' @@ -51,7 +45,6 @@ const desktopInitialStore: Store = { const mobileInitialStore: Store = { hasPermissions: true, justSignedUp: false, - pendingPushNotification: undefined, showPushPrompt: false, token: '', } @@ -64,14 +57,11 @@ export const usePushState = Z.createZustand('push', (set, get) => { checkPermissions: async () => { return Promise.resolve(false) }, - clearPendingPushNotification: () => {}, deleteTokenForLogout: async () => {}, - handlePush: () => {}, initialPermissionsCheck: () => {}, rejectPermissions: () => {}, requestPermissions: () => {}, resetState: Z.defaultReset, - setPendingPushNotification: () => {}, setPushToken: () => {}, showPermissionsPrompt: () => {}, } @@ -110,41 +100,6 @@ export const usePushState = Z.createZustand('push', (set, get) => { } } - const handleLoudMessage = async (notification: T.Push.PushNotification) => { - if (notification.type !== 'chat.newmessage') { - return - } - if (!notification.userInteraction) { - logger.warn('[Push] handleLoudMessage: ignore non userInteraction') - return - } - - const {conversationIDKey, unboxPayload, membersType} = notification - - const rootState = getRootState() - const topRoute = rootState?.routes?.at(-1) - const alreadyOnConv = - topRoute?.name === 'chatConversation' && - (topRoute.params as {conversationIDKey?: string} | undefined)?.conversationIDKey === conversationIDKey - if (!alreadyOnConv) { - const targetUid = 'forUid' in notification ? notification.forUid : undefined - emitDeepLink(`keybase://convid/${conversationIDKey}`, { - targetUid, - }) - } - if (unboxPayload && membersType && !isIOS) { - try { - await T.RPCChat.localUnboxMobilePushNotificationRpcPromise({ - convID: conversationIDKey, - membersType, - payload: unboxPayload, - }) - } catch { - logger.info('[Push] failed to unbox message from payload') - } - } - } - const dispatch: State['dispatch'] = { checkPermissions: async () => { const permissions = await checkPermissionsFromNative() @@ -168,11 +123,6 @@ export const usePushState = Z.createZustand('push', (set, get) => { return false } }, - clearPendingPushNotification: () => { - set(s => { - s.pendingPushNotification = undefined - }) - }, deleteTokenForLogout: async () => { try { const deviceID = useCurrentUserState.getState().deviceID @@ -192,98 +142,6 @@ export const usePushState = Z.createZustand('push', (set, get) => { logger.error('[PushToken] delete failed', e) } }, - handlePush: notification => { - const f = async () => { - try { - const forUid = 'forUid' in notification ? notification.forUid : undefined - const navigationIntentOptions = { - targetUid: forUid, - } - - if (forUid) { - const currentUid = useCurrentUserState.getState().uid - if (forUid !== currentUid) { - const userInteraction = 'userInteraction' in notification ? notification.userInteraction : false - if (!userInteraction) { - logger.info('[Push] notification for different account but no userInteraction, skipping') - return - } - const {configuredAccounts, dispatch: configDispatch} = useConfigState.getState() - const account = configuredAccounts.find(acc => acc.uid === forUid) - if (!account) { - logger.info('[Push] notification forUid not in configured accounts yet, waiting to retry') - set(s => { - s.pendingPushNotification = notification - }) - return - } - if (!account.hasStoredSecret) { - logger.info('[Push] account has no stored secret, cannot switch') - return - } - if (useConfigState.getState().userSwitching) { - logger.info('[Push] switch already in progress for this account, skipping duplicate') - return - } - logger.info('[Push] switching to account for notification tap') - configDispatch.setUserSwitching(true) - set(s => { - s.pendingPushNotification = notification - }) - configDispatch.login(account.username, '') - return - } - } - - switch (notification.type) { - case 'chat.readmessage': - if (notification.badges === 0) { - removeAllPendingNotificationRequests() - } - break - case 'chat.newmessageSilent_2': - // entirely handled by go on ios and in onNotification on Android - break - case 'chat.newmessage': - await handleLoudMessage(notification) - break - case 'follow': - // We only care if the user clicked while in session - if (notification.userInteraction) { - const {username} = notification - emitDeepLink(`keybase://profile/show/${username}`, navigationIntentOptions) - } - break - case 'device.revoked': - case 'device.new': - if (notification.userInteraction && useConfigState.getState().loggedIn) { - switchTab(Tabs.settingsTab) - navUpToScreen('devicesRoot') - } - break - case 'autoreset': - break - case 'chat.extension': - { - const {conversationIDKey} = notification - emitDeepLink(`keybase://convid/${conversationIDKey}`, navigationIntentOptions) - } - break - case 'settings.contacts': - if (useConfigState.getState().loggedIn) { - emitDeepLink('keybase://people', navigationIntentOptions) - } - break - } - } catch (e) { - if (__DEV__) { - console.error(e) - } - logger.error('[Push] unhandled', e) - } - } - ignorePromise(f()) - }, initialPermissionsCheck: () => { const f = async () => { const hasPermissions = await get().dispatch.checkPermissions() @@ -356,19 +214,7 @@ export const usePushState = Z.createZustand('push', (set, get) => { ignorePromise(f()) }, resetState: () => { - const pendingPushNotification = useConfigState.getState().userSwitching - ? get().pendingPushNotification - : undefined - set(s => ({ - ...initialStore, - dispatch: s.dispatch, - pendingPushNotification, - })) - }, - setPendingPushNotification: (notification: T.Push.PushNotification) => { - set(s => { - s.pendingPushNotification = notification - }) + set(s => ({...initialStore, dispatch: s.dispatch})) }, setPushToken: (token: string) => { set(s => { @@ -431,21 +277,3 @@ export const usePushState = Z.createZustand('push', (set, get) => { dispatch, } }) - -// A login error used to clear the pending push notification via a direct call -// from config's setLoginError. Subscribing here instead keeps config from -// importing push (breaks the config <-> push require cycle). -// -// Guard against HMR: the config store instance (and its subscribers) survive -// hot reloads via Z.createZustand's registry, but this module re-evaluates, so -// an unguarded subscribe would register a duplicate every reload. -// eslint-disable-next-line -const _g = globalThis as any -if (!__DEV__ || !_g.__pushLoginErrorSubscribed) { - if (__DEV__) _g.__pushLoginErrorSubscribed = true - useConfigState.subscribe((s, p) => { - if (s.loginError && s.loginError !== p.loginError) { - usePushState.getState().dispatch.clearPendingPushNotification() - } - }) -} diff --git a/shared/stores/shell.tsx b/shared/stores/shell.tsx index 7bb9237e8ee0..47c09f9e8dc0 100644 --- a/shared/stores/shell.tsx +++ b/shared/stores/shell.tsx @@ -6,7 +6,6 @@ import isEqual from 'lodash/isEqual' import logger from '@/logger' import {RPCError} from '@/util/errors' import {defaultUseNativeFrame} from '@/constants/platform' -import {useConfigState} from '@/stores/config' export type ConnectionType = NetInfo.NetInfoStateType | 'notavailable' @@ -156,11 +155,16 @@ export const useShellState = Z.createZustand('shell', (set, get) => { s.networkStatus.type = type } }) - const updateGregor = async () => { - const reachability = await T.RPCGen.reachabilityCheckReachabilityRpcPromise() - useConfigState.getState().dispatch.setGregorReachable(reachability.reachable) + // Not for the result: the service re-dials gregor inside this call and reconnects if the + // dial fails, which is what gets it off a dead connection after the network moves. + const nudgeGregor = async () => { + try { + await T.RPCGen.reachabilityCheckReachabilityRpcPromise() + } catch (error) { + logger.warn('failed to check gregor reachability: ', error) + } } - ignorePromise(updateGregor()) + ignorePromise(nudgeGregor()) const updateFS = async () => { if (isInit) return diff --git a/shared/stores/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts new file mode 100644 index 000000000000..d46b295c0574 --- /dev/null +++ b/shared/stores/tests/client-state.test.ts @@ -0,0 +1,220 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '../config' +import {useCurrentUserState} from '../current-user' +import {useShellState} from '../shell' +import {_onEngineIncoming, applyClientState} from '@/constants/init/shared' + +const g = globalThis as unknown as {isMobile: boolean} + +const session = (over: Partial = {}): T.RPCGen.ClientSession => ({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + uid: 'u1', + username: 'testuser', + ...over, +}) + +const loggedOut = session({deviceID: '', deviceName: '', loggedIn: false, uid: '', username: ''}) + +const clientState = (over: Partial = {}): T.RPCGen.ClientState => ({ + appState: T.RPCGen.MobileAppState.foreground, + session: session(), + ...over, +}) + +const notifyClientState = (state: T.RPCGen.ClientState) => + _onEngineIncoming({payload: {params: {state}}, type: 'keybase.1.NotifyApp.clientState'} as never) + +const notifyHTTP = (address: string) => + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: {info: {address, token: 'token'}}}, + type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', + } as never) + +afterEach(() => { + g.isMobile = false + jest.restoreAllMocks() + resetAllStores() +}) + +describe('a clientState', () => { + test('replaces the session, the current user, the http address and the app state', () => { + g.isMobile = true + notifyClientState( + clientState({ + appState: T.RPCGen.MobileAppState.background, + httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, + }) + ) + + expect(useConfigState.getState().loggedIn).toBe(true) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') + expect(useCurrentUserState.getState().username).toBe('testuser') + expect(useCurrentUserState.getState().deviceID).toBe('d1') + expect(useShellState.getState().mobileAppState).toBe('background') + }) + + test('is applied in arrival order, with no versions: the last one wins', () => { + applyClientState(clientState({httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}})) + notifyHTTP('127.0.0.1:2') + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + + applyClientState(clientState({httpSrvInfo: {address: '127.0.0.1:3', token: 'token'}, session: loggedOut})) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') + expect(useConfigState.getState().loggedIn).toBe(false) + }) + + test('with no session leaves the session as it was: the service does not know it yet', () => { + applyClientState(clientState({session: undefined})) + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useCurrentUserState.getState().username).toBe('') + + applyClientState(clientState()) + expect(useConfigState.getState().loggedIn).toBe(true) + + applyClientState(clientState({session: null})) + expect(useConfigState.getState().loggedIn).toBe(true) + }) + + test('has the current user in place before anything reacts to the login', () => { + // setLoggedIn fans out synchronously; every subscriber of a login has always been able to + // read the current user by the time it runs + let seen = 'not called' + const unsub = useConfigState.subscribe((st, prev) => { + if (st.loggedIn && !prev.loggedIn) { + seen = useCurrentUserState.getState().username + } + }) + + applyClientState(clientState()) + unsub() + + expect(seen).toBe('testuser') + }) + + test('logging out keeps the http server address', () => { + notifyHTTP('127.0.0.1:2') + useConfigState.getState().dispatch.resetState() + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + }) + + test('loggedIn and loggedOut change nothing about the session: clientState owns it', () => { + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: {signedUp: false, username: 'testuser'}}, + type: 'keybase.1.NotifySession.loggedIn', + } as never) + expect(useConfigState.getState().loggedIn).toBe(false) + + applyClientState(clientState()) + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: undefined}, + type: 'keybase.1.NotifySession.loggedOut', + } as never) + expect(useConfigState.getState().loggedIn).toBe(true) + }) +}) + +describe('an account switch', () => { + const userB = session({deviceID: 'd2', uid: 'u2', username: 'testuser2'}) + + // what resetAllStores clears, standing in for the previous account's state + const markAccountState = () => useConfigState.setState({justDeletedSelf: 'testuser'}) + const accountStateCleared = () => useConfigState.getState().justDeletedSelf === '' + + const loginChanges = () => { + const changes: Array = [] + const unsub = useConfigState.subscribe((st, prev) => { + if (st.loggedIn !== prev.loggedIn) { + changes.push(st.loggedIn) + } + }) + return {changes, unsub} + } + + test('with both clientStates logs out, clearing the old account, then logs in as the new one', () => { + applyClientState(clientState()) + markAccountState() + useConfigState.getState().dispatch.setUserSwitching(true) + const {changes, unsub} = loginChanges() + + applyClientState(clientState({session: loggedOut})) + expect(accountStateCleared()).toBe(true) + applyClientState(clientState({session: userB})) + unsub() + + expect(changes).toEqual([false, true]) + expect(useCurrentUserState.getState().username).toBe('testuser2') + expect(useConfigState.getState().loggedIn).toBe(true) + }) + + test('whose logged-out clientState never arrived still clears the old account', () => { + applyClientState(clientState()) + markAccountState() + useConfigState.getState().dispatch.setUserSwitching(true) + const {changes, unsub} = loginChanges() + + applyClientState(clientState({session: userB})) + unsub() + + expect(changes).toEqual([false, true]) + expect(accountStateCleared()).toBe(true) + expect(useCurrentUserState.getState().username).toBe('testuser2') + expect(useCurrentUserState.getState().uid).toBe('u2') + }) + + test('whose login fails after the logout ends logged out, no longer switching', () => { + applyClientState(clientState()) + useConfigState.getState().dispatch.setUserSwitching(true) + + applyClientState(clientState({session: loggedOut})) + useConfigState.getState().dispatch.setLoginError(new Error('bad password') as never) + + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().userSwitching).toBe(false) + expect(useCurrentUserState.getState().username).toBe('') + }) + + test('a logout never shows a logged-in session with no user', () => { + applyClientState(clientState()) + const seen: Array<{loggedIn: boolean; uid: string}> = [] + const record = () => + seen.push({loggedIn: useConfigState.getState().loggedIn, uid: useCurrentUserState.getState().uid}) + const unsubs = [useConfigState.subscribe(record), useCurrentUserState.subscribe(record)] + + applyClientState(clientState({session: loggedOut})) + unsubs.forEach(u => u()) + + expect(seen.length).toBeGreaterThan(0) + expect(seen.filter(s => s.loggedIn && !s.uid)).toEqual([]) + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useCurrentUserState.getState().uid).toBe('') + }) + + test('logged in with no current user yet is not a switch', () => { + useConfigState.getState().dispatch.setLoggedIn(true) + markAccountState() + const {changes, unsub} = loginChanges() + + applyClientState(clientState()) + unsub() + + expect(changes).toEqual([]) + expect(accountStateCleared()).toBe(false) + expect(useCurrentUserState.getState().uid).toBe('u1') + }) + + test('the same user again is not a switch', () => { + applyClientState(clientState()) + markAccountState() + const {changes, unsub} = loginChanges() + + applyClientState(clientState()) + unsub() + + expect(changes).toEqual([]) + expect(accountStateCleared()).toBe(false) + }) +}) diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index 1df0b762284f..b607e6961daf 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -1,4 +1,7 @@ /// +import * as T from '../../constants/types' +import * as Tabs from '../../constants/tabs' +import {RPCError} from '../../util/errors' import {noConversationIDKey} from '../../constants/types/chat/common' import {useConfigState} from '../config' @@ -16,8 +19,6 @@ const resetConfigState = () => { }, startup: { conversation: noConversationIDKey, - followUser: '', - link: '', loaded: false, }, userSwitching: false, @@ -38,23 +39,17 @@ test('setStartupDetails only records the first startup payload', () => { dispatch.setStartupDetails({ conversation: 'first-convo' as any, - followUser: 'alice', - link: 'keybase://first', - tab: undefined, + tab: Tabs.chatTab, }) dispatch.setStartupDetails({ conversation: 'second-convo' as any, - followUser: 'bob', - link: 'keybase://second', - tab: undefined, + tab: Tabs.peopleTab, }) expect(useConfigState.getState().startup).toEqual({ conversation: 'first-convo', - followUser: 'alice', - link: 'keybase://first', loaded: true, - tab: undefined, + tab: Tabs.chatTab, }) }) @@ -120,3 +115,31 @@ test('custom resetState preserves the fields config intentionally carries across expect(state.userSwitching).toBe(true) expect(state.globalError).toBeUndefined() }) + +describe('login', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + const flush = async () => new Promise(resolve => setImmediate(resolve)) + + test('leaves the session to the clientState when the login succeeds', async () => { + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockResolvedValue(undefined) + useConfigState.getState().dispatch.login('testuser', 'password') + await flush() + + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().loginError).toBeUndefined() + }) + + test('leaves the session to the clientState when already logged in', async () => { + jest + .spyOn(T.RPCGen, 'loginLoginRpcListener') + .mockRejectedValue(new RPCError('already logged in', T.RPCGen.StatusCode.scalreadyloggedin)) + useConfigState.getState().dispatch.login('testuser', 'password') + await flush() + + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().loginError).toBeUndefined() + }) +}) diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index dbb1ad951b0d..ed44e1e3c656 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -147,3 +147,38 @@ describe('daemon store', () => { expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries) }) }) + +describe('a superseded read', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() + }) + + test('does not write its status over the newer load', async () => { + // a reconnect invalidates in-flight reads: the generation orders client attempts + let resolveLosing!: (bs: T.RPCGen.BootstrapStatus) => void + jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockReturnValueOnce( + new Promise(resolve => { + resolveLosing = resolve + }) + ) + .mockResolvedValue(bootstrapStatus) + const {dispatch} = useDaemonState.getState() + dispatch.initBootstrapSteps([]) + + const losing = dispatch.loadDaemonBootstrapStatus() + dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + resolveLosing({...bootstrapStatus, username: 'stale'}) + await losing + await jest.advanceTimersByTimeAsync(0) + + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') + }) +}) diff --git a/shared/stores/tests/push.desktop.test.ts b/shared/stores/tests/push.desktop.test.ts index 8f640c662f53..682cfca6599a 100644 --- a/shared/stores/tests/push.desktop.test.ts +++ b/shared/stores/tests/push.desktop.test.ts @@ -11,7 +11,6 @@ test('desktop push store reports resettable defaults', async () => { await expect(dispatch.checkPermissions()).resolves.toBe(false) - dispatch.clearPendingPushNotification() await dispatch.deleteTokenForLogout() dispatch.initialPermissionsCheck() dispatch.rejectPermissions() diff --git a/shared/stores/tests/settings.test.ts b/shared/stores/tests/settings.test.ts index ae699f7d314f..24d0636cd511 100644 --- a/shared/stores/tests/settings.test.ts +++ b/shared/stores/tests/settings.test.ts @@ -1,10 +1,4 @@ /// -jest.mock('../../constants/router', () => ({ - clearModals: jest.fn(), - navigateAppend: jest.fn(), - switchTab: jest.fn(), -})) - import * as T from '../../constants/types' import {loadSettings} from '../../settings/load-settings' import {resetAllStores} from '../../util/zustand' @@ -51,4 +45,54 @@ describe('settings loading', () => { expect(emailHandler).toHaveBeenCalledWith(emails) expect(phoneHandler).toHaveBeenCalledWith(phoneNumbers) }) + + test('a notification that lands while the settings load is in flight is not overwritten', async () => { + const stale = [{ctime: 0, phoneNumber: '+15550000000', superseded: false, verified: true, visibility: 0}] + const notified = [{ctime: 0, phoneNumber: '+15551111111', superseded: false, verified: true, visibility: 0}] + const staleEmails = [ + {email: 'stale@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0}, + ] + const notifiedEmails = [ + {email: 'fresh@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0}, + ] + + useConfigState.setState({loggedIn: true}) + jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockImplementation((async () => { + // the notifications win the race: they carry the newer server state + await Promise.resolve() + useSettingsPhoneState.getState().dispatch.notifyPhoneNumberPhoneNumbersChanged(notified) + useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(notifiedEmails) + return {emails: staleEmails, phoneNumbers: stale} + }) as never) + + loadSettings() + for (let i = 0; i < 10; ++i) await Promise.resolve() + + expect([...useSettingsPhoneState.getState().phones!.keys()]).toEqual(['+15551111111']) + expect([...useSettingsEmailState.getState().emails.keys()]).toEqual(['fresh@example.com']) + }) + + test('a logout while the settings load is in flight drops the reply', async () => { + const emails = [ + {email: 'a@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0}, + ] + const phoneNumbers = [ + {ctime: 0, phoneNumber: '+15555555555', superseded: false, verified: true, visibility: 0}, + ] + + useConfigState.setState({loggedIn: true}) + jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockImplementation((async () => { + // Z.defaultReset restores the identities captured at store creation, so the reference + // checks cannot see this; only the loggedIn re-read can. + await Promise.resolve() + useConfigState.getState().dispatch.setLoggedIn(false) + return {emails, phoneNumbers} + }) as never) + + loadSettings() + for (let i = 0; i < 10; ++i) await Promise.resolve() + + expect(useSettingsPhoneState.getState().phones).toBeUndefined() + expect([...useSettingsEmailState.getState().emails.keys()]).toEqual([]) + }) }) diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts new file mode 100644 index 000000000000..f1f824287fdb --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -0,0 +1,184 @@ +import {expect} from '@wdio/globals' +import {requireSmokeUser} from '../helpers/app' +import {byText, tab} from '../helpers/elements' +import { + activateApp, + appPid, + appSnapshot, + backgroundApp, + closeNotificationCenter, + crashReportsSince, + findLines, + goAppStateUpdates, + goLogMark, + goLogSince, + launchApp, + metroClientLogSince, + metroLogMark, + openNotificationCenter, + openSelfConversation, + startSenderDevice, + terminateApp, + waitFor, + waitForAppState, + waitForAvatar200, + waitForLinesInOrder, +} from '../helpers/lifecycle' + +// Log lines these flows rely on: +// - Go (ios.log): "lifecycle: : …" per native UI report, +// "MobileAppState.Update: useful update: " per Go app state change, +// "Srv: start: addr:

" when the image server starts on a new address, +// "kbhttp.Srv: server starting on:
" on every start of a Go http server. +// - Metro (JS): "app focus changed: " when the shell store's app state changes. +// The cold launch test requires a match of each server line, so an empty result in the +// Notification Center test means no restart, not a pattern that no longer matches Go's log. +const httpSrvStarted = /Srv: start: addr: / +const httpSrvStartedAny = /kbhttp\.Srv: server starting on: / +describe('app lifecycle: app state', () => { + it('cold launch reaches active under scenes and serves images', async () => { + const user = requireSmokeUser() + const since = Date.now() + await terminateApp() + const goMark = goLogMark() + await launchApp() + + // The app restores its last screen, which may hide the tab bar, so wait on state. + const snap = await waitForAppState('active', undefined, 90000) + const goLines = await waitForLinesInOrder('Go to report the launch', () => goLogSince(goMark), [ + /lifecycle: uiInactive: /, + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: uiActive: /, + ]) + expect(goLines).toHaveLength(3) + // JS must hold the address of the server Go started, not a stale one. + const started = findLines(goLogSince(goMark), httpSrvStarted).at(-1) ?? '' + expect(started).toContain(`addr: ${snap.httpSrv.address} `) + expect(findLines(goLogSince(goMark), httpSrvStartedAny).length).toBeGreaterThanOrEqual(1) + + const avatar = await waitForAvatar200(user) + expect(avatar.status).toBe(200) + + // The UIScene SIGTRAP crashed within a second of launch; give it several. + const pid = appPid() + await browser.pause(5000) + expect(appPid()).toBe(pid) + expect(crashReportsSince(since)).toEqual([]) + }) + + it('background, then foreground: images load and chat receives a new message', async () => { + const user = requireSmokeUser() + const since = Date.now() + const convID = await openSelfConversation(user) + const sender = await startSenderDevice(user) + try { + const pid = appPid() + const goMark = goLogMark() + await backgroundApp() + await waitForLinesInOrder('Go to go to the background', () => goLogSince(goMark), [ + /lifecycle: uiInactive: /, + /lifecycle: uiBackground: /, + ]) + + const text = `e2e-lifecycle-recv-${Date.now()}` + await sender.send(convID, text) + await browser.pause(10000) + await activateApp() + + const snap = await waitForAppState('active') + expect(snap.screen?.params?.['conversationIDKey']).toBe(convID) + await waitForLinesInOrder('Go to return to the foreground', () => goLogSince(goMark), [ + /lifecycle: uiBackground: /, + /lifecycle: uiInactive: /, + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: uiActive: /, + ]) + await waitForAvatar200(user) + + // The message sent from the other device while this one was in the background. + await byText(text).waitForExist({interval: 250, timeout: 60000, timeoutMsg: `"${text}" never arrived`}) + expect(appPid()).toBe(pid) + expect(crashReportsSince(since)).toEqual([]) + } finally { + // A cleanup failure must not hide the test's own failure. + await sender.stop().catch((e: unknown) => { + // eslint-disable-next-line no-console + console.warn(`sender device cleanup failed: ${e instanceof Error ? e.message : String(e)}`) + }) + } + }) + + it('five quick background/foreground cycles leave the app healthy', async () => { + const user = requireSmokeUser() + const since = Date.now() + await waitForAppState('active') + const pid = appPid() + const goMark = goLogMark() + const metroMark = metroLogMark() + + const cycles = 5 + for (let i = 0; i < cycles; i++) { + await backgroundApp() + await browser.pause(700) + await activateApp() + await browser.pause(700) + } + + await waitForAppState('active') + expect(appPid()).toBe(pid) + // Every cycle reaches Go, and the last word is foreground. + await waitFor( + 'Go to see every cycle', + () => { + const lines = goLogSince(goMark) + const backgrounds = findLines(lines, /lifecycle: uiBackground: /).length + const actives = findLines(lines, /lifecycle: uiActive: /).length + return backgrounds >= cycles && actives >= cycles && goAppStateUpdates(goMark).at(-1) === 'FOREGROUND' + ? true + : undefined + }, + {interval: 500, timeout: 20000} + ) + // JS saw the app go away and come back, ending active. + const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) + expect(focus.some(l => l.endsWith('app focus changed: background'))).toBe(true) + expect(focus.at(-1)).toMatch(/app focus changed: active$/) + + const avatar = await waitForAvatar200(user) + expect(avatar.status).toBe(200) + await expect(tab('People')).toExist() + expect(crashReportsSince(since)).toEqual([]) + }) + + it('Notification Center makes the app inactive and keeps images served', async () => { + const user = requireSmokeUser() + const before = await waitForAppState('active') + const goMark = goLogMark() + const metroMark = metroLogMark() + + await openNotificationCenter() + const inactive = await waitForAppState('inactive', undefined, 15000) + await waitForLinesInOrder('Go to go inactive', () => goLogSince(goMark), [ + /MobileAppState\.Update: useful update: INACTIVE/, + /lifecycle: uiInactive: /, + ]) + // INACTIVE is not background: the image server keeps serving at the same address. + expect(inactive.httpSrv.address).toBe(before.httpSrv.address) + await waitForAvatar200(user) + expect(findLines(goLogSince(goMark), /lifecycle: uiBackground: /)).toEqual([]) + + await closeNotificationCenter() + await waitForAppState('active', undefined, 15000) + await waitForLinesInOrder('Go to become active again', () => goLogSince(goMark), [ + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: uiActive: /, + ]) + const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) + expect(focus).toEqual([ + expect.stringMatching(/app focus changed: inactive$/), + expect.stringMatching(/app focus changed: active$/), + ]) + expect(findLines(goLogSince(goMark), httpSrvStartedAny)).toEqual([]) + expect((await appSnapshot()).httpSrv.address).toBe(before.httpSrv.address) + }) +}) diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts new file mode 100644 index 000000000000..2aa0657a74c1 --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -0,0 +1,214 @@ +import {expect} from '@wdio/globals' +import {requireSmokeUser} from '../helpers/app' +import {el} from '../helpers/elements' +import {escapeToTabs, navigateToPeople} from '../helpers/navigate' +import * as T from '../../shared/test-ids' +import { + activateApp, + appSnapshot, + backgroundApp, + closeNotificationCenter, + ensureNotificationPermission, + findLines, + findNotification, + goLogMark, + goLogSince, + jsEval, + metroClientLogSince, + metroLogMark, + openSelfConversation, + openUrl, + sendPush, + terminateApp, + waitFor, + waitForAppState, + waitForLinesInOrder, +} from '../helpers/lifecycle' + +// A public account safe to deep link to. +const profileLink = {url: 'keybase://profile/show/keybase', username: 'keybase'} + +const waitForScreen = async (what: string, match: (s: Awaited>['screen']) => boolean) => + waitFor( + what, + async () => { + const {screen} = await appSnapshot() + return match(screen) ? screen : undefined + }, + {interval: 500, timeout: 30000} + ) + +// A push sent before the app is really in the background is handed to the app instead of shown. +const waitForBackground = async (goMark: ReturnType) => + waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [/lifecycle: uiBackground: /]) + +// Terminating right after navigating can leave the previous screen as the route the app saves +// and restores on launch (routes are saved on a delay, and on backgrounding). Leaving on People +// and backgrounding first makes a cold launch that opens a conversation prove the launch +// input (link or push) did it, not the restored route. +const terminateFromPeople = async () => { + await escapeToTabs() + await navigateToPeople() + const goMark = goLogMark() + const metroMark = metroLogMark() + await backgroundApp() + await waitForBackground(goMark) + await waitForLinesInOrder('JS to see the background', () => metroClientLogSince(metroMark), [ + /app focus changed: background$/, + ]) + await terminateApp() +} + +const onProfile = (s: Awaited>['screen']) => + s?.name === 'profile' && s.params?.['username'] === profileLink.username + +// Log lines these flows rely on: +// - Metro (JS): "[Startup] loadStartupDetails: Linking.getInitialURL returned in ms: " for +// a cold deep link; "[PushTap] took a tap link: " for every tap JS takes, cold or warm +// (only a tap reaches JS at all); "[DeepLink] url event: " for a link opened while running; +// "[AccountLink] switching accounts" for a tap that switches accounts, which must never appear +// in these flows. +// - Go (ios.log): "lifecycle: uiBackground: " before a push is sent to a backgrounded app, +// so it can't arrive while the app is still in the foreground (and not be shown). +describe('app lifecycle: deep links', () => { + it('opens a deep link while running', async () => { + await waitForAppState('active') + await navigateToPeople() + openUrl(profileLink.url) + await waitForScreen('the linked profile', onProfile) + await el(T.PROFILE_PAGE).waitForExist({interval: 250, timeout: 15000}) + }) + + // A cold profile link is not used here: the router builds its launch state with the profile + // inside the People tab, where it isn't a screen, so it opens People instead. That is + // router-v2/linking.tsx behavior, independent of app state; a conversation link is built + // at the root and exercises the same launch path. + it('opens a deep link that launches the app', async () => { + const user = requireSmokeUser() + await waitForAppState('active') + const convID = await openSelfConversation(user) + await terminateFromPeople() + const metroMark = metroLogMark() + const url = `keybase://convid/${convID}` + openUrl(url) + await waitForAppState('active', undefined, 90000) + await waitForScreen('the linked conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) + const startup = findLines(metroClientLogSince(metroMark), /Linking\.getInitialURL returned in \d+ms: /) + expect(startup.at(-1)).toContain(url) + }) + + it('a link naming another account opens as a plain link and never switches accounts', async () => { + const user = requireSmokeUser() + await waitForAppState('active') + const convID = await openSelfConversation(user) + await navigateToPeople() + const uidBefore = await jsEval(`return kbModule('stores/current-user.tsx').useCurrentUserState.getState().uid`) + const metroMark = metroLogMark() + // a uid that isn't this account: a link, unlike a tap, can never act on one + openUrl(`keybase://convid/${convID}?uid=00000000000000000000000000000019`) + await waitForScreen('the linked conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) + const lines = metroClientLogSince(metroMark) + expect(findLines(lines, /\[DeepLink\] url event: /)).toHaveLength(1) + expect(findLines(lines, /\[AccountLink\]/)).toEqual([]) + expect(findLines(lines, /\[PushTap\] took a tap link: /)).toEqual([]) + await browser.pause(3000) + expect(await jsEval(`return kbModule('stores/current-user.tsx').useCurrentUserState.getState().uid`)).toBe(uidBefore) + }) +}) + +describe('app lifecycle: push notifications', () => { + let convID = '' + let uid = '' + // Real pushes name the account they are for; a payload without uid skips the account check. + const pushFor = (body: string) => ({ + aps: {alert: {body, title: 'e2e'}, sound: 'default'}, + convID, + m: '', + t: '1', + type: 'chat.newmessage', + uid, + }) + const tapLink = () => `keybase://convid/${convID}` + // Every tap JS took. A push that was not tapped produces none. + const tapLines = (lines: Array) => findLines(lines, /\[PushTap\] took a tap link: /) + + before(async () => { + const user = requireSmokeUser() + await waitForAppState('active') + await ensureNotificationPermission() + convID = await openSelfConversation(user) + uid = await jsEval(`return kbModule('stores/current-user.tsx').useCurrentUserState.getState().uid`) + expect(uid).not.toBe('') + }) + + it('a visible push that arrives in the foreground does not navigate', async () => { + await waitForAppState('active') + await navigateToPeople() + const metroMark = metroLogMark() + const body = `e2e-push-foreground-${Date.now()}` + sendPush(pushFor(body)) + + await browser.pause(5000) + expect(tapLines(metroClientLogSince(metroMark))).toEqual([]) + expect((await appSnapshot()).screen?.name).not.toBe('chatConversation') + }) + + it('a push shown in the background but not tapped does not navigate', async () => { + await waitForAppState('active') + await navigateToPeople() + const metroMark = metroLogMark() + const goMark = goLogMark() + await backgroundApp() + await waitForBackground(goMark) + const body = `e2e-push-untapped-${Date.now()}` + sendPush(pushFor(body)) + // The notification is really shown, just not tapped. + const where = await findNotification(body, {tap: false}) + expect(where).toBeDefined() + if (where === 'center') await closeNotificationCenter() + await activateApp() + + await waitForAppState('active') + await browser.pause(3000) + expect((await appSnapshot()).screen?.name).not.toBe('chatConversation') + expect(tapLines(metroClientLogSince(metroMark))).toEqual([]) + }) + + it('tapping a push shown in the background opens its conversation', async () => { + await waitForAppState('active') + await navigateToPeople() + const metroMark = metroLogMark() + const goMark = goLogMark() + await backgroundApp() + await waitForBackground(goMark) + const body = `e2e-push-tapped-${Date.now()}` + sendPush(pushFor(body)) + expect(await findNotification(body, {tap: true})).toBeDefined() + + await waitForAppState('active') + await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) + const lines = metroClientLogSince(metroMark) + // Delivered once, and never through Linking. + expect(tapLines(lines)).toEqual([expect.stringContaining(tapLink())]) + expect(findLines(lines, /\[DeepLink\] url event: /)).toEqual([]) + }) + + it('tapping a push while the app is not running launches into its conversation', async () => { + await waitForAppState('active') + await terminateFromPeople() + const metroMark = metroLogMark() + const body = `e2e-push-cold-${Date.now()}` + sendPush(pushFor(body)) + expect(await findNotification(body, {tap: true})).toBeDefined() + + await waitForAppState('active', undefined, 90000) + await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) + // The tap reaches JS through the native tap slot and picks the startup route. + const lines = metroClientLogSince(metroMark) + expect(tapLines(lines)).toEqual([expect.stringContaining(tapLink())]) + // startup's inbox load can still pick a screen after the route opens; the conversation must stay + await browser.pause(3000) + expect((await appSnapshot()).screen?.params?.['conversationIDKey']).toBe(convID) + expect(findLines(metroClientLogSince(metroMark), /\[AccountLink\]/)).toEqual([]) + }) +}) diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts new file mode 100644 index 000000000000..8a3cd491737a --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -0,0 +1,213 @@ +import {expect} from '@wdio/globals' +import {requireSmokeUser} from '../helpers/app' +import {el, enterText, waitForTestID} from '../helpers/elements' +import * as T from '../../shared/test-ids' +import { + activateApp, + appPid, + backgroundApp, + deviceUdid, + findLines, + goLogMark, + goLogSince, + jsEval, + metroBundlingStartedSince, + metroClientLogSince, + metroLogMark, + nativeLogSince, + openSelfConversation, + setLocation, + simctl, + terminateApp, + waitFor, + waitForAppState, + waitForLinesInOrder, + BUNDLE_ID, +} from '../helpers/lifecycle' + +// Live location on iOS runs natively: Go asks the Swift watcher to start, each fix goes +// straight to Go, and Go posts it to the conversation as a map unfurl. These flows move the +// simulated location and follow that in the Go log (ios.log): +// - "LiveLocationTracker: StartTracking" / "StopAllTracking" when sharing starts and stops, +// - "+ LiveLocationTracker: LocationUpdate" for each fix Go records (native hands it every fix), +// - "LiveLocationTracker: tracker[]: got coords" when the tracker takes it, +// - "+ LiveLocationTracker: updateMapUnfurl" when Go posts the location to the conversation, +// - "LiveLocationTracker: restoreLocked: restored trackers" when a relaunch restores sharing, +// - "lifecycle: acquire: liveLocation hold " when a fix holds a backgrounded app up. +// The relaunch flow also reads Metro's start.log: a background launch must start no JS at all, so +// neither a bundle request nor a JS log line may appear while it runs, and activating the app +// afterwards must make both appear from the same mark. +// And in the app's unified log (com.keybase.app, category location): "starting location updates" +// and "stopping location updates" when the Swift watcher turns the OS service on and off. +// The posted map itself never renders here: the maps server rejects the render request, so the +// unfurl fails after Go posts it. The flows stop at the post. +// +// Only the smoke user's conversation with themselves is used. + +// The simulator reports no fix when the watcher starts at the location it already has, so +// each run starts somewhere new. +const start = {lat: 37.7749 + Math.random() * 0.01, lon: -122.4194} +// Far enough apart that Go, which in the background waits for real movement, records them, +// and the last far enough for iOS to count it as a significant change. +const moves = [ + {lat: start.lat + 0.01, lon: start.lon}, + {lat: start.lat + 0.02, lon: start.lon}, + {lat: start.lat + 0.06, lon: start.lon + 0.04}, +] + +const locationUpdates = (lines: Array) => findLines(lines, /\+ LiveLocationTracker: LocationUpdate/) + +const sendCommand = async (text: string) => { + await waitForTestID(T.CHAT_INPUT, 10000) + await enterText(T.CHAT_INPUT, text) + await waitForTestID(T.CHAT_SEND_BUTTON, 5000) + await el(T.CHAT_SEND_BUTTON).click() +} + +describe('app lifecycle: live location', () => { + let convID = '' + let sharing = false + + before(() => { + const udid = deviceUdid() + simctl('privacy', udid, 'grant', 'location-always', BUNDLE_ID) + setLocation(start.lat, start.lon) + }) + + // Sharing must never be left on, even when a flow fails partway. + after(async () => { + if (!sharing) return + await activateApp() + await waitForAppState('active', undefined, 90000) + await jsEval( + `kbModule('chat/conversation/send-actions.tsx').sendTextToConversation(${JSON.stringify(convID)}, ${JSON.stringify(requireSmokeUser())}, '/location stop'); return true` + ) + }) + + it('shares live location and posts a move while in the foreground', async () => { + const user = requireSmokeUser() + convID = await openSelfConversation(user) + const goMark = goLogMark() + const since = new Date(Date.now() - 1000) + await sendCommand('/location live 15m') + sharing = true + await waitForLinesInOrder('Go to start tracking', () => goLogSince(goMark), [/LiveLocationTracker: StartTracking/], 30000) + await waitForLinesInOrder('the native watcher to start', () => nativeLogSince('location', since), [ + /starting location updates/, + ]) + + const moveMark = goLogMark() + setLocation(moves[0]!.lat, moves[0]!.lon) + // The tracker can be busy posting the first fix for up to a minute before it posts this one. + await waitForLinesInOrder( + 'the foreground move to be posted', + () => goLogSince(moveMark), + [/\+ LiveLocationTracker: LocationUpdate/, /tracker\[\d+\]: got coords/, /\+ LiveLocationTracker: updateMapUnfurl/], + 180000 + ) + }) + + it('posts a move while in the background', async () => { + await waitForAppState('active') + const goMark = goLogMark() + await backgroundApp() + await waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [ + /lifecycle: uiBackground: /, + ]) + // JS doesn't run in the background, so anything after this comes from native. + const moveMark = goLogMark() + setLocation(moves[1]!.lat, moves[1]!.lon) + await waitForLinesInOrder( + 'the background move to be posted', + () => goLogSince(moveMark), + [/\+ LiveLocationTracker: LocationUpdate/, /tracker\[\d+\]: got coords/, /\+ LiveLocationTracker: updateMapUnfurl/], + 180000 + ) + expect(findLines(goLogSince(moveMark), /lifecycle: ui(Inactive|Active): /)).toEqual([]) + await activateApp() + await waitForAppState('active') + }) + + it('a move relaunches the app after it was killed and posts from the background', async function () { + // iOS delivers significant location changes on its own schedule: seconds to minutes. The + // budget covers all three waits below: the relaunch, the Go log, and the control's activation. + this.timeout(510000) + await terminateApp() + const goMark = goLogMark() + // start.log is shared by every device attached to Metro, so this device must be the only + // one running while the relaunch is watched. + const metroMark = metroLogMark() + setLocation(moves[2]!.lat, moves[2]!.lon) + + // iOS relaunches the app in the background for the significant location change. + const pid = await waitFor('iOS to relaunch the app for the move', () => appPid(), {interval: 1000, timeout: 300000}) + const lines = await waitForLinesInOrder( + 'the relaunched app to restore sharing and post the move', + () => goLogSince(goMark), + [ + /LiveLocationTracker: restoreLocked: restored [1-9]\d* trackers/, + /\+ LiveLocationTracker: LocationUpdate/, + /tracker\[\d+\]: got coords/, + /\+ LiveLocationTracker: updateMapUnfurl/, + ], + 120000 + ) + expect(lines).toHaveLength(4) + // Launched for location, not by the user: no scene came to the foreground. + const relaunched = goLogSince(goMark) + expect(findLines(relaunched, /lifecycle: acquire: liveLocation hold /).length).toBeGreaterThan(0) + expect(findLines(relaunched, /lifecycle: ui(Inactive|Active): /)).toEqual([]) + expect(appPid()).toBe(pid) + // No scene connected, so React Native never started: no bundle request and no JS logging. + expect(metroBundlingStartedSince(metroMark)).toEqual([]) + expect(metroClientLogSince(metroMark)).toEqual([]) + + // Control: starting a scene makes both readers, from that same mark, see the JS start they + // just reported absent, so neither can go quietly blind. + await activateApp() + await waitForAppState('active', undefined, 90000) + expect(metroBundlingStartedSince(metroMark).length).toBeGreaterThan(0) + expect(metroClientLogSince(metroMark).length).toBeGreaterThan(0) + }) + + it('stops sharing, stops the OS location service, and a move no longer relaunches the app', async function () { + this.timeout(420000) + const user = requireSmokeUser() + await activateApp() + await waitForAppState('active', undefined, 90000) + await openSelfConversation(user) + const goMark = goLogMark() + const since = new Date(Date.now() - 1000) + await sendCommand('/location stop') + // The tracker posts a final "done" update before it lets go of the watcher, and that post + // waits up to a minute for the unfurl that fails here. + await waitForLinesInOrder( + 'Go to stop tracking', + () => goLogSince(goMark), + [ + /LiveLocationTracker: StopAllTracking/, + /tracker\[\d+\]: stopped, updating with done status/, + /- LiveLocationTracker: updateMapUnfurl -> /, + ], + 150000 + ) + sharing = false + await waitForLinesInOrder('the native watcher to stop', () => nativeLogSince('location', since), [ + /stopping location updates/, + ]) + + // A move reaches neither Go nor, once the app is killed, a relaunch. + const moveMark = goLogMark() + setLocation(moves[0]!.lat, moves[0]!.lon) + await browser.pause(15000) + expect(locationUpdates(goLogSince(moveMark))).toEqual([]) + + // A relaunch took up to a minute and a half while sharing, so wait longer than that. + await terminateApp() + setLocation(moves[2]!.lat, moves[2]!.lon) + await browser.pause(120000) + expect(appPid()).toBeUndefined() + await activateApp() + await waitForAppState('active', undefined, 90000) + }) +}) diff --git a/shared/tests/e2e/ios-appium/helpers/app.ts b/shared/tests/e2e/ios-appium/helpers/app.ts index b25966673e75..21fdafdfa648 100644 --- a/shared/tests/e2e/ios-appium/helpers/app.ts +++ b/shared/tests/e2e/ios-appium/helpers/app.ts @@ -1,4 +1,6 @@ import {execSync} from 'child_process' +import {existsSync} from 'fs' +import * as path from 'path' export function udidForName(name: string): string { const json = execSync('xcrun simctl list devices available -j', {encoding: 'utf8'}) @@ -64,6 +66,21 @@ export function androidCapabilities(serial: string) { } } +// Xcode 27 replaced Simulator.app with DeviceHub.app. With a simulator running and no +// Simulator.app window, the xcuitest driver (appium-ios-simulator run()) shuts the simulator +// down and tries to open Simulator.app to show it, which fails the session. isHeadless makes it +// accept the booted simulator as is instead. It must stay off wherever Simulator.app exists: in +// headless mode the driver kills the Simulator.app window and reboots the device without one. +// It never touches DeviceHub, which the runners open, so the window stays up either way. +function hasSimulatorApp(): boolean { + try { + const developerDir = execSync('xcode-select -p', {encoding: 'utf8'}).trim() + return existsSync(path.join(developerDir, 'Applications', 'Simulator.app')) + } catch { + return true + } +} + interface IosCapsOpts { wdaLocalPort?: number // false for old-iOS sims (e.g. iOS 16.4): the single prebuilt WDA is built @@ -85,6 +102,7 @@ export function iosCapabilities(udid: string, opts: IosCapsOpts = {}) { 'appium:bundleId': 'keybase.ios', 'appium:noReset': true, 'appium:newCommandTimeout': 120, + ...(hasSimulatorApp() ? {} : {'appium:isHeadless': true}), // A fresh WDA build (prebuilt: false) runs xcodebuild and can take minutes // the first time; the prebuilt path launches in seconds. 'appium:wdaLaunchTimeout': prebuilt ? 120000 : 600000, diff --git a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts new file mode 100644 index 000000000000..5d20fe8c46b0 --- /dev/null +++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts @@ -0,0 +1,558 @@ +import {execFileSync} from 'child_process' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import {udidForName} from './app' +import * as T from '../../shared/test-ids' +import {waitForTestID} from './elements' +import {atTabs, escapeToTabs, navigateToChat} from './navigate' + +// Lifecycle flows assert on state and logs, never on screenshots: +// - JS state is read from the running app through the Metro inspector (Runtime.evaluate). +// - Native and Go transitions come from the app container's Go log (ios.log). +// - JS log lines (logger.info/warn) come from Metro's start.log as metro:client_log events. +// start.log is shared by every device attached to Metro, so flows that read it keep a +// single app running. + +export const BUNDLE_ID = 'keybase.ios' + +export const deviceName = () => process.env['KB_IOS_DEVICE'] ?? 'iPhoneTest' +export const deviceUdid = () => process.env['KB_IOS_UDID'] ?? udidForName(deviceName()) + +const sleep = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +export const simctl = (...args: Array): string => + execFileSync('xcrun', ['simctl', ...args], {encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']}) + +// Retries until check returns a value, or throws with the last error once the timeout passes. +export const waitFor = async ( + what: string, + check: () => Promise | R | undefined, + {timeout = 20000, interval = 250}: {timeout?: number; interval?: number} = {} +): Promise => { + const end = Date.now() + timeout + let lastErr: unknown + for (;;) { + try { + const r = await check() + if (r !== undefined) return r + } catch (e) { + lastErr = e + } + if (Date.now() > end) { + const detail = lastErr instanceof Error ? `: ${lastErr.message}` : '' + throw new Error(`timed out after ${timeout}ms waiting for ${what}${detail}`) + } + await sleep(interval) + } +} + +const statOrUndefined = (file: string) => { + try { + return fs.statSync(file) + } catch { + return undefined + } +} + +// -- app process ------------------------------------------------------------ + +// The pid of the running app on the simulator, or undefined when it isn't running. +export const appPid = (udid = deviceUdid()): number | undefined => { + const out = simctl('spawn', udid, 'launchctl', 'list') + for (const line of out.split('\n')) { + if (line.includes(`UIKitApplication:${BUNDLE_ID}[`)) { + const pid = Number(line.trim().split(/\s+/)[0]) + return Number.isFinite(pid) && pid > 0 ? pid : undefined + } + } + return undefined +} + +export const terminateApp = async (udid = deviceUdid()) => { + try { + simctl('terminate', udid, BUNDLE_ID) + } catch {} + await waitFor('the app to exit', () => (appPid(udid) === undefined ? true : undefined), {timeout: 10000}) +} + +// Crash reports for the iOS app written after `since`. Simulator crashes land in the host's +// DiagnosticReports next to the host's own, including the desktop Keybase app's, so match on +// the bundle id in the report's JSON header line. +export const crashReportsSince = (since: number): Array => { + const dir = path.join(os.homedir(), 'Library/Logs/DiagnosticReports') + let names: Array + try { + names = fs.readdirSync(dir) + } catch { + return [] + } + const bundleOf = (file: string) => { + try { + const header = fs.readFileSync(file, 'utf8').split('\n', 1)[0] ?? '' + return (JSON.parse(header) as {bundleID?: string}).bundleID + } catch { + return undefined + } + } + return names + .filter(n => n.endsWith('.ips')) + .map(n => path.join(dir, n)) + .filter(p => (statOrUndefined(p)?.mtimeMs ?? 0) >= since && bundleOf(p) === BUNDLE_ID) +} + +// -- Metro inspector ---------------------------------------------------------- + +const metroOrigin = 'http://127.0.0.1:8081' + +type InspectorPage = {deviceName?: string; appId?: string; webSocketDebuggerUrl: string} + +type EvalResponse = { + id: number + result?: {result?: {value?: unknown}; exceptionDetails?: {text?: string}} +} + +// Metro keeps a page per JS runtime the device has started; the newest is last. +const inspectorUrl = async (device: string) => { + const res = await fetch(`${metroOrigin}/json/list`) + const pages = (await res.json()) as Array + const page = pages.filter(p => p.deviceName === device && p.appId === BUNDLE_ID).at(-1) + if (!page) throw new Error(`no Metro inspector page for ${device}`) + return page.webSocketDebuggerUrl.replace('ws://localhost:', 'ws://127.0.0.1:') +} + +// Metro dev bundles register modules by path; this finds and requires one by that path. +const prelude = `const kbModule = name => { for (const [id, m] of __r.getModules()) if (m.verboseName === name) return __r(id); throw new Error('no module ' + name) };` + +// Evaluates a synchronous function body in the app's JS runtime and returns its value. +// Only works against a debug build served by Metro. +export const jsEval = async (body: string, device = deviceName()): Promise => { + const url = await inspectorUrl(device) + // The inspector proxy rejects connections without a local Origin; Node's WebSocket + // takes headers as a non-standard option. + const WS = WebSocket as unknown as new (url: string, opts: {headers: Record}) => WebSocket + const ws = new WS(url, {headers: {Origin: metroOrigin}}) + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('inspector evaluate timed out')), 10000) + ws.addEventListener('error', () => { + clearTimeout(timer) + reject(new Error('inspector connection failed')) + }) + ws.addEventListener('open', () => { + const expression = `(() => { ${prelude} ${body} })()` + ws.send(JSON.stringify({id: 1, method: 'Runtime.evaluate', params: {expression, returnByValue: true}})) + }) + ws.addEventListener('message', (e: MessageEvent) => { + const m = JSON.parse(String(e.data)) as EvalResponse + if (m.id !== 1) return + clearTimeout(timer) + if (m.result?.exceptionDetails) { + reject(new Error(`app evaluate threw: ${m.result.exceptionDetails.text ?? 'unknown'}`)) + } else { + resolve(m.result?.result?.value as R) + } + }) + }) + } finally { + ws.close() + } +} + +export type AppSnapshot = { + loggedIn: boolean + mobileAppState: string + httpSrv: {address: string; token: string} + screen?: {name?: string; params?: Record} +} + +// JS app state (shell store, fed by the service's appState notification), the http +// server address JS uses for images, and the visible screen. +export const appSnapshot = async (device = deviceName()) => + jsEval( + `const shell = kbModule('stores/shell.tsx').useShellState.getState() + const config = kbModule('stores/config.tsx').useConfigState.getState() + const screen = kbModule('constants/router.tsx').getVisibleScreen() + return { + httpSrv: config.httpSrv, + loggedIn: config.loggedIn, + mobileAppState: shell.mobileAppState, + screen: screen ? {name: screen.name, params: screen.params} : undefined, + }`, + device + ) + +// Waits for a relaunched JS runtime to be logged in and report `state`. There is only one +// derivation of it now, so there is no second value to agree with. +export const waitForAppState = async (state: string, device = deviceName(), timeout = 60000) => + waitFor( + `JS app state ${state}`, + async () => { + const s = await appSnapshot(device) + return s.loggedIn && s.mobileAppState === state && s.httpSrv.address ? s : undefined + }, + {interval: 500, timeout} + ) + +// -- avatars over the local http server -------------------------------------- + +// Fetches the smoke user's avatar from the app's local http server using the address and +// token JS currently holds, the same URL shape Go hands to image components. +export const fetchAvatar = async (httpSrv: {address: string; token: string}, username: string) => { + const url = `http://${httpSrv.address}/av?typ=user&name=${encodeURIComponent(username)}&format=square_192&token=${httpSrv.token}` + try { + const res = await fetch(url, {signal: AbortSignal.timeout(5000)}) + const body = await res.arrayBuffer() + return {bytes: body.byteLength, contentType: res.headers.get('content-type') ?? '', status: res.status} + } catch (e) { + return {bytes: 0, contentType: '', error: e instanceof Error ? e.message : String(e), status: 0} + } +} + +// Images load only once JS holds the running server's address; after a restart that can +// take a moment to arrive, so re-read it on each try. +export const waitForAvatar200 = async (username: string, device = deviceName(), timeout = 20000) => + waitFor( + 'the avatar to load from the local http server', + async () => { + const {httpSrv} = await appSnapshot(device) + const r = await fetchAvatar(httpSrv, username) + return r.status === 200 && r.bytes > 0 && r.contentType.startsWith('image/') ? {...r, httpSrv} : undefined + }, + {interval: 500, timeout} + ) + +// -- logs -------------------------------------------------------------------- + +type LogMark = {file: string; offset: number; ino: number} + +const readFrom = (file: string, offset: number) => { + const size = fs.statSync(file).size + if (size <= offset) return '' + const fd = fs.openSync(file, 'r') + try { + const buf = Buffer.alloc(size - offset) + fs.readSync(fd, buf, 0, buf.length, offset) + return buf.toString('utf8') + } finally { + fs.closeSync(fd) + } +} + +const markFile = (file: string): LogMark => { + const st = statOrUndefined(file) + return {file, ino: st?.ino ?? 0, offset: st?.size ?? 0} +} + +// Lines written to the file since the mark. The Go log is replaced by a new file when the app +// launches and rotated aside when it grows too big, so when the file at the path is no longer +// the marked one, the rest of the marked file (found by inode, if still there) is read first, +// then the new file from its start. +const linesSince = (mark: LogMark): Array => { + const st = statOrUndefined(mark.file) + if (!st) return [] + let text: string + if (st.ino === mark.ino) { + text = readFrom(mark.file, mark.offset) + } else { + const dir = path.dirname(mark.file) + const moved = fs + .readdirSync(dir) + .map(n => path.join(dir, n)) + .find(p => statOrUndefined(p)?.ino === mark.ino) + text = (moved ? readFrom(moved, mark.offset) : '') + readFrom(mark.file, 0) + } + return text.split('\n').filter(Boolean) +} + +// The Go service log inside the app's data container. The container moves on reinstall, +// so resolve it each time. +export const goLogPath = (udid = deviceUdid()) => + path.join(simctl('get_app_container', udid, BUNDLE_ID, 'data').trim(), 'Library/Caches/Keybase/logs/ios.log') + +export const goLogMark = (udid = deviceUdid()): LogMark => markFile(goLogPath(udid)) + +export const goLogSince = (mark: LogMark): Array => linesSince(mark) + +export const metroLogPath = path.resolve('.expo/dev/logs/start.log') + +export const metroLogMark = (): LogMark => markFile(metroLogPath) + +// JS console output since the mark, one string per call (arguments joined by spaces). +export const metroClientLogSince = (mark: LogMark): Array => + linesSince(mark) + .filter(l => l.includes('"metro:client_log"')) + .map(l => { + try { + const e = JSON.parse(l) as {data?: Array} + return (e.data ?? []).map(d => (typeof d === 'string' ? d : JSON.stringify(d))).join(' ') + } catch { + return '' + } + }) + .filter(Boolean) + +// Metro's bundle requests since the mark. A JS runtime that starts against a dev server always +// asks for a bundle, so this catches a JS start whose own logging never reached client_log. +export const metroBundlingStartedSince = (mark: LogMark): Array => + linesSince(mark).filter(l => l.includes('"metro:bundling:started"')) + +export const findLines = (lines: Array, re: RegExp) => lines.filter(l => re.test(l)) + +// Waits until `read` yields a line matching every pattern, in order. Returns the matched lines. +export const waitForLinesInOrder = async ( + what: string, + read: () => Array, + patterns: Array, + timeout = 20000 +) => + waitFor( + what, + () => { + const lines = read() + const matched: Array = [] + let i = 0 + for (const re of patterns) { + while (i < lines.length && !re.test(lines[i]!)) i++ + if (i === lines.length) return undefined + matched.push(lines[i]!) + i++ + } + return matched + }, + {interval: 500, timeout} + ) + +// Go's MobileAppState transitions since the mark, e.g. ['FOREGROUND', 'BACKGROUND']. +export const goAppStateUpdates = (mark: LogMark) => + goLogSince(mark) + .map(l => /MobileAppState\.Update: useful update: (\w+)/.exec(l)?.[1]) + .filter((s): s is string => !!s) + +// -- simulator actions ------------------------------------------------------- + +export const openUrl = (url: string, udid = deviceUdid()) => simctl('openurl', udid, url) + +export const sendPush = (payload: object, udid = deviceUdid()) => { + const file = path.join(os.tmpdir(), `kb-e2e-push-${process.pid}-${Date.now()}.json`) + fs.writeFileSync(file, JSON.stringify(payload)) + try { + simctl('push', udid, BUNDLE_ID, file) + } finally { + fs.rmSync(file, {force: true}) + } +} + +export const setLocation = (lat: number, lon: number, udid = deviceUdid()) => + simctl('location', udid, 'set', `${lat},${lon}`) + +export const isBooted = (udid: string) => simctl('list', 'devices', 'booted').includes(udid) + +// -- app, springboard and chat actions ----------------------------------------- + +export const backgroundApp = async () => browser.execute('mobile: backgroundApp', {seconds: -1}) + +export const activateApp = async () => browser.execute('mobile: activateApp', {bundleId: BUNDLE_ID}) + +export const launchApp = async () => browser.execute('mobile: launchApp', {bundleId: BUNDLE_ID}) + +// Runs fn with element lookups pointed at the home screen / system UI instead of the app. +export const withSpringboard = async (fn: () => Promise): Promise => { + await browser.updateSettings({defaultActiveApplication: 'com.apple.springboard'}) + try { + return await fn() + } finally { + await browser.updateSettings({defaultActiveApplication: BUNDLE_ID}) + } +} + +const labelContains = (text: string) => browser.$$(`-ios predicate string:label CONTAINS "${text}"`) + +// Pulling down from the top-left edge opens Notification Center over the app, which +// deactivates its scene without backgrounding it (the same inactive state Control Center +// and system alerts cause). +export const openNotificationCenter = async () => { + const {width, height} = await browser.getWindowRect() + const x = Math.round(width * 0.3) + await browser + .action('pointer') + .move({x, y: 2}) + .down() + .move({x, y: Math.round(height * 0.6), duration: 400}) + .up() + .perform() +} + +export const closeNotificationCenter = async () => { + const {width, height} = await browser.getWindowRect() + const x = Math.round(width * 0.5) + await browser + .action('pointer') + .move({x, y: height - 5}) + .down() + .move({x, y: Math.round(height * 0.2), duration: 300}) + .up() + .perform() +} + +// Waits for the system to show a notification whose text contains `body`, as a banner or, +// once the banner is gone, a row in Notification Center, and taps it when asked. Returns where +// it was found; a caller that doesn't tap must close Notification Center when it was opened. +export const findNotification = async (body: string, {tap}: {tap: boolean}) => + withSpringboard(async (): Promise<'banner' | 'center' | undefined> => { + const shown = async () => { + const els = await labelContains(body).getElements() + // The banner exposes a container, a button and the text; the button takes the tap. + for (const e of els) { + if ((await e.getAttribute('type')) === 'XCUIElementTypeButton') return e + } + return els[0] + } + const inBanner = await waitFor('the notification banner', shown, {interval: 300, timeout: 8000}).catch(() => undefined) + if (inBanner) { + if (tap) await inBanner.click() + return 'banner' + } + await openNotificationCenter() + const inCenter = await waitFor('the notification in Notification Center', shown, {interval: 300, timeout: 8000}).catch( + () => undefined + ) + if (!inCenter) { + await closeNotificationCenter() + return undefined + } + if (tap) await inCenter.click() + return 'center' + }) + +// Notification banners need the user's permission. Grants it through the app's own request +// and the system prompt when the simulator hasn't been asked yet. +export const ensureNotificationPermission = async () => { + const has = async () => jsEval(`return kbModule('stores/push.tsx').usePushState.getState().hasPermissions`) + if (await has()) return + await jsEval(`kbModule('stores/push.tsx').usePushState.getState().dispatch.requestPermissions(); return true`) + await withSpringboard(async () => { + const allow = browser.$('-ios predicate string:type == "XCUIElementTypeButton" AND label == "Allow"') + await allow.waitForExist({interval: 250, timeout: 15000}) + await waitFor( + 'the notification prompt to close', + async () => { + if (!(await allow.isExisting())) return true + await allow.click().catch(() => {}) + return undefined + }, + {interval: 1000, timeout: 15000} + ) + }) + await waitFor( + 'notification permission', + async () => { + await jsEval(`kbModule('stores/push.tsx').usePushState.getState().dispatch.checkPermissions(); return true`) + return (await has()) ? true : undefined + }, + {interval: 1000, timeout: 15000} + ) +} + +// Opens the smoke user's conversation with themselves and returns its id. The id comes from +// the inbox layout, and the conversation is opened by id: a keybase://chat/ link resolves +// the conversation through a lookup that can sit on a placeholder id for a long time. +export const openSelfConversation = async (username: string) => { + // The inbox layout loads with the inbox. + await escapeToTabs() + await navigateToChat() + const convID = await waitFor( + 'the self conversation in the inbox', + async () => + jsEval( + `const layout = kbModule('chat/inbox/layout-state.tsx').useInboxLayoutState.getState().layout + const row = layout && layout.smallTeams.find(t => !t.isTeam && t.name === ${JSON.stringify(username)}) + return row ? row.convID : null` + ).then(id => id ?? undefined), + {interval: 500, timeout: 30000} + ) + openUrl(`keybase://convid/${convID}`) + await waitFor( + 'the self conversation to open', + async () => { + const {screen} = await appSnapshot() + return screen?.name === 'chatConversation' && screen.params?.['conversationIDKey'] === convID ? true : undefined + }, + {interval: 500, timeout: 20000} + ) + // JS names the screen before the native push lands. Until it does, the tab root still looks + // current, so a reset right after this would skip popping the conversation and leave the tab + // bar hidden under it. iPad's split view never shows a back button here, hence the catch. + await waitForTestID(T.CHAT_INPUT, 10000) + await browser.waitUntil(async () => !(await atTabs()), {interval: 150, timeout: 5000}).catch(() => {}) + return convID +} + +// A second simulator signed in to the same account sends the message, so it reaches this +// device as an incoming message from another device. +export const senderDeviceName = () => process.env['KB_IOS_SENDER_DEVICE'] ?? 'iPadTest' + +export const startSenderDevice = async (username: string) => { + const name = senderDeviceName() + const udid = udidForName(name) + const bootedHere = !isBooted(udid) + const stop = async () => { + await terminateApp(udid).catch(() => {}) + if (bootedHere) simctl('shutdown', udid) + } + // Every step after a boot cleans up on failure, so a failed start never leaves a second + // simulator running (it render-throttles the one under test). + const ready = async () => { + if (bootedHere) { + simctl('boot', udid) + simctl('bootstatus', udid, '-b') + } + simctl('launch', udid, BUNDLE_ID) + await waitFor( + `${name} to be logged in as ${username}`, + async () => + (await jsEval( + `return kbModule('stores/config.tsx').useConfigState.getState().loggedIn && + kbModule('stores/current-user.tsx').useCurrentUserState.getState().username === ${JSON.stringify(username)}`, + name + )) + ? true + : undefined, + {interval: 1000, timeout: 180000} + ) + } + try { + await ready() + } catch (e) { + await stop().catch(() => {}) + throw e + } + const send = async (conversationIDKey: string, text: string) => + jsEval( + `kbModule('chat/conversation/send-actions.tsx').sendTextToConversation(${JSON.stringify(conversationIDKey)}, ${JSON.stringify(username)}, ${JSON.stringify(text)}); return true`, + name + ) + return {send, stop} +} + +// The app's own os_log lines for a category (subsystem com.keybase.app) since a time, read from +// the simulator's unified log. +export const nativeLogSince = (category: string, since: Date, udid = deviceUdid()) => { + const pad = (n: number) => String(n).padStart(2, '0') + const start = `${since.getFullYear()}-${pad(since.getMonth() + 1)}-${pad(since.getDate())} ${pad(since.getHours())}:${pad(since.getMinutes())}:${pad(since.getSeconds())}` + return simctl( + 'spawn', + udid, + 'log', + 'show', + '--start', + start, + '--info', + '--style', + 'compact', + '--predicate', + `subsystem == "com.keybase.app" AND category == "${category}"` + ) + .split('\n') + .filter(l => l.includes(`[com.keybase.app:${category}]`)) +} diff --git a/shared/tests/e2e/ios-appium/helpers/navigate.ts b/shared/tests/e2e/ios-appium/helpers/navigate.ts index 2733c1b725e2..f88640093e27 100644 --- a/shared/tests/e2e/ios-appium/helpers/navigate.ts +++ b/shared/tests/e2e/ios-appium/helpers/navigate.ts @@ -62,7 +62,7 @@ export async function tapSettingsRow(text: string): Promise { // True once we're at the root of a tab. The tab bar alone isn't proof: on iPad // it stays visible inside pushed stack screens, so also require that no back // button (app-custom or native) is present. -async function atTabs(): Promise { +export async function atTabs(): Promise { if (browser.isAndroid) { // tab() scopes to the native BottomNavigationView's label resource-ids with // EXACT text — a looser contains-match gets fooled by screen content (the diff --git a/shared/tests/e2e/ios-appium/lifecycle.test.ts b/shared/tests/e2e/ios-appium/lifecycle.test.ts new file mode 100644 index 000000000000..cf1adae7bd6f --- /dev/null +++ b/shared/tests/e2e/ios-appium/lifecycle.test.ts @@ -0,0 +1,6 @@ +// App lifecycle flows, run in one session by wdio.lifecycle.conf.ts. Live location runs +// last: the map posts it makes fail on the maps server and retry from the outbox for up to +// fifteen minutes, which keeps background task windows open for the flows after it. +import './flows/lifecycle-app-state.test' +import './flows/lifecycle-links-push.test' +import './flows/lifecycle-location.test' diff --git a/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts new file mode 100644 index 000000000000..bc98e388846b --- /dev/null +++ b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts @@ -0,0 +1,54 @@ +import * as fs from 'fs' +import * as path from 'path' +import {config as base} from './wdio.conf' +import {BUNDLE_ID, waitForAppState} from './helpers/lifecycle' +import {escapeToTabs} from './helpers/navigate' + +// App lifecycle flows (launch, background, deep links, push, live location). They +// kill, background and relaunch the app, so they run in their own session instead of +// the main suite. They validate with app state and logs only: no screenshots. +const debugDir = process.env['KB_IOS_APPIUM_DEBUG_DIR'] ?? 'tests/results/ios-appium-lifecycle-iphone' + +export const config: WebdriverIO.Config = { + ...base, + specs: [process.env['KB_IOS_SPEC'] ?? './lifecycle.test.ts'], + // Flows wait minutes on logs without sending a command (a relaunch for a location change, + // a map post that times out), so the session must outlive the default idle timeout. + capabilities: (base.capabilities as Array>).map(c => ({ + ...c, + 'appium:newCommandTimeout': 900, + })), + // A lifecycle regression is often intermittent, so a retry would hide exactly what + // these flows exist to catch. + mochaOpts: {bail: false, retries: 0, timeout: 420000, ui: 'bdd'}, + // A failed flow can leave the app in the background or not running; bring it back before + // resetting to the tab root, or every later flow fails in its reset. + beforeTest: async test => { + // eslint-disable-next-line no-console + console.log(`▶ ${new Date().toLocaleTimeString()} starting: ${test.title}`) + const foreground = 4 + if ((await browser.execute('mobile: queryAppState', {bundleId: BUNDLE_ID})) !== foreground) { + await browser.execute('mobile: activateApp', {bundleId: BUNDLE_ID}) + } + // A just-launched app is still loading its screens; resetting before then misses taps. + await waitForAppState('active', undefined, 90000) + await escapeToTabs() + }, + afterTest: (test, _context, result: {passed: boolean; duration: number; error?: Error}) => { + // eslint-disable-next-line no-console + console.log( + `${result.passed ? '✓' : '✗'} ${new Date().toLocaleTimeString()} ${test.title} (${(result.duration / 1000).toFixed(1)}s)` + ) + fs.mkdirSync(debugDir, {recursive: true}) + const slug = `${test.parent} ${test.title}`.replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') + fs.writeFileSync( + path.join(debugDir, `${slug}.json`), + JSON.stringify({ + durationMs: result.duration, + error: result.error?.message ?? null, + label: `${test.parent} › ${test.title}`, + passed: result.passed, + }) + ) + }, +} diff --git a/shared/tests/e2e/run-ios-appium-parallel.sh b/shared/tests/e2e/run-ios-appium-parallel.sh index 2fdf1e829f13..13788069f8d4 100755 --- a/shared/tests/e2e/run-ios-appium-parallel.sh +++ b/shared/tests/e2e/run-ios-appium-parallel.sh @@ -47,7 +47,8 @@ for NAME in "${DEVICES[@]}"; do xcrun simctl boot "$NAME" 2>/dev/null || true; d for NAME in "${DEVICES[@]}"; do xcrun simctl bootstatus "$NAME" -b >/dev/null 2>&1 || echo "⚠️ $NAME failed to boot" done -open -a Simulator >/dev/null 2>&1 || true +# Xcode 27 shows simulators in DeviceHub; older Xcodes in Simulator. +open -a Simulator >/dev/null 2>&1 || open -a DeviceHub >/dev/null 2>&1 || true BASE_PORT=4723 PIDS=() diff --git a/shared/tests/e2e/run-ios-appium.sh b/shared/tests/e2e/run-ios-appium.sh index ad9a48cec104..85be66218516 100755 --- a/shared/tests/e2e/run-ios-appium.sh +++ b/shared/tests/e2e/run-ios-appium.sh @@ -69,7 +69,8 @@ for NAME in "${DEVICES[@]}"; do OVERALL=1 continue fi - open -a Simulator >/dev/null 2>&1 || true + # Xcode 27 shows simulators in DeviceHub; older Xcodes in Simulator. + open -a Simulator >/dev/null 2>&1 || open -a DeviceHub >/dev/null 2>&1 || true # iPad runs in landscape; phones stay portrait. ORIENT=""; case "$NAME" in *[Pp]ad*) ORIENT="LANDSCAPE";; esac diff --git a/shared/tests/e2e/run-ios-lifecycle.sh b/shared/tests/e2e/run-ios-lifecycle.sh new file mode 100644 index 000000000000..f9ba46d27f7d --- /dev/null +++ b/shared/tests/e2e/run-ios-lifecycle.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Run the Appium iOS app lifecycle flows (tests/e2e/ios-appium/lifecycle.test.ts) on one simulator. +# +# Usage: +# KB_SMOKE_USER= tests/e2e/run-ios-lifecycle.sh [device] # default iPhoneTest +# +# Needs a debug build of the app installed and signed in as KB_SMOKE_USER, and Metro running: +# the flows read app state through Metro's inspector and JS logs from .expo/dev/logs/start.log. +# The receive flow also launches the app on a second simulator signed in to the same account +# (KB_IOS_SENDER_DEVICE, default iPadTest), booting it if needed and shutting it down after. +# Results (json only, no screenshots) land in tests/results/ios-appium-lifecycle-. +# +# Side effects on the simulators, left in place after the run: +# - the device under test grants the app location "always" (simctl privacy) and simulated +# locations are set on it; +# - the app is granted notification permission through its own prompt, which makes it upload +# an APNs sandbox push token for KB_SMOKE_USER's device to the Keybase server; +# - Notification Center keeps the test notifications, and the smoke user's conversation with +# themselves gains test messages and live location posts; +# - the sender simulator must already have this build installed and signed in; the flow launches +# it and shuts it down afterwards if it booted it. +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SHARED_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$SHARED_DIR" + +NAME="${1:-iPhoneTest}" +SLUG="$(echo "$NAME" | tr '[:upper:]' '[:lower:]')" +DBG="tests/results/ios-appium-lifecycle-$SLUG" +LOG="tests/results/run-lifecycle-$SLUG.log" + +if ! curl -sf http://127.0.0.1:8081/status >/dev/null; then + echo "❌ Metro is not running on 8081 (yarn rn:start)" + exit 1 +fi + +xcrun simctl boot "$NAME" 2>/dev/null || true +if ! xcrun simctl bootstatus "$NAME" -b >/dev/null 2>&1; then + echo "❌ Simulator not found / failed to boot: $NAME" + exit 1 +fi +# Xcode 27 shows simulators in DeviceHub; older Xcodes in Simulator. +open -a Simulator >/dev/null 2>&1 || open -a DeviceHub >/dev/null 2>&1 || true + +rm -rf "$DBG"; mkdir -p "$DBG" +echo "▶ Running lifecycle flows on $NAME" +KB_IOS_DEVICE="$NAME" KB_IOS_APPIUM_DEBUG_DIR="$DBG" \ + yarn wdio run tests/e2e/ios-appium/wdio.lifecycle.conf.ts 2>&1 | tee "$LOG" +exit "${PIPESTATUS[0]}" diff --git a/shared/tools/sim-push-chat.sh b/shared/tools/sim-push-chat.sh index cf4b48293b46..4de2e06e7740 100755 --- a/shared/tools/sim-push-chat.sh +++ b/shared/tools/sim-push-chat.sh @@ -12,8 +12,7 @@ PAYLOAD=$(cat <