From cee9190caff16d3231f0e8d40d917ce95da0da42 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 11:36:28 -0400 Subject: [PATCH 001/127] fix(ios): adopt the UIScene life cycle so Xcode 27 builds launch Apps built with the iOS 27 SDK trap at launch in _UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption unless they use scenes. SceneDelegate builds on Expo's ExpoAppSceneDelegate, which forwards lifecycle, URL and user-activity events to the AppDelegate overrides, and moves React Native's root view controller into a KeyboardWindow so hardware enter/shift-enter keep working. --- shared/ios/Keybase.xcodeproj/project.pbxproj | 4 ++ shared/ios/Keybase/AppDelegate.swift | 41 +++++++++----------- shared/ios/Keybase/Info.plist | 17 ++++++++ shared/ios/Keybase/SceneDelegate.swift | 31 +++++++++++++++ 4 files changed, 71 insertions(+), 22 deletions(-) create mode 100644 shared/ios/Keybase/SceneDelegate.swift diff --git a/shared/ios/Keybase.xcodeproj/project.pbxproj b/shared/ios/Keybase.xcodeproj/project.pbxproj index 7750bbcd19d1..04efe02c9f9d 100644 --- a/shared/ios/Keybase.xcodeproj/project.pbxproj +++ b/shared/ios/Keybase.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF89F52DF7779900EA18C2 /* Pusher.swift */; }; DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.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 */ @@ -120,6 +121,7 @@ DBDF89F52DF7779900EA18C2 /* Pusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pusher.swift; sourceTree = ""; }; DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareIntentDonatorImpl.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 +272,7 @@ children = ( 005BF9961BB9C6B000BD8953 /* Keybase.entitlements */, DBB8CC202DF336C200D43215 /* AppDelegate.swift */, + DBSCENE00270000000000001 /* SceneDelegate.swift */, DBDCF3081B8D03DD00BA95D8 /* Images.xcassets */, DBDCF3091B8D03DD00BA95D8 /* Info.plist */, DB07050422E21B8B002F273D /* KeepThisFile.swift */, @@ -592,6 +595,7 @@ 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 */, diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 53ae0007c74b..d5265b1b1c8b 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -36,11 +36,12 @@ class KeyboardWindow: UIWindow { } @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] = [:] @@ -89,15 +90,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,10 +98,7 @@ 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(application: application) return true } @@ -249,13 +238,26 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte UNUserNotificationCenter.current().delegate = self } - func didLaunchSetupAfter(application: UIApplication, rootView: UIView) { + // BGTaskScheduler.register must run before didFinishLaunching returns, so this + // can't wait for the scene to connect. + func didLaunchSetupAfter(application: UIApplication) { notifyAppState(application) + 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 @@ -266,12 +268,7 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate, UIDropInte self.resignImageView?.alpha = 0 self.resignImageView?.backgroundColor = rootView.backgroundColor self.resignImageView?.image = UIImage(named: "LaunchImage") - if let view = self.resignImageView { self.window?.addSubview(view) } - - BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in - self.handleAppRefresh(task: task as! BGAppRefreshTask) - } - scheduleAppRefresh() + if let view = self.resignImageView { window.addSubview(view) } } func addDrop(_ rootView: UIView) { 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/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift new file mode 100644 index 000000000000..f9a93d0b2091 --- /dev/null +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -0,0 +1,31 @@ +internal import Expo +import UIKit + +class SceneDelegate: ExpoAppSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + + guard let windowScene = scene as? UIWindowScene, + let expoWindow = self.window, + let rootViewController = expoWindow.rootViewController, + let appDelegate = UIApplication.shared.delegate as? AppDelegate + else { return } + + // ExpoAppSceneDelegate always creates a plain UIWindow, but hardware enter / + // shift-enter in the chat input needs KeyboardWindow. Move React Native's root + // view controller over before anything has rendered. + expoWindow.rootViewController = nil + expoWindow.isHidden = true + let window = KeyboardWindow(windowScene: windowScene) + window.rootViewController = rootViewController + window.makeKeyAndVisible() + self.window = window + appDelegate.window = window + + appDelegate.didStartReactNative(in: window) + } +} From 431ef439021a0f0a5c6eec75cf261beb2ca1be00 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 11:41:32 -0400 Subject: [PATCH 002/127] fix(ios): report foreground on become-active so the http server starts under scenes sceneDidBecomeActive is forwarded to applicationDidBecomeActive while applicationState still reads .inactive, so notifyAppState told Go INACTIVE and the local http server stayed stopped, leaving images blank. --- shared/ios/Keybase/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index d5265b1b1c8b..8182d5c6f75a 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -466,7 +466,9 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationDidBecomeActive: hiding keyz screen.") hideCover() log.info("applicationDidBecomeActive: notifying service.") - notifyAppState(application) + // Forwarded from sceneDidBecomeActive, where applicationState still reads + // .inactive; notifyAppState would stop the http server. + Keybasego.KeybaseSetAppStateForeground() // Re-emit a notification the user tapped while React Native wasn't ready yet. KbEmitStoredNotificationOnBecomeActive() From eebd580f78b78ef01841f65ab56990c16562730a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 11:52:21 -0400 Subject: [PATCH 003/127] refactor(ios): follow Expo's scene template Use the stock @objc(SceneDelegate) ExpoAppSceneDelegate with only a post-connect hook for root view setup. Drop KeyboardWindow in favor of handling hardware enter/shift-enter on the app delegate at the end of the responder chain, and drop the manual RCTLinkingManager overrides that Expo's scene forwarder already covers. --- shared/ios/Keybase/AppDelegate.swift | 64 +++++++++----------------- shared/ios/Keybase/SceneDelegate.swift | 17 +------ 2 files changed, 25 insertions(+), 56 deletions(-) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 8182d5c6f75a..04648fc15bdd 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -11,30 +11,6 @@ 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, ExpoReactNativeFactoryProvider, UNUserNotificationCenterDelegate, UIDropInteractionDelegate { var window: UIWindow? @@ -103,23 +79,28 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi 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 @@ -292,7 +273,8 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi 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() diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift index f9a93d0b2091..17be345c2006 100644 --- a/shared/ios/Keybase/SceneDelegate.swift +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -1,6 +1,7 @@ internal import Expo import UIKit +@objc(SceneDelegate) class SceneDelegate: ExpoAppSceneDelegate { override func scene( _ scene: UIScene, @@ -9,23 +10,9 @@ class SceneDelegate: ExpoAppSceneDelegate { ) { super.scene(scene, willConnectTo: session, options: connectionOptions) - guard let windowScene = scene as? UIWindowScene, - let expoWindow = self.window, - let rootViewController = expoWindow.rootViewController, + guard let window = self.window, let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } - - // ExpoAppSceneDelegate always creates a plain UIWindow, but hardware enter / - // shift-enter in the chat input needs KeyboardWindow. Move React Native's root - // view controller over before anything has rendered. - expoWindow.rootViewController = nil - expoWindow.isHidden = true - let window = KeyboardWindow(windowScene: windowScene) - window.rootViewController = rootViewController - window.makeKeyAndVisible() - self.window = window - appDelegate.window = window - appDelegate.didStartReactNative(in: window) } } From 6c149094f62de9aa1339a7275abb3b5d23398320 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 12:08:15 -0400 Subject: [PATCH 004/127] fix(appstate): add a state generation, iOS background start, and cheap single-flight flush MobileAppState now bumps a generation on every accepted update, including same-value ones, and exposes StateAndGeneration and UpdateIfGeneration so owners can undo only their own transitions. Update reports whether the value changed; waking NextUpdate, cancelling RPCs and flushing local DBs happen only on a real change. iOS starts in BACKGROUND. LevelDb.Flush compacts only the sentinel's key range, which still rotates and flushes the memtable, and is single-flight per DB. The lazy open now assigns l.db under the write lock. --- go/bind/appstate_test.go | 36 ++++++ go/bind/keybase.go | 18 ++- go/libkb/appstate.go | 105 ++++++++++++----- go/libkb/appstate_test.go | 242 ++++++++++++++++++++++++++++++++++++++ go/libkb/leveldb.go | 119 ++++++++++++------- go/libkb/leveldb_test.go | 123 +++++++++++++++++++ 6 files changed, 564 insertions(+), 79 deletions(-) create mode 100644 go/bind/appstate_test.go create mode 100644 go/libkb/appstate_test.go diff --git a/go/bind/appstate_test.go b/go/bind/appstate_test.go new file mode 100644 index 000000000000..6dd168dc342a --- /dev/null +++ b/go/bind/appstate_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package keybase + +import ( + "testing" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +func TestUpdateAppStateAndFlushOnlyOnChange(t *testing.T) { + tc := libkb.SetupTest(t, "UpdateAppStateAndFlush", 0) + defer tc.Cleanup() + appState := libkb.NewMobileAppState(tc.G) + + flushes := 0 + flush := func() { flushes++ } + + updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUND, flush) + require.Equal(t, 1, flushes) + _, gen := appState.StateAndGeneration() + + updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUND, flush) + require.Equal(t, 1, flushes, "a repeated BACKGROUND must not flush again") + _, gen2 := appState.StateAndGeneration() + require.Greater(t, gen2, gen) + + appState.Update(keybase1.MobileAppState_FOREGROUND) + updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUNDACTIVE, flush) + require.Equal(t, 2, flushes) + updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUNDACTIVE, flush) + require.Equal(t, 2, flushes) +} diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 4aa2ea2827de..3769e82dbb0b 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -888,8 +888,15 @@ func SetAppStateBackground() { return } defer kbCtx.Trace("SetAppStateBackground", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - flushLocalDbs() + updateAppStateAndFlush(kbCtx.MobileAppState, keybase1.MobileAppState_BACKGROUND, flushLocalDbs) +} + +// updateAppStateAndFlush flushes only when the state actually changes, so +// repeated lifecycle callbacks don't queue a flush each. +func updateAppStateAndFlush(appState *libkb.MobileAppState, state keybase1.MobileAppState, flush func()) { + if appState.Update(state) { + flush() + } } // flushLocalDbs flushes the leveldb memtables in the background. An unclean @@ -1027,8 +1034,7 @@ func AppWillExit(pusher PushNotifier) { // know they will get stuck pushPendingMessageFailure(obrs, pusher) } - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - flushLocalDbs() + updateAppStateAndFlush(kbCtx.MobileAppState, keybase1.MobileAppState_BACKGROUND, flushLocalDbs) } // AppDidEnterBackground notifies the service that the app is in the background @@ -1058,8 +1064,8 @@ func AppDidEnterBackground() bool { } if stayRunning { kbCtx.Log.Debug("AppDidEnterBackground: setting background active") - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - flushLocalDbs() + // The OS may still kill us once the background task runs out. + updateAppStateAndFlush(kbCtx.MobileAppState, keybase1.MobileAppState_BACKGROUNDACTIVE, flushLocalDbs) return true } SetAppStateBackground() diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index c278bce2fd79..572e0310014e 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -27,6 +27,10 @@ type MobileAppState struct { Contextified sync.Mutex state keybase1.MobileAppState + // generation increments on every accepted update, including one that + // sets the current value again, so a writer that read an older generation + // can tell that someone else has spoken since. + generation uint64 // changed is closed and replaced whenever state actually changes. Any // caller holding a reference to the previous channel is woken by the // close; they then re-read State() to see the new value. @@ -38,18 +42,28 @@ 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": + // we need this so cold notifications work on android + return keybase1.MobileAppState_BACKGROUNDACTIVE + case "ios": + // iOS launches the process in the background for silent pushes and + // background refresh; the scene life cycle reports foreground once + // the app is actually on screen. + 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,29 +82,31 @@ 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{}) +func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bool) { + a.generation++ + if a.state == state { + a.G().Log.Debug("MobileAppState.Update: same-value update: %v, generation: %d", + state, a.generation) + return false + } + a.G().Log.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v, generation: %d", + state, a.state, a.generation) + 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) + // 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. } + return true } func (a *MobileAppState) UpdateWithCheck(state keybase1.MobileAppState, @@ -106,12 +122,33 @@ func (a *MobileAppState) UpdateWithCheck(state keybase1.MobileAppState, } } -// 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 bumps the generation, even when state +// is already current. It returns whether the value changed; only a change +// wakes NextUpdate callers and has side effects. +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) +} + +// UpdateIfGeneration applies state only if no update has been accepted since +// gen was read from StateAndGeneration. It returns the generation after the +// call (the new one when applied, the current one otherwise), whether the +// update was applied, and whether the value changed. +func (a *MobileAppState) UpdateIfGeneration(gen uint64, state keybase1.MobileAppState) ( + newGen uint64, applied bool, changed bool, +) { + defer a.G().Trace(fmt.Sprintf("MobileAppState.UpdateIfGeneration(%d, %v)", gen, state), nil)() + a.Lock() + defer a.Unlock() + if a.generation != gen { + a.G().Log.Debug("MobileAppState.UpdateIfGeneration: skipping update, generation %d is now %d", + gen, a.generation) + return a.generation, false, false + } + changed = a.updateLocked(state) + return a.generation, true, changed } // State returns the current app state @@ -121,6 +158,14 @@ func (a *MobileAppState) State() keybase1.MobileAppState { return a.state } +// StateAndGeneration returns the current app state together with the +// generation that produced it, for use with UpdateIfGeneration. +func (a *MobileAppState) StateAndGeneration() (keybase1.MobileAppState, uint64) { + a.Lock() + defer a.Unlock() + return a.state, a.generation +} + func (a *MobileAppState) StateAndMtime() (keybase1.MobileAppState, *time.Time) { a.Lock() defer a.Unlock() diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go new file mode 100644 index 000000000000..02a1256d3f2c --- /dev/null +++ b/go/libkb/appstate_test.go @@ -0,0 +1,242 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package libkb + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "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_BACKGROUNDACTIVE, initialMobileAppState("android")) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("darwin")) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("linux")) +} + +func TestMobileAppStateGeneration(t *testing.T) { + tc := SetupTest(t, "MobileAppStateGeneration", 0) + defer tc.Cleanup() + a := NewMobileAppState(tc.G) + + state, gen := a.StateAndGeneration() + require.Equal(t, keybase1.MobileAppState_FOREGROUND, state) + + require.True(t, a.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)) + state, gen1 := a.StateAndGeneration() + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) + require.Greater(t, gen1, gen) + + // A same-value update is accepted and bumps the generation. + require.False(t, a.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)) + state, gen2 := a.StateAndGeneration() + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) + require.Greater(t, gen2, gen1) + + // A CAS against the generation read before that same-value update fails. + newGen, applied, changed := a.UpdateIfGeneration(gen1, keybase1.MobileAppState_BACKGROUND) + require.False(t, applied) + require.False(t, changed) + require.Equal(t, gen2, newGen) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, a.State()) + + // A CAS against the current generation applies. + newGen, applied, changed = a.UpdateIfGeneration(gen2, keybase1.MobileAppState_BACKGROUND) + require.True(t, applied) + require.True(t, changed) + state, gen3 := a.StateAndGeneration() + require.Equal(t, keybase1.MobileAppState_BACKGROUND, state) + require.Equal(t, gen3, newGen) + require.Greater(t, gen3, gen2) + + // A same-value CAS applies, bumps the generation, and reports no change. + newGen, applied, changed = a.UpdateIfGeneration(gen3, keybase1.MobileAppState_BACKGROUND) + require.True(t, applied) + require.False(t, changed) + require.Greater(t, newGen, gen3) +} + +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) + + _, gen := a.StateAndGeneration() + _, _, _ = a.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUND) + requireOpen(t, next) + + // 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 TestMobileAppStateStress(t *testing.T) { + tc := SetupTest(t, "MobileAppStateStress", 0) + defer tc.Cleanup() + a := NewMobileAppState(tc.G) + + // Writers never set BACKGROUNDACTIVE; it marks the end for waiters. + states := []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_INACTIVE, + } + const ( + writers = 8 + casWriters = 8 + waiters = 8 + iterations = 300 + ) + _, startGen := a.StateAndGeneration() + + var ( + accepted atomic.Uint64 + waitersWG sync.WaitGroup + writersWG sync.WaitGroup + ) + errs := make(chan string, casWriters*iterations) + + for i := 0; i < waiters; i++ { + waitersWG.Add(1) + go func() { + defer waitersWG.Done() + for { + s := a.State() + if s == keybase1.MobileAppState_BACKGROUNDACTIVE { + return + } + <-a.NextUpdate(s) + } + }() + } + + for i := 0; i < writers; i++ { + writersWG.Add(1) + go func(i int) { + defer writersWG.Done() + for j := 0; j < iterations; j++ { + a.Update(states[(i+j)%len(states)]) + accepted.Add(1) + } + }(i) + } + + for i := 0; i < casWriters; i++ { + writersWG.Add(1) + go func(i int) { + defer writersWG.Done() + for j := 0; j < iterations; j++ { + next := states[(i+j)%len(states)] + if j%2 == 0 { + // Our own update in between makes gen stale, whatever else runs. + _, gen := a.StateAndGeneration() + a.Update(next) + accepted.Add(1) + if _, applied, _ := a.UpdateIfGeneration(gen, next); applied { + errs <- "a CAS with a stale generation applied" + } + continue + } + _, gen := a.StateAndGeneration() + newGen, applied, _ := a.UpdateIfGeneration(gen, next) + if applied { + accepted.Add(1) + if newGen != gen+1 { + errs <- "an applied CAS did not advance the generation by one" + } + } else if newGen <= gen { + errs <- "a rejected CAS reported a generation that did not move" + } + } + }(i) + } + + requireDoneWithin(t, &writersWG, 30*time.Second, "writers deadlocked") + + _, gen := a.StateAndGeneration() + require.Equal(t, startGen+accepted.Load(), gen, "every accepted update bumps the generation exactly once") + + // A CAS on the current generation applies and is the last update, so it + // must be the final state. + newGen, applied, _ := a.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) + require.True(t, applied) + state, finalGen := a.StateAndGeneration() + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) + require.Equal(t, newGen, finalGen) + + requireDoneWithin(t, &waitersWG, 30*time.Second, "a NextUpdate waiter missed the final change") + close(errs) + for err := range errs { + require.Fail(t, err) + } +} + +func requireDoneWithin(t *testing.T, wg *sync.WaitGroup, timeout time.Duration, msg string) { + t.Helper() + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(timeout): + require.Fail(t, msg) + } +} diff --git a/go/libkb/leveldb.go b/go/libkb/leveldb.go index 7a048acce3ed..ceab79085fbe 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" @@ -121,6 +122,7 @@ type LevelDb struct { db *leveldb.DB dbOpenerOnce *sync.Once cleaner *levelDbCleaner + flushing atomic.Bool filename string Contextified @@ -153,45 +155,66 @@ func (l *LevelDb) Opts() *opt.Options { } } +// openLocked runs the lazy open if it hasn't run since the last Nuke. It +// reports whether this call ran it. Callers must hold the write lock, since +// opening assigns l.db. +func (l *LevelDb) openLocked() (ran bool, err error) { + l.dbOpenerOnce.Do(func() { + ran = true + l.G().Log.Debug("+ LevelDb.open") + fn := l.GetFilename() + 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()) + 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) + if recoveryError != nil { + l.G().Log.Debug("| Recovery failed: %v", recoveryError) + } else { + l.G().Log.Debug("| Recovery succeeded!") + // wipe the outer error since it's fixed now + err = nil + } + } + l.G().Log.Debug("- LevelDb.open -> %s", ErrToOk(err)) + if l.db != nil { + l.cleaner.setDb(l.db) + } + }) + return ran, err +} + func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) { err = func() error { l.RLock() defer l.RUnlock() - // This only happens at first ever doWhileOpenAndNukeIfCorrupted call, or - // when doOpenerOnce is just reset in Nuke() - 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 options: %+v", l.Opts()) - l.db, err = leveldb.OpenFile(fn, l.Opts()) - 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) - if recoveryError != nil { - l.G().Log.Debug("| Recovery failed: %v", recoveryError) - } else { - l.G().Log.Debug("| Recovery succeeded!") - // wipe the outer error since it's fixed now - err = nil - } + // The lazy open only runs at the first ever call, or after Nuke() resets + // dbOpenerOnce. It assigns l.db, so it needs the write lock. Close() or + // Nuke() may slip in while the read lock is dropped, so re-check once + // it's back. + for l.db == nil { + l.RUnlock() + l.Lock() + var openErr error + closed := false + if l.db == nil { + var ran bool + ran, openErr = l.openLocked() + closed = !ran } - l.G().Log.Debug("- LevelDb.open -> %s", ErrToOk(err)) - if l.db != nil { - l.cleaner.setDb(l.db) + l.Unlock() + l.RLock() + if openErr != nil { + return openErr + } + if closed { + // This means DB is already closed. We are preventing lazy-opening after + // closing, so just return error here. + return LevelDBOpenClosedError{} } - }) - - if err != nil { - return err - } - - if l.db == nil { - // This means DB is already closed. We are preventing lazy-opening after - // closing, so just return error here. - return LevelDBOpenClosedError{} } return action() @@ -230,9 +253,7 @@ 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. +// levelDbFlushSentinelKey lives in the "pm" table so the db cleaner ignores it. var levelDbFlushSentinelKey = []byte(levelDbTablePerm + ":ff:flush-sentinel") // Flush writes the current memtable to disk and rotates the journal. An @@ -241,23 +262,29 @@ var levelDbFlushSentinelKey = []byte(levelDbTablePerm + ":ff:flush-sentinel") // journal tail is corrupt — both of which block startup. Flushing while // entering the background leaves a near-empty journal so the next cold start // opens fast. No-op if the DB is not currently open; does not trigger a lazy -// open. +// open. A call made while another flush of this DB is running returns without +// doing anything. func (l *LevelDb) Flush() (err error) { + if !l.flushing.CompareAndSwap(false, true) { + return nil + } + defer l.flushing.Store(false) defer convertNoSpaceError(&err) l.RLock() defer l.RUnlock() if l.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. + // CompactRange rotates and flushes the memtable only when the memtable + // overlaps the range, so write the sentinel first. Compacting just the + // sentinel's own range keeps the table compaction that follows limited to + // the few tables holding that key, instead of rewriting the whole DB. If a + // concurrent write rotates the memtable between the Put and CompactRange, + // that rotation has already scheduled the sentinel's memtable for flushing. if err = l.db.Put(levelDbFlushSentinelKey, nil, nil); err != nil { return err } - if err = l.db.CompactRange(util.Range{}); err != nil { + if err = l.db.CompactRange(*util.BytesPrefix(levelDbFlushSentinelKey)); err != nil { return err } return l.db.Delete(levelDbFlushSentinelKey, nil) @@ -407,7 +434,13 @@ func (l *LevelDb) OpenTransaction() (LocalDbTransaction, error) { ltr LevelDbTransaction err error ) - if ltr.tr, err = l.db.OpenTransaction(); err != nil { + l.RLock() + db := l.db + l.RUnlock() + if db == nil { + return LevelDbTransaction{}, LevelDBOpenClosedError{} + } + if ltr.tr, err = db.OpenTransaction(); err != nil { return LevelDbTransaction{}, err } ltr.cleaner = l.cleaner diff --git a/go/libkb/leveldb_test.go b/go/libkb/leveldb_test.go index e40565abfa6f..6b553023a544 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,28 @@ 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.Stats(&stats) + })) + return stats +} + +func levelDbTableCount(t *testing.T, db *LevelDb) (count int) { + for _, n := range levelDbStats(t, db).LevelTablesCounts { + count += n + } + return count +} + +func levelDbLevelTableCount(t *testing.T, db *LevelDb, level int) int { + counts := levelDbStats(t, db).LevelTablesCounts + if level >= len(counts) { + return 0 + } + return counts[level] +} + func testLevelDbPut(db *LevelDb) (key DbKey, err error) { key = DbKey{Key: "test-key", Typ: 0} v := []byte{1, 2, 3, 4} @@ -123,8 +146,10 @@ 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. @@ -140,6 +165,104 @@ func TestLevelDb(t *testing.T) { require.NoError(t, err) }, }, + { + name: "flush-single-flight", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-flush-single-flight", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + + _, err = testLevelDbPut(db) + require.NoError(t, err) + + db.flushing.Store(true) + require.NoError(t, db.Flush()) + require.Zero(t, levelDbTableCount(t, db), "a flush already in flight should make this one a no-op") + + db.flushing.Store(false) + require.NoError(t, db.Flush()) + require.NotZero(t, levelDbTableCount(t, db)) + }, + }, + { + name: "flush-compacts-only-sentinel-range", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-flush-narrow", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + + // Opening a transaction flushes the memtable to level 0 without a + // table compaction, which leaves level-0 tables that don't hold the + // flush sentinel. + for _, k := range []string{"a", "b"} { + require.NoError(t, db.Put(DbKey{Key: k, Typ: 0}, nil, []byte{1})) + tr, err := db.OpenTransaction() + require.NoError(t, err) + tr.Discard() + } + require.Equal(t, 2, levelDbLevelTableCount(t, db, 0)) + + require.NoError(t, db.Flush()) + require.Equal(t, 2, levelDbLevelTableCount(t, db, 0), + "flush should not compact tables outside the sentinel range") + }, + }, + { + 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) From 354cbec991d572dfcb2cb36ccf94bec6812845a3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 12:19:24 -0400 Subject: [PATCH 005/127] fix(leveldb): flush by rotating the memtable and coalesce concurrent flushes Flush now rotates the memtable through an opened-and-discarded transaction, which waits for the memtable to reach a table without compacting any tables. A Flush that arrives while one is running makes it run once more instead of being dropped. The lazy open goes back to running under the read lock; a write-locked open deadlocked against an in-flight read-locked operation. Only the db assignment and the readers that skip the open are guarded now. --- go/libkb/leveldb.go | 172 +++++++++++++++++++++------------------ go/libkb/leveldb_test.go | 134 +++++++++++++++++++++++------- 2 files changed, 199 insertions(+), 107 deletions(-) diff --git a/go/libkb/leveldb.go b/go/libkb/leveldb.go index ceab79085fbe..3ae088436682 100644 --- a/go/libkb/leveldb.go +++ b/go/libkb/leveldb.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "sync" - "sync/atomic" "github.com/syndtr/goleveldb/leveldb" errors "github.com/syndtr/goleveldb/leveldb/errors" @@ -121,8 +120,16 @@ type LevelDb struct { sync.RWMutex db *leveldb.DB dbOpenerOnce *sync.Once - cleaner *levelDbCleaner - flushing atomic.Bool + // dbMu guards the lazy open's assignment of db, which runs under the read + // lock, against readers that don't go through dbOpenerOnce. + dbMu sync.Mutex + cleaner *levelDbCleaner + + flushMu sync.Mutex + flushRunning bool + flushRerun bool + // flushHook, if set, runs after each memtable rotation. Tests only. + flushHook func() filename string Contextified @@ -155,66 +162,49 @@ func (l *LevelDb) Opts() *opt.Options { } } -// openLocked runs the lazy open if it hasn't run since the last Nuke. It -// reports whether this call ran it. Callers must hold the write lock, since -// opening assigns l.db. -func (l *LevelDb) openLocked() (ran bool, err error) { - l.dbOpenerOnce.Do(func() { - ran = true - l.G().Log.Debug("+ LevelDb.open") - fn := l.GetFilename() - 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()) - 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) - if recoveryError != nil { - l.G().Log.Debug("| Recovery failed: %v", recoveryError) - } else { - l.G().Log.Debug("| Recovery succeeded!") - // wipe the outer error since it's fixed now - err = nil - } - } - l.G().Log.Debug("- LevelDb.open -> %s", ErrToOk(err)) - if l.db != nil { - l.cleaner.setDb(l.db) - } - }) - return ran, err -} - func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) { err = func() error { l.RLock() defer l.RUnlock() - // The lazy open only runs at the first ever call, or after Nuke() resets - // dbOpenerOnce. It assigns l.db, so it needs the write lock. Close() or - // Nuke() may slip in while the read lock is dropped, so re-check once - // it's back. - for l.db == nil { - l.RUnlock() - l.Lock() - var openErr error - closed := false - if l.db == nil { - var ran bool - ran, openErr = l.openLocked() - closed = !ran - } - l.Unlock() - l.RLock() - if openErr != nil { - return openErr + // This only happens at first ever doWhileOpenAndNukeIfCorrupted call, or + // when doOpenerOnce is just reset in Nuke() + l.dbOpenerOnce.Do(func() { + l.G().Log.Debug("+ LevelDb.open") + fn := l.GetFilename() + l.G().Log.Debug("| Opening LevelDB for local cache: %s", fn) + l.G().Log.Debug("| Opening LevelDB options: %+v", 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 + db, recoveryError = leveldb.RecoverFile(fn, nil) + if recoveryError != nil { + l.G().Log.Debug("| Recovery failed: %v", recoveryError) + } else { + l.G().Log.Debug("| Recovery succeeded!") + // wipe the outer error since it's fixed now + err = nil + } } - if closed { - // This means DB is already closed. We are preventing lazy-opening after - // closing, so just return error here. - return LevelDBOpenClosedError{} + l.G().Log.Debug("- LevelDb.open -> %s", ErrToOk(err)) + l.dbMu.Lock() + l.db = db + l.dbMu.Unlock() + if db != nil { + l.cleaner.setDb(db) } + }) + + if err != nil { + return err + } + + if l.db == nil { + // This means DB is already closed. We are preventing lazy-opening after + // closing, so just return error here. + return LevelDBOpenClosedError{} } return action() @@ -253,41 +243,69 @@ func (l *LevelDb) ForceOpen() error { return l.doWhileOpenAndNukeIfCorrupted(func() error { return nil }) } -// levelDbFlushSentinelKey lives in the "pm" table so the db cleaner ignores it. -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. A call made while another flush of this DB is running returns without -// doing anything. +// open. +// +// Flushes of one DB never overlap. A call that arrives while a flush is +// running returns immediately and makes the running flush go around once +// more, so writes made before that call still get flushed. func (l *LevelDb) Flush() (err error) { - if !l.flushing.CompareAndSwap(false, true) { + l.flushMu.Lock() + if l.flushRunning { + l.flushRerun = true + l.flushMu.Unlock() return nil } - defer l.flushing.Store(false) + l.flushRunning = true + l.flushMu.Unlock() + + for { + err = l.flushMemtable() + l.flushMu.Lock() + if err != nil || !l.flushRerun { + l.flushRunning = false + l.flushRerun = false + l.flushMu.Unlock() + return err + } + l.flushRerun = false + l.flushMu.Unlock() + } +} + +// 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 { + l.dbMu.Lock() + defer l.dbMu.Unlock() + return l.db +} + +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 } - // CompactRange rotates and flushes the memtable only when the memtable - // overlaps the range, so write the sentinel first. Compacting just the - // sentinel's own range keeps the table compaction that follows limited to - // the few tables holding that key, instead of rewriting the whole DB. If a - // concurrent write rotates the memtable between the Put and CompactRange, - // that rotation has already scheduled the sentinel's memtable for flushing. - 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.BytesPrefix(levelDbFlushSentinelKey)); 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) { @@ -435,7 +453,7 @@ func (l *LevelDb) OpenTransaction() (LocalDbTransaction, error) { err error ) l.RLock() - db := l.db + db := l.openedDb() l.RUnlock() if db == nil { return LevelDbTransaction{}, LevelDBOpenClosedError{} diff --git a/go/libkb/leveldb_test.go b/go/libkb/leveldb_test.go index 6b553023a544..7f0a03a97f15 100644 --- a/go/libkb/leveldb_test.go +++ b/go/libkb/leveldb_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "testing" "time" @@ -84,12 +85,18 @@ func levelDbTableCount(t *testing.T, db *LevelDb) (count int) { return count } -func levelDbLevelTableCount(t *testing.T, db *LevelDb, level int) int { - counts := levelDbStats(t, db).LevelTablesCounts - if level >= len(counts) { - return 0 +// 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 counts[level] + return size } func testLevelDbPut(db *LevelDb) (key DbKey, err error) { @@ -152,13 +159,11 @@ func TestLevelDb(t *testing.T) { 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) @@ -166,45 +171,114 @@ func TestLevelDb(t *testing.T) { }, }, { - name: "flush-single-flight", testBody: func(t *testing.T) { - tc := SetupTest(t, "LevelDb-flush-single-flight", 0) + 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()) - _, err = testLevelDbPut(db) - require.NoError(t, err) + 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.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.OpenTransaction() + require.NoError(t, err) + tr.Discard() + } + putAcrossPrefixes(2) + require.NotZero(t, levelDbJournalSize(t, db)) + before := levelDbStats(t, db).LevelTablesCounts + beforeTotal := levelDbTableCount(t, db) - db.flushing.Store(true) require.NoError(t, db.Flush()) - require.Zero(t, levelDbTableCount(t, db), "a flush already in flight should make this one a no-op") - db.flushing.Store(false) + 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.Get([]byte("zz:2:19"), nil) + require.NoError(t, err) + require.Equal(t, bytes.Repeat([]byte{19}, 100), val) + }, + }, + { + name: "flush-coalesces", testBody: func(t *testing.T) { + tc := SetupTest(t, "LevelDb-flush-coalesces", 0) + defer tc.Cleanup() + db, err := createTempLevelDbForTest(&tc, &td) + require.NoError(t, err) + _, err = testLevelDbPut(db) + require.NoError(t, err) + + // A write and a Flush request that land after the running flush + // rotated the memtable must still be flushed before it returns. + rotations := 0 + db.flushHook = func() { + rotations++ + if rotations == 1 { + require.NoError(t, db.db.Put([]byte("kv:late"), []byte{1}, nil)) + require.NoError(t, db.Flush()) + } + } require.NoError(t, db.Flush()) - require.NotZero(t, levelDbTableCount(t, db)) + require.Equal(t, 2, rotations) + require.Zero(t, levelDbJournalSize(t, db)) }, }, { - name: "flush-compacts-only-sentinel-range", testBody: func(t *testing.T) { - tc := SetupTest(t, "LevelDb-flush-narrow", 0) + 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()) - // Opening a transaction flushes the memtable to level 0 without a - // table compaction, which leaves level-0 tables that don't hold the - // flush sentinel. - for _, k := range []string{"a", "b"} { - require.NoError(t, db.Put(DbKey{Key: k, Typ: 0}, nil, []byte{1})) - tr, err := db.OpenTransaction() - require.NoError(t, err) - tr.Discard() + var active, maxActive atomic.Int32 + db.flushHook = func() { + n := active.Add(1) + for { + m := maxActive.Load() + if n <= m || maxActive.CompareAndSwap(m, n) { + break + } + } + time.Sleep(time.Millisecond) + active.Add(-1) } - require.Equal(t, 2, levelDbLevelTableCount(t, db, 0)) - require.NoError(t, db.Flush()) - require.Equal(t, 2, levelDbLevelTableCount(t, db, 0), - "flush should not compact tables outside the sentinel range") + 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.Equal(t, int32(1), maxActive.Load(), "flushes must not overlap") + 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) + } }, }, { From 77184f1358125fa4c890a486df1446a634a21399 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 12:29:19 -0400 Subject: [PATCH 006/127] fix(appstate): owners undo only their own app-state transitions BackgroundSync, the background task window, its expiration and live location each keep the generation of the BACKGROUNDACTIVE transition they made and return to BACKGROUND with a generation CAS, so a newer lifecycle update (including iOS willEnterForeground's same-value BACKGROUNDACTIVE) is never overwritten. The background task now returns to BACKGROUND when it finishes, fails or times out. UpdateWithCheck returns the generation it applied at. New bind entry points: AppBackgroundTaskExpired for the iOS expiration handler, and AppPushWindowBegin/AppPushWindowEnd for Android's push window. --- go/bind/appstate_owners.go | 211 +++++++++++++++ go/bind/appstate_owners_test.go | 462 ++++++++++++++++++++++++++++++++ go/bind/keybase.go | 210 ++++++--------- go/chat/maps/bgactive.go | 31 +++ go/chat/maps/bgactive_test.go | 52 ++++ go/chat/maps/livelocation.go | 22 +- go/libkb/appstate.go | 14 +- go/libkb/appstate_test.go | 22 ++ 8 files changed, 880 insertions(+), 144 deletions(-) create mode 100644 go/bind/appstate_owners.go create mode 100644 go/bind/appstate_owners_test.go create mode 100644 go/chat/maps/bgactive.go create mode 100644 go/chat/maps/bgactive_test.go diff --git a/go/bind/appstate_owners.go b/go/bind/appstate_owners.go new file mode 100644 index 000000000000..faad2dbc86e9 --- /dev/null +++ b/go/bind/appstate_owners.go @@ -0,0 +1,211 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package keybase + +import ( + "context" + "errors" + "sync/atomic" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "golang.org/x/sync/errgroup" +) + +const ( + backgroundSyncWindowDuration = 10 * time.Second + backgroundTaskPollInterval = 5 * time.Second + backgroundTaskMaxDuration = 10 * time.Minute +) + +// backgroundTaskGen is the generation at which the app entered the +// BACKGROUNDACTIVE window that a background task (AppBeginBackgroundTask) +// works under; 0 when no window is open. +var backgroundTaskGen atomic.Uint64 + +func isState(want keybase1.MobileAppState) func(keybase1.MobileAppState) bool { + return func(s keybase1.MobileAppState) bool { return s == want } +} + +// undoToBackground returns to BACKGROUND only if nothing has updated the +// app state since the owner's own transition at gen. +func undoToBackground(appState *libkb.MobileAppState, gen uint64, flush func()) (applied bool) { + if gen == 0 { + return false + } + _, applied, changed := appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUND) + if changed { + flush() + } + return applied +} + +// runBackgroundSyncWindow moves BACKGROUND to BACKGROUNDACTIVE for window, +// then undoes that transition unless someone else updated the state meanwhile. +func runBackgroundSyncWindow(appState *libkb.MobileAppState, window time.Duration, flush func()) string { + gen, applied, _ := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + isState(keybase1.MobileAppState_BACKGROUND)) + if !applied { + return "skipping, app not in background state: " + appState.State().String() + } + timer := time.NewTimer(window) + defer timer.Stop() + select { + case <-appState.NextUpdate(keybase1.MobileAppState_BACKGROUNDACTIVE): + return "bailing out early, appstate change: " + appState.State().String() + case <-timer.C: + if !undoToBackground(appState, gen, flush) { + return "completed window, app state updated meanwhile: " + appState.State().String() + } + return "completed window" + } +} + +// enterBackground applies the app's move to the background. When the app +// needs to keep running, it opens a BACKGROUNDACTIVE window for a background +// task and returns true; otherwise it moves to BACKGROUND. +func enterBackground(appState *libkb.MobileAppState, stayRunning bool, taskGen *atomic.Uint64, flush func()) bool { + if !stayRunning { + taskGen.Store(0) + updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUND, flush) + return false + } + gen, _, changed := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + func(keybase1.MobileAppState) bool { return true }) + taskGen.Store(gen) + if changed { + flush() + } + return true +} + +// beginPushWindow moves to BACKGROUNDACTIVE unless the app is in the +// foreground, and returns the generation of that transition, or 0 if the app +// is in the foreground. +func beginPushWindow(appState *libkb.MobileAppState) int64 { + gen, applied, _ := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + func(s keybase1.MobileAppState) bool { return s != keybase1.MobileAppState_FOREGROUND }) + if !applied { + return 0 + } + return int64(gen) +} + +// endPushWindow closes the window opened at token, only if nothing has updated +// the app state since. It returns true when it hands the window over to a +// background task (as enterBackground does), and false when it moved to +// BACKGROUND or someone else owns the state now. +func endPushWindow(appState *libkb.MobileAppState, token int64, stayRunning func() bool, + taskGen *atomic.Uint64, flush func(), +) bool { + if token <= 0 { + return false + } + gen := uint64(token) + if stayRunning() { + newGen, applied, _ := appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) + if !applied { + return false + } + taskGen.Store(newGen) + return true + } + undoToBackground(appState, gen, flush) + return false +} + +// expireBackgroundTask ends the background task window without clobbering a +// state reported after the window opened, such as a return to the foreground. +func expireBackgroundTask(appState *libkb.MobileAppState, taskGen *atomic.Uint64, flush func()) { + undoToBackground(appState, taskGen.Swap(0), flush) +} + +type backgroundTaskDeps struct { + activeDeliveries func(context.Context) ([]chat1.OutboxRecord, error) + nextFailure func() (chan []chat1.OutboxRecord, func()) + notifyFailure func([]chat1.OutboxRecord) + debug func(format string, args ...interface{}) + pollInterval time.Duration + maxDuration time.Duration +} + +// runBackgroundTask waits while the background task window opened by +// enterBackground is still current, until outgoing messages are delivered, +// one fails, or time runs out; then it returns to BACKGROUND unless someone +// else has updated the app state since the window opened. +func runBackgroundTask(ctx context.Context, appState *libkb.MobileAppState, taskGen *atomic.Uint64, + deps backgroundTaskDeps, flush func(), +) { + gen := taskGen.Load() + state, cur := appState.StateAndGeneration() + if state != keybase1.MobileAppState_BACKGROUNDACTIVE || gen == 0 || cur != gen { + deps.debug("AppBeginBackgroundTask: no background task window, early out: state: %v", state) + return + } + beginTime := libkb.ForceWallClock(time.Now()) + ticker := time.NewTicker(deps.pollInterval) + defer ticker.Stop() + var g *errgroup.Group + g, ctx = errgroup.WithContext(ctx) + g.Go(func() error { + select { + case <-appState.NextUpdate(state): + deps.debug("AppBeginBackgroundTask: app state change, aborting: %v", appState.State()) + return errors.New("app state change") + case <-ctx.Done(): + return ctx.Err() + } + }) + g.Go(func() error { + ch, cancel := deps.nextFailure() + defer cancel() + select { + case obrs := <-ch: + deps.debug("AppBeginBackgroundTask: failure received, alerting the user: %d marked", len(obrs)) + deps.notifyFailure(obrs) + return errors.New("failure received") + case <-ctx.Done(): + return ctx.Err() + } + }) + g.Go(func() error { + successCount := 0 + for { + select { + case <-ticker.C: + obrs, err := deps.activeDeliveries(ctx) + if err != nil { + deps.debug("AppBeginBackgroundTask: failed to query active deliveries: %s", err) + continue + } + if len(obrs) == 0 { + deps.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) >= deps.maxDuration { + deps.debug("AppBeginBackgroundTask: failed to deliver and time is up, aborting") + deps.notifyFailure(obrs) + return errors.New("time expired") + } + case <-ctx.Done(): + return ctx.Err() + } + } + }) + if err := g.Wait(); err != nil { + deps.debug("AppBeginBackgroundTask: dropped out of wait because: %s", err) + } + // A matching CAS also clears the window, so a later expiration is a no-op. + if taskGen.CompareAndSwap(gen, 0) { + undoToBackground(appState, gen, flush) + } +} diff --git a/go/bind/appstate_owners_test.go b/go/bind/appstate_owners_test.go new file mode 100644 index 000000000000..d638efbce5c3 --- /dev/null +++ b/go/bind/appstate_owners_test.go @@ -0,0 +1,462 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package keybase + +import ( + "context" + "math/rand" + "sync" + "sync/atomic" + "testing" + "time" + + "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" +) + +const ( + foreground = keybase1.MobileAppState_FOREGROUND + background = keybase1.MobileAppState_BACKGROUND + backgroundActive = keybase1.MobileAppState_BACKGROUNDACTIVE +) + +type flushCounter struct{ n atomic.Int32 } + +func (f *flushCounter) flush() { f.n.Add(1) } +func (f *flushCounter) count() int { return int(f.n.Load()) } + +func newTestAppState(t *testing.T, initial keybase1.MobileAppState) *libkb.MobileAppState { + tc := libkb.SetupTest(t, t.Name(), 0) + t.Cleanup(tc.Cleanup) + appState := libkb.NewMobileAppState(tc.G) + appState.Update(initial) + return appState +} + +// waitForGeneration blocks until the app state generation moves past gen. +func waitForGeneration(t *testing.T, appState *libkb.MobileAppState, gen uint64) { + t.Helper() + require.Eventually(t, func() bool { + _, cur := appState.StateAndGeneration() + return cur > gen + }, 5*time.Second, time.Millisecond) +} + +func TestBackgroundSyncWindowCompletes(t *testing.T) { + appState := newTestAppState(t, background) + var flushes flushCounter + msg := runBackgroundSyncWindow(appState, 10*time.Millisecond, flushes.flush) + require.Equal(t, "completed window", msg) + require.Equal(t, background, appState.State()) + require.Equal(t, 1, flushes.count()) +} + +func TestBackgroundSyncWindowSkipsOutsideBackground(t *testing.T) { + for _, initial := range []keybase1.MobileAppState{foreground, backgroundActive, keybase1.MobileAppState_INACTIVE} { + appState := newTestAppState(t, initial) + _, gen := appState.StateAndGeneration() + msg := runBackgroundSyncWindow(appState, time.Millisecond, func() {}) + require.Contains(t, msg, "skipping") + state, cur := appState.StateAndGeneration() + require.Equal(t, initial, state) + require.Equal(t, gen, cur) + } +} + +// iOS willEnterForeground reports BACKGROUNDACTIVE, the value the window +// already holds; the window must not return to BACKGROUND over it. +func TestBackgroundSyncWindowWillEnterForegroundMidWindow(t *testing.T) { + appState := newTestAppState(t, background) + _, gen := appState.StateAndGeneration() + var flushes flushCounter + done := make(chan string) + go func() { done <- runBackgroundSyncWindow(appState, 200*time.Millisecond, flushes.flush) }() + waitForGeneration(t, appState, gen) + appState.Update(backgroundActive) + + msg := <-done + require.Contains(t, msg, "updated meanwhile") + require.Equal(t, backgroundActive, appState.State()) + require.Equal(t, 0, flushes.count()) + + appState.Update(foreground) + require.Equal(t, foreground, appState.State()) +} + +func TestBackgroundSyncWindowForegroundMidWindow(t *testing.T) { + appState := newTestAppState(t, background) + _, gen := appState.StateAndGeneration() + done := make(chan string) + go func() { done <- runBackgroundSyncWindow(appState, time.Minute, func() {}) }() + waitForGeneration(t, appState, gen) + appState.Update(foreground) + require.Contains(t, <-done, "bailing out early") + require.Equal(t, foreground, appState.State()) +} + +type fakeDeliverer struct { + mu sync.Mutex + pending []chat1.OutboxRecord + failures chan []chat1.OutboxRecord + notified atomic.Int32 + polls atomic.Int32 +} + +func newFakeDeliverer(pending int) *fakeDeliverer { + return &fakeDeliverer{ + pending: make([]chat1.OutboxRecord, pending), + failures: make(chan []chat1.OutboxRecord, 1), + } +} + +func (f *fakeDeliverer) setPending(n int) { + f.mu.Lock() + defer f.mu.Unlock() + f.pending = make([]chat1.OutboxRecord, n) +} + +func (f *fakeDeliverer) deps(maxDuration time.Duration) backgroundTaskDeps { + return backgroundTaskDeps{ + activeDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { + f.polls.Add(1) + f.mu.Lock() + defer f.mu.Unlock() + return f.pending, nil + }, + nextFailure: func() (chan []chat1.OutboxRecord, func()) { return f.failures, func() {} }, + notifyFailure: func([]chat1.OutboxRecord) { f.notified.Add(1) }, + debug: func(string, ...interface{}) {}, + pollInterval: time.Millisecond, + maxDuration: maxDuration, + } +} + +func startBackgroundTask(appState *libkb.MobileAppState, taskGen *atomic.Uint64, d *fakeDeliverer, + maxDuration time.Duration, flush func(), +) chan struct{} { + done := make(chan struct{}) + go func() { + runBackgroundTask(context.Background(), appState, taskGen, d.deps(maxDuration), flush) + close(done) + }() + return done +} + +// waitForPolling blocks until the background task is past its window check. +func (f *fakeDeliverer) waitForPolling(t *testing.T) { + t.Helper() + require.Eventually(t, func() bool { return f.polls.Load() > 0 }, 5*time.Second, time.Millisecond) +} + +func requireDone(t *testing.T, done chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + require.Fail(t, "background task did not finish") + } +} + +func TestEnterBackground(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + var flushes flushCounter + + require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) + state, gen := appState.StateAndGeneration() + require.Equal(t, backgroundActive, state) + require.Equal(t, gen, taskGen.Load()) + require.Equal(t, 1, flushes.count()) + + require.False(t, enterBackground(appState, false, &taskGen, flushes.flush)) + require.Equal(t, background, appState.State()) + require.Zero(t, taskGen.Load()) + require.Equal(t, 2, flushes.count()) +} + +func TestBackgroundTaskReturnsToBackgroundWhenDelivered(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + var flushes flushCounter + require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) + + d := newFakeDeliverer(0) + requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, flushes.flush)) + require.Equal(t, background, appState.State()) + require.Equal(t, 2, flushes.count()) + require.Zero(t, taskGen.Load()) + require.Zero(t, d.notified.Load()) +} + +func TestBackgroundTaskReturnsToBackgroundOnFailure(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + require.True(t, enterBackground(appState, true, &taskGen, func() {})) + + d := newFakeDeliverer(1) + d.failures <- make([]chat1.OutboxRecord, 1) + requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, func() {})) + require.Equal(t, background, appState.State()) + require.Equal(t, int32(1), d.notified.Load()) +} + +func TestBackgroundTaskReturnsToBackgroundWhenTimeExpires(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + require.True(t, enterBackground(appState, true, &taskGen, func() {})) + + d := newFakeDeliverer(1) + requireDone(t, startBackgroundTask(appState, &taskGen, d, 20*time.Millisecond, func() {})) + require.Equal(t, background, appState.State()) + require.Equal(t, int32(1), d.notified.Load()) +} + +func TestBackgroundTaskExitsOnForeground(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + require.True(t, enterBackground(appState, true, &taskGen, func() {})) + + d := newFakeDeliverer(1) + done := startBackgroundTask(appState, &taskGen, d, time.Minute, func() {}) + d.waitForPolling(t) + appState.Update(foreground) + requireDone(t, done) + require.Equal(t, foreground, appState.State()) +} + +// willEnterForeground's same-value BACKGROUNDACTIVE doesn't wake the task; +// when deliveries finish afterwards, the task must leave the state alone. +func TestBackgroundTaskExitAfterWillEnterForeground(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + var flushes flushCounter + require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) + + d := newFakeDeliverer(1) + done := startBackgroundTask(appState, &taskGen, d, time.Minute, flushes.flush) + d.waitForPolling(t) + appState.Update(backgroundActive) + d.setPending(0) + requireDone(t, done) + require.Equal(t, backgroundActive, appState.State()) + require.Equal(t, 1, flushes.count()) + + appState.Update(foreground) + require.Equal(t, foreground, appState.State()) +} + +func TestBackgroundTaskWithoutWindowEarlyOut(t *testing.T) { + appState := newTestAppState(t, backgroundActive) + var taskGen atomic.Uint64 + d := newFakeDeliverer(0) + requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, func() {})) + require.Equal(t, backgroundActive, appState.State()) + + // A window whose generation was superseded is not the task's to close. + require.True(t, enterBackground(appState, true, &taskGen, func() {})) + appState.Update(backgroundActive) + requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, func() {})) + require.Equal(t, backgroundActive, appState.State()) +} + +func TestBackgroundTaskExpiredInWindow(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + var flushes flushCounter + require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) + + expireBackgroundTask(appState, &taskGen, flushes.flush) + require.Equal(t, background, appState.State()) + require.Equal(t, 2, flushes.count()) + require.Zero(t, taskGen.Load()) + + // A second expiration has no window to close. + appState.Update(foreground) + expireBackgroundTask(appState, &taskGen, flushes.flush) + require.Equal(t, foreground, appState.State()) +} + +func TestBackgroundTaskExpiredAfterForeground(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + var flushes flushCounter + require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) + + appState.Update(backgroundActive) // willEnterForeground + appState.Update(foreground) // didBecomeActive + expireBackgroundTask(appState, &taskGen, flushes.flush) + require.Equal(t, foreground, appState.State()) + require.Equal(t, 1, flushes.count()) +} + +// The expiration arriving after the task already closed its window must not +// close a window that isn't there. +func TestBackgroundTaskExpiredAfterTaskFinished(t *testing.T) { + appState := newTestAppState(t, foreground) + var taskGen atomic.Uint64 + require.True(t, enterBackground(appState, true, &taskGen, func() {})) + requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) + require.Equal(t, background, appState.State()) + + appState.Update(foreground) + expireBackgroundTask(appState, &taskGen, func() {}) + require.Equal(t, foreground, appState.State()) +} + +func TestPushWindow(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + var flushes flushCounter + stay := func(v bool) func() bool { return func() bool { return v } } + + token := beginPushWindow(appState) + require.Positive(t, token) + require.Equal(t, backgroundActive, appState.State()) + require.False(t, endPushWindow(appState, token, stay(false), &taskGen, flushes.flush)) + require.Equal(t, background, appState.State()) + require.Equal(t, 1, flushes.count()) + + appState.Update(foreground) + require.Zero(t, beginPushWindow(appState)) + require.Equal(t, foreground, appState.State()) + require.False(t, endPushWindow(appState, 0, stay(false), &taskGen, flushes.flush)) + require.False(t, endPushWindow(appState, -1, stay(false), &taskGen, flushes.flush)) + require.Equal(t, foreground, appState.State()) +} + +func TestPushWindowForegroundInBetween(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + var flushes flushCounter + + token := beginPushWindow(appState) + require.Positive(t, token) + appState.Update(foreground) + require.False(t, endPushWindow(appState, token, func() bool { return false }, &taskGen, flushes.flush)) + require.Equal(t, foreground, appState.State()) + require.Zero(t, flushes.count()) + + token = beginPushWindow(appState) + require.Zero(t, token) + + // A foreground and a return to the background in between: the window is + // no longer the push handler's, even though the value matches. + appState.Update(backgroundActive) + token = beginPushWindow(appState) + require.Positive(t, token) + appState.Update(foreground) + require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) + _, gen := appState.StateAndGeneration() + require.False(t, endPushWindow(appState, token, func() bool { return false }, &taskGen, flushes.flush)) + require.Equal(t, backgroundActive, appState.State()) + require.Equal(t, gen, taskGen.Load(), "the background task window stays open") + require.False(t, endPushWindow(appState, token, func() bool { return true }, &taskGen, flushes.flush)) + require.Equal(t, gen, taskGen.Load()) +} + +func TestPushWindowOverlapping(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + first := beginPushWindow(appState) + second := beginPushWindow(appState) + require.Positive(t, first) + require.Greater(t, second, first) + + require.False(t, endPushWindow(appState, first, func() bool { return false }, &taskGen, func() {})) + require.Equal(t, backgroundActive, appState.State(), "the later window is still open") + require.False(t, endPushWindow(appState, second, func() bool { return false }, &taskGen, func() {})) + require.Equal(t, background, appState.State()) +} + +func TestPushWindowHandsOverToBackgroundTask(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + token := beginPushWindow(appState) + require.True(t, endPushWindow(appState, token, func() bool { return true }, &taskGen, func() {})) + _, gen := appState.StateAndGeneration() + require.Equal(t, gen, taskGen.Load()) + require.Equal(t, backgroundActive, appState.State()) + + requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) + require.Equal(t, background, appState.State()) +} + +// Owners run concurrently with lifecycle events. Once the last lifecycle event +// is FOREGROUND and every owner has finished, no owner may have moved the app +// out of FOREGROUND. +func TestAppStateOwnersStress(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + flush := func() {} + const iterations = 300 + + 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 4 { + runOwner(func(r *rand.Rand) { + token := beginPushWindow(appState) + if r.Intn(2) == 0 { + time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) + } + if endPushWindow(appState, token, func() bool { return r.Intn(3) == 0 }, &taskGen, flush) { + runBackgroundTask(context.Background(), appState, &taskGen, + newFakeDeliverer(0).deps(time.Minute), flush) + } + }) + runOwner(func(r *rand.Rand) { + runBackgroundSyncWindow(appState, time.Duration(r.Intn(200))*time.Microsecond, flush) + }) + runOwner(func(*rand.Rand) { + expireBackgroundTask(appState, &taskGen, flush) + }) + } + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for range iterations { + switch r.Intn(5) { + case 0: + appState.Update(foreground) + case 1: + appState.Update(backgroundActive) + case 2: + appState.Update(keybase1.MobileAppState_INACTIVE) + case 3: + enterBackground(appState, r.Intn(2) == 0, &taskGen, flush) + case 4: + appState.Update(background) + } + time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) + } + appState.Update(foreground) + close(lifecycleDone) + + done := make(chan struct{}) + go func() { + owners.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(30 * time.Second): + require.Fail(t, "owners deadlocked") + } + require.Equal(t, foreground, appState.State()) +} diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 3769e82dbb0b..4ffcc9a5ccbc 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" @@ -969,39 +968,9 @@ 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" - } + msg := runBackgroundSyncWindow(kbCtx.MobileAppState, backgroundSyncWindowDuration, flushLocalDbs) + kbCtx.Log.Debug("BackgroundSync: %s", msg) + return msg } // pushPendingMessageFailure sends at most one notification that a message @@ -1027,51 +996,92 @@ func AppWillExit(pusher PushNotifier) { return } defer kbCtx.Trace("AppWillExit", nil)() - ctx := context.Background() - obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx) - 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) - } + notifyPendingMessageFailure(pusher) + backgroundTaskGen.Store(0) updateAppStateAndFlush(kbCtx.MobileAppState, 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 { +// AppBackgroundTaskExpired is called when the OS is about to suspend the app +// before the background task started by AppBeginBackgroundTask finished. It +// returns to BACKGROUND only if nothing has updated the app state since +// AppDidEnterBackground opened the window. +func AppBackgroundTaskExpired(pusher PushNotifier) { if !isInited() { - return false + return } - defer kbCtx.Trace("AppDidEnterBackground", nil)() - ctx := context.Background() + defer kbCtx.Trace("AppBackgroundTaskExpired", nil)() + notifyPendingMessageFailure(pusher) + expireBackgroundTask(kbCtx.MobileAppState, &backgroundTaskGen, flushLocalDbs) +} + +// 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 { + pushPendingMessageFailure(obrs, pusher) + } +} + +func shouldStayRunningInBackground(ctx context.Context) bool { 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") - // The OS may still kill us once the background task runs out. - updateAppStateAndFlush(kbCtx.MobileAppState, keybase1.MobileAppState_BACKGROUNDACTIVE, flushLocalDbs) + kbCtx.Log.Debug("shouldStayRunningInBackground: active coin flip games in progress") return true } - SetAppStateBackground() return false } +// 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)() + // The OS may still kill us once the background task runs out. + return enterBackground(kbCtx.MobileAppState, shouldStayRunningInBackground(context.Background()), + &backgroundTaskGen, flushLocalDbs) +} + +// AppPushWindowBegin moves the app to BACKGROUNDACTIVE while a push +// notification is handled, unless the app is in the foreground. It returns a +// token for AppPushWindowEnd: positive when the window opened, 0 when the app +// is in the foreground (skip the work), and -1 when the service isn't +// initialized (no window, but the work may still run). +func AppPushWindowBegin() int64 { + if !isInited() { + return -1 + } + defer kbCtx.Trace("AppPushWindowBegin", nil)() + return beginPushWindow(kbCtx.MobileAppState) +} + +// AppPushWindowEnd closes the window opened by AppPushWindowBegin, only if +// nothing has updated the app state since. It returns true when the caller +// should start AppBeginBackgroundTaskNonblock to keep running, as with +// AppDidEnterBackground. +func AppPushWindowEnd(token int64) bool { + if !isInited() { + return false + } + defer kbCtx.Trace("AppPushWindowEnd", nil)() + return endPushWindow(kbCtx.MobileAppState, token, func() bool { + return shouldStayRunningInBackground(context.Background()) + }, &backgroundTaskGen, flushLocalDbs) +} + func AppBeginBackgroundTaskNonblock(pusher PushNotifier) { if !isInited() { return @@ -1087,76 +1097,14 @@ func AppBeginBackgroundTask(pusher PushNotifier) { return } defer kbCtx.Trace("AppBeginBackgroundTask", nil)() - ctx := context.Background() - // Poll active deliveries in case we can shutdown early - beginTime := libkb.ForceWallClock(time.Now()) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - appState := kbCtx.MobileAppState.State() - if appState != keybase1.MobileAppState_BACKGROUNDACTIVE { - kbCtx.Log.Debug("AppBeginBackgroundTask: not in background mode, early out") - 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) - } + runBackgroundTask(context.Background(), kbCtx.MobileAppState, &backgroundTaskGen, backgroundTaskDeps{ + activeDeliveries: kbChatCtx.MessageDeliverer.ActiveDeliveries, + nextFailure: kbChatCtx.MessageDeliverer.NextFailure, + notifyFailure: func(obrs []chat1.OutboxRecord) { pushPendingMessageFailure(obrs, pusher) }, + debug: kbCtx.Log.Debug, + pollInterval: backgroundTaskPollInterval, + maxDuration: backgroundTaskMaxDuration, + }, flushLocalDbs) } func startTrace(logFile string) { diff --git a/go/chat/maps/bgactive.go b/go/chat/maps/bgactive.go new file mode 100644 index 000000000000..5de390372ef0 --- /dev/null +++ b/go/chat/maps/bgactive.go @@ -0,0 +1,31 @@ +package maps + +import ( + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" +) + +// backgroundActiveOwner records a BACKGROUND to BACKGROUNDACTIVE transition +// made for live location, so that tracking can undo exactly that transition. +// Callers serialize access. +type backgroundActiveOwner struct { + gen uint64 +} + +func (o *backgroundActiveOwner) claim(appState *libkb.MobileAppState) { + gen, applied, _ := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + func(s keybase1.MobileAppState) bool { return s == keybase1.MobileAppState_BACKGROUND }) + if applied { + o.gen = gen + } +} + +// release returns to BACKGROUND only if nothing has updated the app state +// since claim. +func (o *backgroundActiveOwner) release(appState *libkb.MobileAppState) { + if o.gen == 0 { + return + } + appState.UpdateIfGeneration(o.gen, keybase1.MobileAppState_BACKGROUND) + o.gen = 0 +} diff --git a/go/chat/maps/bgactive_test.go b/go/chat/maps/bgactive_test.go new file mode 100644 index 000000000000..f312e6bb502e --- /dev/null +++ b/go/chat/maps/bgactive_test.go @@ -0,0 +1,52 @@ +package maps + +import ( + "testing" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +func TestBackgroundActiveOwner(t *testing.T) { + tc := libkb.SetupTest(t, "BackgroundActiveOwner", 0) + defer tc.Cleanup() + appState := libkb.NewMobileAppState(tc.G) + var owner backgroundActiveOwner + + // Only a move out of BACKGROUND is claimed. + owner.claim(appState) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) + owner.release(appState) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) + + appState.Update(keybase1.MobileAppState_BACKGROUND) + owner.claim(appState) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + // Repeated location updates while already BACKGROUNDACTIVE keep the claim. + owner.claim(appState) + owner.release(appState) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) +} + +func TestBackgroundActiveOwnerReleaseAfterForeground(t *testing.T) { + tc := libkb.SetupTest(t, "BackgroundActiveOwnerForeground", 0) + defer tc.Cleanup() + appState := libkb.NewMobileAppState(tc.G) + var owner backgroundActiveOwner + + appState.Update(keybase1.MobileAppState_BACKGROUND) + owner.claim(appState) + appState.Update(keybase1.MobileAppState_FOREGROUND) + owner.release(appState) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) + + // Back in the background, someone else set BACKGROUNDACTIVE after the + // claim; ending tracking leaves it alone. + appState.Update(keybase1.MobileAppState_BACKGROUND) + owner.claim(appState) + appState.Update(keybase1.MobileAppState_FOREGROUND) + appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + owner.release(appState) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) +} diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 76e24d886e23..75babbb3333b 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -32,6 +32,7 @@ type LiveLocationTracker struct { trackers map[types.LiveLocationKey]*locationTrack lastCoord chat1.Coordinate maxCoords int + bgActive backgroundActiveOwner // testing only TestingCoordsAddedCh chan struct{} @@ -92,6 +93,14 @@ func (l *LiveLocationTracker) saveLocked(ctx context.Context) { } } +func (l *LiveLocationTracker) removeTrackerLocked(ctx context.Context, t *locationTrack) { + delete(l.trackers, t.Key()) + l.saveLocked(ctx) + if len(l.trackers) == 0 { + l.bgActive.release(l.G().MobileAppState) + } +} + func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { trackers, err := l.storage.Restore(ctx) if err != nil { @@ -260,8 +269,7 @@ func (l *LiveLocationTracker) tracker(t *locationTrack) error { if t.endTime.Before(l.clock.Now()) { l.Lock() defer l.Unlock() - delete(l.trackers, t.Key()) - l.saveLocked(ctx) + l.removeTrackerLocked(ctx, t) l.Debug(ctx, "tracker: old tracker, not running and clearing") return errors.New("tracker from the past") } @@ -279,8 +287,7 @@ func (l *LiveLocationTracker) tracker(t *locationTrack) error { } l.Lock() defer l.Unlock() - delete(l.trackers, t.Key()) - l.saveLocked(ctx) + l.removeTrackerLocked(ctx, t) }() // if this is a live location request, just put whatever the last coord is on the screen, makes it // feel more live @@ -373,14 +380,11 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co defer l.Trace(ctx, nil, "LocationUpdate")() l.Lock() defer l.Unlock() - if l.G().IsMobileAppType() { + if l.G().IsMobileAppType() && len(l.trackers) > 0 { // 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.bgActive.claim(l.G().MobileAppState) } if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 572e0310014e..5d4afb2101a1 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -109,17 +109,23 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo return true } +// UpdateWithCheck applies state only if check accepts the current state, +// evaluated under the same lock as the update. It returns the generation +// after the call, whether the update was applied, and whether the value +// changed. Owners keep newGen to undo their transition with +// UpdateIfGeneration. func (a *MobileAppState) UpdateWithCheck(state keybase1.MobileAppState, check func(keybase1.MobileAppState) bool, -) { +) (newGen uint64, applied bool, changed 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 { + if !check(a.state) { a.G().Log.Debug("MobileAppState.UpdateWithCheck: skipping update, failed check") + return a.generation, false, false } + changed = a.updateLocked(state) + return a.generation, true, changed } // Update sets the current app state and bumps the generation, even when state diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index 02a1256d3f2c..6b04690feedf 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -81,6 +81,28 @@ func TestMobileAppStateGeneration(t *testing.T) { require.Greater(t, newGen, gen3) } +func TestMobileAppStateUpdateWithCheck(t *testing.T) { + tc := SetupTest(t, "MobileAppStateUpdateWithCheck", 0) + defer tc.Cleanup() + a := NewMobileAppState(tc.G) + isBackground := func(s keybase1.MobileAppState) bool { return s == keybase1.MobileAppState_BACKGROUND } + + _, gen := a.StateAndGeneration() + newGen, applied, changed := a.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, isBackground) + require.False(t, applied) + require.False(t, changed) + require.Equal(t, gen, newGen) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, a.State()) + + a.Update(keybase1.MobileAppState_BACKGROUND) + newGen, applied, changed = a.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, isBackground) + require.True(t, applied) + require.True(t, changed) + state, cur := a.StateAndGeneration() + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) + require.Equal(t, cur, newGen) +} + func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) { tc := SetupTest(t, "MobileAppStateSideEffects", 0) defer tc.Cleanup() From 21bd13c1f56f72a2d76e0a12839cd02731067648 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 12:39:03 -0400 Subject: [PATCH 007/127] fix(appstate): record background task windows monotonically Concurrent window openers (Android's onPause and the push service) could record their generations out of order and strand BACKGROUNDACTIVE; the recorded generation now only rises. AppPushWindowEnd checks the token before querying deliveries, and an expired background task warns about pending messages only when its window was still open. Tests force the out-of-order recording, cover the live location claim and release through the tracker, and the stress test now ends phases in a background task window and in BACKGROUND and checks for leaked goroutines. --- go/bind/appstate_owners.go | 36 ++++- go/bind/appstate_owners_test.go | 248 ++++++++++++++++++++++++-------- go/bind/keybase.go | 9 +- go/chat/maps/bgactive_test.go | 48 +++++++ 4 files changed, 270 insertions(+), 71 deletions(-) diff --git a/go/bind/appstate_owners.go b/go/bind/appstate_owners.go index faad2dbc86e9..a440a9e93865 100644 --- a/go/bind/appstate_owners.go +++ b/go/bind/appstate_owners.go @@ -26,6 +26,25 @@ const ( // works under; 0 when no window is open. var backgroundTaskGen atomic.Uint64 +// testHookAfterWindowUpdate runs between opening a background task window and +// recording its generation. +var testHookAfterWindowUpdate func() + +// recordWindowGen raises taskGen to gen. Opening a window and recording it are +// separate steps, so concurrent openers can record out of order; only raising +// keeps the newest window recorded. +func recordWindowGen(taskGen *atomic.Uint64, gen uint64) { + if testHookAfterWindowUpdate != nil { + testHookAfterWindowUpdate() + } + for { + cur := taskGen.Load() + if cur >= gen || taskGen.CompareAndSwap(cur, gen) { + return + } + } +} + func isState(want keybase1.MobileAppState) func(keybase1.MobileAppState) bool { return func(s keybase1.MobileAppState) bool { return s == want } } @@ -75,7 +94,7 @@ func enterBackground(appState *libkb.MobileAppState, stayRunning bool, taskGen * } gen, _, changed := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, func(keybase1.MobileAppState) bool { return true }) - taskGen.Store(gen) + recordWindowGen(taskGen, gen) if changed { flush() } @@ -105,12 +124,15 @@ func endPushWindow(appState *libkb.MobileAppState, token int64, stayRunning func return false } gen := uint64(token) + if _, cur := appState.StateAndGeneration(); cur != gen { + return false + } if stayRunning() { newGen, applied, _ := appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) if !applied { return false } - taskGen.Store(newGen) + recordWindowGen(taskGen, newGen) return true } undoToBackground(appState, gen, flush) @@ -119,8 +141,14 @@ func endPushWindow(appState *libkb.MobileAppState, token int64, stayRunning func // expireBackgroundTask ends the background task window without clobbering a // state reported after the window opened, such as a return to the foreground. -func expireBackgroundTask(appState *libkb.MobileAppState, taskGen *atomic.Uint64, flush func()) { - undoToBackground(appState, taskGen.Swap(0), flush) +// notifyPending runs only when the window was still open, since otherwise we +// aren't about to be suspended. +func expireBackgroundTask(appState *libkb.MobileAppState, taskGen *atomic.Uint64, flush func(), + notifyPending func(), +) { + if undoToBackground(appState, taskGen.Swap(0), flush) { + notifyPending() + } } type backgroundTaskDeps struct { diff --git a/go/bind/appstate_owners_test.go b/go/bind/appstate_owners_test.go index d638efbce5c3..69f0494f464a 100644 --- a/go/bind/appstate_owners_test.go +++ b/go/bind/appstate_owners_test.go @@ -6,6 +6,8 @@ package keybase import ( "context" "math/rand" + "runtime" + "strings" "sync" "sync/atomic" "testing" @@ -29,7 +31,7 @@ func (f *flushCounter) flush() { f.n.Add(1) } func (f *flushCounter) count() int { return int(f.n.Load()) } func newTestAppState(t *testing.T, initial keybase1.MobileAppState) *libkb.MobileAppState { - tc := libkb.SetupTest(t, t.Name(), 0) + tc := libkb.SetupTest(t, strings.ReplaceAll(t.Name(), "/", "_"), 0) t.Cleanup(tc.Cleanup) appState := libkb.NewMobileAppState(tc.G) appState.Update(initial) @@ -265,31 +267,34 @@ func TestBackgroundTaskWithoutWindowEarlyOut(t *testing.T) { func TestBackgroundTaskExpiredInWindow(t *testing.T) { appState := newTestAppState(t, foreground) var taskGen atomic.Uint64 - var flushes flushCounter + var flushes, notified flushCounter require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - expireBackgroundTask(appState, &taskGen, flushes.flush) + expireBackgroundTask(appState, &taskGen, flushes.flush, notified.flush) require.Equal(t, background, appState.State()) require.Equal(t, 2, flushes.count()) + require.Equal(t, 1, notified.count()) require.Zero(t, taskGen.Load()) // A second expiration has no window to close. appState.Update(foreground) - expireBackgroundTask(appState, &taskGen, flushes.flush) + expireBackgroundTask(appState, &taskGen, flushes.flush, notified.flush) require.Equal(t, foreground, appState.State()) + require.Equal(t, 1, notified.count()) } func TestBackgroundTaskExpiredAfterForeground(t *testing.T) { appState := newTestAppState(t, foreground) var taskGen atomic.Uint64 - var flushes flushCounter + var flushes, notified flushCounter require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) appState.Update(backgroundActive) // willEnterForeground appState.Update(foreground) // didBecomeActive - expireBackgroundTask(appState, &taskGen, flushes.flush) + expireBackgroundTask(appState, &taskGen, flushes.flush, notified.flush) require.Equal(t, foreground, appState.State()) require.Equal(t, 1, flushes.count()) + require.Zero(t, notified.count(), "no pending-message warning while in the foreground") } // The expiration arriving after the task already closed its window must not @@ -297,13 +302,60 @@ func TestBackgroundTaskExpiredAfterForeground(t *testing.T) { func TestBackgroundTaskExpiredAfterTaskFinished(t *testing.T) { appState := newTestAppState(t, foreground) var taskGen atomic.Uint64 + var notified flushCounter require.True(t, enterBackground(appState, true, &taskGen, func() {})) requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) require.Equal(t, background, appState.State()) appState.Update(foreground) - expireBackgroundTask(appState, &taskGen, func() {}) + expireBackgroundTask(appState, &taskGen, func() {}, notified.flush) require.Equal(t, foreground, appState.State()) + require.Zero(t, notified.count()) +} + +// Two windows open concurrently and record their generations in the opposite +// order; the newer window must stay recorded so its task can close it. +func TestBackgroundTaskWindowsRecordedOutOfOrder(t *testing.T) { + stay := func() bool { return true } + openers := map[string]func(*libkb.MobileAppState, *atomic.Uint64){ + "enterBackground": func(appState *libkb.MobileAppState, taskGen *atomic.Uint64) { + enterBackground(appState, true, taskGen, func() {}) + }, + "endPushWindow": func(appState *libkb.MobileAppState, taskGen *atomic.Uint64) { + endPushWindow(appState, beginPushWindow(appState), stay, taskGen, func() {}) + }, + } + for name, openFirst := range openers { + t.Run(name, func(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + paused := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Int32 + testHookAfterWindowUpdate = func() { + if calls.Add(1) == 1 { + close(paused) + <-release + } + } + t.Cleanup(func() { testHookAfterWindowUpdate = nil }) + + firstDone := make(chan struct{}) + go func() { + openFirst(appState, &taskGen) + close(firstDone) + }() + <-paused + require.True(t, enterBackground(appState, true, &taskGen, func() {})) + _, newest := appState.StateAndGeneration() + close(release) + requireDone(t, firstDone) + require.Equal(t, newest, taskGen.Load()) + + requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) + require.Equal(t, background, appState.State()) + }) + } } func TestPushWindow(t *testing.T) { @@ -357,6 +409,20 @@ func TestPushWindowForegroundInBetween(t *testing.T) { require.Equal(t, gen, taskGen.Load()) } +func TestPushWindowEndStaleTokenSkipsStayRunning(t *testing.T) { + appState := newTestAppState(t, background) + var taskGen atomic.Uint64 + token := beginPushWindow(appState) + appState.Update(foreground) + called := false + require.False(t, endPushWindow(appState, token, func() bool { + called = true + return true + }, &taskGen, func() {})) + require.False(t, called) + require.Equal(t, foreground, appState.State()) +} + func TestPushWindowOverlapping(t *testing.T) { appState := newTestAppState(t, background) var taskGen atomic.Uint64 @@ -384,79 +450,135 @@ func TestPushWindowHandsOverToBackgroundTask(t *testing.T) { require.Equal(t, background, appState.State()) } -// Owners run concurrently with lifecycle events. Once the last lifecycle event -// is FOREGROUND and every owner has finished, no owner may have moved the app -// out of FOREGROUND. +// Owners run concurrently with lifecycle events, then each phase ends on a +// known last event and checks nothing is left stuck: FOREGROUND stays +// FOREGROUND, a background task window closes to BACKGROUND, and a plain +// BACKGROUND stays BACKGROUND. Owner goroutines must all exit. func TestAppStateOwnersStress(t *testing.T) { appState := newTestAppState(t, background) var taskGen atomic.Uint64 flush := func() {} - const iterations = 300 - - 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()) + notify := func() {} + // Widen the gap between opening a window and recording it, where a + // competing opener can slip in. + testHookAfterWindowUpdate = func() { + if rand.Intn(2) == 0 { + time.Sleep(time.Duration(rand.Intn(200)) * time.Microsecond) + } } - for range 4 { - runOwner(func(r *rand.Rand) { - token := beginPushWindow(appState) - if r.Intn(2) == 0 { - time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) - } - if endPushWindow(appState, token, func() bool { return r.Intn(3) == 0 }, &taskGen, flush) { - runBackgroundTask(context.Background(), appState, &taskGen, - newFakeDeliverer(0).deps(time.Minute), flush) + t.Cleanup(func() { testHookAfterWindowUpdate = nil }) + 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 4 { + runOwner(func(r *rand.Rand) { + token := beginPushWindow(appState) + if r.Intn(2) == 0 { + time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) + } + if endPushWindow(appState, token, func() bool { return r.Intn(3) == 0 }, &taskGen, flush) { + runBackgroundTask(context.Background(), appState, &taskGen, + newFakeDeliverer(0).deps(time.Minute), flush) + } + }) + runOwner(func(r *rand.Rand) { + runBackgroundSyncWindow(appState, time.Duration(r.Intn(200))*time.Microsecond, flush) + }) + runOwner(func(*rand.Rand) { + expireBackgroundTask(appState, &taskGen, flush, notify) + }) + } + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for range iterations { + switch r.Intn(5) { + case 0: + appState.Update(foreground) + case 1: + appState.Update(backgroundActive) + case 2: + appState.Update(keybase1.MobileAppState_INACTIVE) + case 3: + enterBackground(appState, r.Intn(2) == 0, &taskGen, flush) + case 4: + appState.Update(background) } - }) - runOwner(func(r *rand.Rand) { - runBackgroundSyncWindow(appState, time.Duration(r.Intn(200))*time.Microsecond, flush) - }) - runOwner(func(*rand.Rand) { - expireBackgroundTask(appState, &taskGen, flush) - }) + time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) + } + appState.Update(foreground) + close(lifecycleDone) + waitGroupWithin(t, &owners, "owners deadlocked") + require.Equal(t, foreground, appState.State()) } - r := rand.New(rand.NewSource(time.Now().UnixNano())) - for range iterations { - switch r.Intn(5) { - case 0: - appState.Update(foreground) - case 1: - appState.Update(backgroundActive) - case 2: - appState.Update(keybase1.MobileAppState_INACTIVE) - case 3: - enterBackground(appState, r.Intn(2) == 0, &taskGen, flush) - case 4: - appState.Update(background) + t.Run("ends in foreground", func(t *testing.T) { + chaos(t, 300) + }) + + // Android's onPause and the push service both open a window and start a + // task; the newest window must close once the tasks are done. + t.Run("ends in background task", func(t *testing.T) { + for range 50 { + chaos(t, 20) + var tasks sync.WaitGroup + for range 4 { + tasks.Add(1) + go func() { + defer tasks.Done() + if enterBackground(appState, true, &taskGen, flush) { + runBackgroundTask(context.Background(), appState, &taskGen, + newFakeDeliverer(0).deps(time.Minute), flush) + } + }() + } + waitGroupWithin(t, &tasks, "background tasks deadlocked") + require.Equal(t, background, appState.State()) } - time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) + }) + + t.Run("ends in background", func(t *testing.T) { + chaos(t, 300) + appState.Update(background) + require.Equal(t, background, appState.State()) + }) + + // 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() } - appState.Update(foreground) - close(lifecycleDone) + 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() { - owners.Wait() + wg.Wait() close(done) }() select { case <-done: case <-time.After(30 * time.Second): - require.Fail(t, "owners deadlocked") + require.Fail(t, msg) } - require.Equal(t, foreground, appState.State()) } diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 4ffcc9a5ccbc..b04c4e1c3f28 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -1003,15 +1003,16 @@ func AppWillExit(pusher PushNotifier) { // AppBackgroundTaskExpired is called when the OS is about to suspend the app // before the background task started by AppBeginBackgroundTask finished. It -// returns to BACKGROUND only if nothing has updated the app state since -// AppDidEnterBackground opened the window. +// returns to BACKGROUND, and warns about messages still waiting to send, only +// if nothing has updated the app state since the window opened. func AppBackgroundTaskExpired(pusher PushNotifier) { if !isInited() { return } defer kbCtx.Trace("AppBackgroundTaskExpired", nil)() - notifyPendingMessageFailure(pusher) - expireBackgroundTask(kbCtx.MobileAppState, &backgroundTaskGen, flushLocalDbs) + expireBackgroundTask(kbCtx.MobileAppState, &backgroundTaskGen, flushLocalDbs, func() { + notifyPendingMessageFailure(pusher) + }) } // notifyPendingMessageFailure warns the user that messages still waiting to diff --git a/go/chat/maps/bgactive_test.go b/go/chat/maps/bgactive_test.go index f312e6bb502e..9043f9dcfb25 100644 --- a/go/chat/maps/bgactive_test.go +++ b/go/chat/maps/bgactive_test.go @@ -1,7 +1,12 @@ 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/protocol/keybase1" @@ -50,3 +55,46 @@ func TestBackgroundActiveOwnerReleaseAfterForeground(t *testing.T) { owner.release(appState) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) } + +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) + } + + appState.Update(keybase1.MobileAppState_BACKGROUND) + l.LocationUpdate(ctx, coord(1)) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "no trackers, no claim") + + 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 foreground while tracking leaves the state to the foreground. + third := addTracker(3) + l.LocationUpdate(ctx, coord(3)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + appState.Update(keybase1.MobileAppState_FOREGROUND) + removeTracker(third) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) +} From 0201f9a9206336395d3b79023f56dcd88a8279e2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:00:55 -0400 Subject: [PATCH 008/127] refactor(appstate): drive app state from lifecycle events in a testable controller Native lifecycle events go through libkb/lifecycle.Controller, which owns the event to state mapping, owner generations, flushes and an injected clock. Bind entry points and live location become one-line adapters. Adds lifecycletest with a state recorder and replayable iOS/Android scenarios. --- go/bind/appstate_owners.go | 239 ------- go/bind/appstate_owners_test.go | 584 ------------------ go/bind/appstate_test.go | 36 -- go/bind/keybase.go | 107 ++-- go/chat/maps/bgactive.go | 31 - go/chat/maps/livelocation.go | 5 +- ..._test.go => livelocation_appstate_test.go} | 43 -- go/libkb/appstate.go | 25 + go/libkb/globals.go | 6 + go/libkb/lifecycle/controller_test.go | 289 +++++++++ go/libkb/lifecycle/export_test.go | 8 + go/libkb/lifecycle/lifecycle.go | 409 ++++++++++++ go/libkb/lifecycle/lifecycletest/clock.go | 72 +++ go/libkb/lifecycle/lifecycletest/harness.go | 384 ++++++++++++ go/libkb/lifecycle/lifecycletest/recorder.go | 111 ++++ go/libkb/lifecycle/lifecycletest/scenarios.go | 429 +++++++++++++ go/libkb/lifecycle/scenario_test.go | 102 +++ 17 files changed, 1883 insertions(+), 997 deletions(-) delete mode 100644 go/bind/appstate_owners.go delete mode 100644 go/bind/appstate_owners_test.go delete mode 100644 go/bind/appstate_test.go delete mode 100644 go/chat/maps/bgactive.go rename go/chat/maps/{bgactive_test.go => livelocation_appstate_test.go} (54%) create mode 100644 go/libkb/lifecycle/controller_test.go create mode 100644 go/libkb/lifecycle/export_test.go create mode 100644 go/libkb/lifecycle/lifecycle.go create mode 100644 go/libkb/lifecycle/lifecycletest/clock.go create mode 100644 go/libkb/lifecycle/lifecycletest/harness.go create mode 100644 go/libkb/lifecycle/lifecycletest/recorder.go create mode 100644 go/libkb/lifecycle/lifecycletest/scenarios.go create mode 100644 go/libkb/lifecycle/scenario_test.go diff --git a/go/bind/appstate_owners.go b/go/bind/appstate_owners.go deleted file mode 100644 index a440a9e93865..000000000000 --- a/go/bind/appstate_owners.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright 2026 Keybase, Inc. All rights reserved. Use of -// this source code is governed by the included BSD license. - -package keybase - -import ( - "context" - "errors" - "sync/atomic" - "time" - - "github.com/keybase/client/go/libkb" - "github.com/keybase/client/go/protocol/chat1" - "github.com/keybase/client/go/protocol/keybase1" - "golang.org/x/sync/errgroup" -) - -const ( - backgroundSyncWindowDuration = 10 * time.Second - backgroundTaskPollInterval = 5 * time.Second - backgroundTaskMaxDuration = 10 * time.Minute -) - -// backgroundTaskGen is the generation at which the app entered the -// BACKGROUNDACTIVE window that a background task (AppBeginBackgroundTask) -// works under; 0 when no window is open. -var backgroundTaskGen atomic.Uint64 - -// testHookAfterWindowUpdate runs between opening a background task window and -// recording its generation. -var testHookAfterWindowUpdate func() - -// recordWindowGen raises taskGen to gen. Opening a window and recording it are -// separate steps, so concurrent openers can record out of order; only raising -// keeps the newest window recorded. -func recordWindowGen(taskGen *atomic.Uint64, gen uint64) { - if testHookAfterWindowUpdate != nil { - testHookAfterWindowUpdate() - } - for { - cur := taskGen.Load() - if cur >= gen || taskGen.CompareAndSwap(cur, gen) { - return - } - } -} - -func isState(want keybase1.MobileAppState) func(keybase1.MobileAppState) bool { - return func(s keybase1.MobileAppState) bool { return s == want } -} - -// undoToBackground returns to BACKGROUND only if nothing has updated the -// app state since the owner's own transition at gen. -func undoToBackground(appState *libkb.MobileAppState, gen uint64, flush func()) (applied bool) { - if gen == 0 { - return false - } - _, applied, changed := appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUND) - if changed { - flush() - } - return applied -} - -// runBackgroundSyncWindow moves BACKGROUND to BACKGROUNDACTIVE for window, -// then undoes that transition unless someone else updated the state meanwhile. -func runBackgroundSyncWindow(appState *libkb.MobileAppState, window time.Duration, flush func()) string { - gen, applied, _ := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - isState(keybase1.MobileAppState_BACKGROUND)) - if !applied { - return "skipping, app not in background state: " + appState.State().String() - } - timer := time.NewTimer(window) - defer timer.Stop() - select { - case <-appState.NextUpdate(keybase1.MobileAppState_BACKGROUNDACTIVE): - return "bailing out early, appstate change: " + appState.State().String() - case <-timer.C: - if !undoToBackground(appState, gen, flush) { - return "completed window, app state updated meanwhile: " + appState.State().String() - } - return "completed window" - } -} - -// enterBackground applies the app's move to the background. When the app -// needs to keep running, it opens a BACKGROUNDACTIVE window for a background -// task and returns true; otherwise it moves to BACKGROUND. -func enterBackground(appState *libkb.MobileAppState, stayRunning bool, taskGen *atomic.Uint64, flush func()) bool { - if !stayRunning { - taskGen.Store(0) - updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUND, flush) - return false - } - gen, _, changed := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - func(keybase1.MobileAppState) bool { return true }) - recordWindowGen(taskGen, gen) - if changed { - flush() - } - return true -} - -// beginPushWindow moves to BACKGROUNDACTIVE unless the app is in the -// foreground, and returns the generation of that transition, or 0 if the app -// is in the foreground. -func beginPushWindow(appState *libkb.MobileAppState) int64 { - gen, applied, _ := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - func(s keybase1.MobileAppState) bool { return s != keybase1.MobileAppState_FOREGROUND }) - if !applied { - return 0 - } - return int64(gen) -} - -// endPushWindow closes the window opened at token, only if nothing has updated -// the app state since. It returns true when it hands the window over to a -// background task (as enterBackground does), and false when it moved to -// BACKGROUND or someone else owns the state now. -func endPushWindow(appState *libkb.MobileAppState, token int64, stayRunning func() bool, - taskGen *atomic.Uint64, flush func(), -) bool { - if token <= 0 { - return false - } - gen := uint64(token) - if _, cur := appState.StateAndGeneration(); cur != gen { - return false - } - if stayRunning() { - newGen, applied, _ := appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) - if !applied { - return false - } - recordWindowGen(taskGen, newGen) - return true - } - undoToBackground(appState, gen, flush) - return false -} - -// expireBackgroundTask ends the background task window without clobbering a -// state reported after the window opened, such as a return to the foreground. -// notifyPending runs only when the window was still open, since otherwise we -// aren't about to be suspended. -func expireBackgroundTask(appState *libkb.MobileAppState, taskGen *atomic.Uint64, flush func(), - notifyPending func(), -) { - if undoToBackground(appState, taskGen.Swap(0), flush) { - notifyPending() - } -} - -type backgroundTaskDeps struct { - activeDeliveries func(context.Context) ([]chat1.OutboxRecord, error) - nextFailure func() (chan []chat1.OutboxRecord, func()) - notifyFailure func([]chat1.OutboxRecord) - debug func(format string, args ...interface{}) - pollInterval time.Duration - maxDuration time.Duration -} - -// runBackgroundTask waits while the background task window opened by -// enterBackground is still current, until outgoing messages are delivered, -// one fails, or time runs out; then it returns to BACKGROUND unless someone -// else has updated the app state since the window opened. -func runBackgroundTask(ctx context.Context, appState *libkb.MobileAppState, taskGen *atomic.Uint64, - deps backgroundTaskDeps, flush func(), -) { - gen := taskGen.Load() - state, cur := appState.StateAndGeneration() - if state != keybase1.MobileAppState_BACKGROUNDACTIVE || gen == 0 || cur != gen { - deps.debug("AppBeginBackgroundTask: no background task window, early out: state: %v", state) - return - } - beginTime := libkb.ForceWallClock(time.Now()) - ticker := time.NewTicker(deps.pollInterval) - defer ticker.Stop() - var g *errgroup.Group - g, ctx = errgroup.WithContext(ctx) - g.Go(func() error { - select { - case <-appState.NextUpdate(state): - deps.debug("AppBeginBackgroundTask: app state change, aborting: %v", appState.State()) - return errors.New("app state change") - case <-ctx.Done(): - return ctx.Err() - } - }) - g.Go(func() error { - ch, cancel := deps.nextFailure() - defer cancel() - select { - case obrs := <-ch: - deps.debug("AppBeginBackgroundTask: failure received, alerting the user: %d marked", len(obrs)) - deps.notifyFailure(obrs) - return errors.New("failure received") - case <-ctx.Done(): - return ctx.Err() - } - }) - g.Go(func() error { - successCount := 0 - for { - select { - case <-ticker.C: - obrs, err := deps.activeDeliveries(ctx) - if err != nil { - deps.debug("AppBeginBackgroundTask: failed to query active deliveries: %s", err) - continue - } - if len(obrs) == 0 { - deps.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) >= deps.maxDuration { - deps.debug("AppBeginBackgroundTask: failed to deliver and time is up, aborting") - deps.notifyFailure(obrs) - return errors.New("time expired") - } - case <-ctx.Done(): - return ctx.Err() - } - } - }) - if err := g.Wait(); err != nil { - deps.debug("AppBeginBackgroundTask: dropped out of wait because: %s", err) - } - // A matching CAS also clears the window, so a later expiration is a no-op. - if taskGen.CompareAndSwap(gen, 0) { - undoToBackground(appState, gen, flush) - } -} diff --git a/go/bind/appstate_owners_test.go b/go/bind/appstate_owners_test.go deleted file mode 100644 index 69f0494f464a..000000000000 --- a/go/bind/appstate_owners_test.go +++ /dev/null @@ -1,584 +0,0 @@ -// Copyright 2026 Keybase, Inc. All rights reserved. Use of -// this source code is governed by the included BSD license. - -package keybase - -import ( - "context" - "math/rand" - "runtime" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "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" -) - -const ( - foreground = keybase1.MobileAppState_FOREGROUND - background = keybase1.MobileAppState_BACKGROUND - backgroundActive = keybase1.MobileAppState_BACKGROUNDACTIVE -) - -type flushCounter struct{ n atomic.Int32 } - -func (f *flushCounter) flush() { f.n.Add(1) } -func (f *flushCounter) count() int { return int(f.n.Load()) } - -func newTestAppState(t *testing.T, initial keybase1.MobileAppState) *libkb.MobileAppState { - tc := libkb.SetupTest(t, strings.ReplaceAll(t.Name(), "/", "_"), 0) - t.Cleanup(tc.Cleanup) - appState := libkb.NewMobileAppState(tc.G) - appState.Update(initial) - return appState -} - -// waitForGeneration blocks until the app state generation moves past gen. -func waitForGeneration(t *testing.T, appState *libkb.MobileAppState, gen uint64) { - t.Helper() - require.Eventually(t, func() bool { - _, cur := appState.StateAndGeneration() - return cur > gen - }, 5*time.Second, time.Millisecond) -} - -func TestBackgroundSyncWindowCompletes(t *testing.T) { - appState := newTestAppState(t, background) - var flushes flushCounter - msg := runBackgroundSyncWindow(appState, 10*time.Millisecond, flushes.flush) - require.Equal(t, "completed window", msg) - require.Equal(t, background, appState.State()) - require.Equal(t, 1, flushes.count()) -} - -func TestBackgroundSyncWindowSkipsOutsideBackground(t *testing.T) { - for _, initial := range []keybase1.MobileAppState{foreground, backgroundActive, keybase1.MobileAppState_INACTIVE} { - appState := newTestAppState(t, initial) - _, gen := appState.StateAndGeneration() - msg := runBackgroundSyncWindow(appState, time.Millisecond, func() {}) - require.Contains(t, msg, "skipping") - state, cur := appState.StateAndGeneration() - require.Equal(t, initial, state) - require.Equal(t, gen, cur) - } -} - -// iOS willEnterForeground reports BACKGROUNDACTIVE, the value the window -// already holds; the window must not return to BACKGROUND over it. -func TestBackgroundSyncWindowWillEnterForegroundMidWindow(t *testing.T) { - appState := newTestAppState(t, background) - _, gen := appState.StateAndGeneration() - var flushes flushCounter - done := make(chan string) - go func() { done <- runBackgroundSyncWindow(appState, 200*time.Millisecond, flushes.flush) }() - waitForGeneration(t, appState, gen) - appState.Update(backgroundActive) - - msg := <-done - require.Contains(t, msg, "updated meanwhile") - require.Equal(t, backgroundActive, appState.State()) - require.Equal(t, 0, flushes.count()) - - appState.Update(foreground) - require.Equal(t, foreground, appState.State()) -} - -func TestBackgroundSyncWindowForegroundMidWindow(t *testing.T) { - appState := newTestAppState(t, background) - _, gen := appState.StateAndGeneration() - done := make(chan string) - go func() { done <- runBackgroundSyncWindow(appState, time.Minute, func() {}) }() - waitForGeneration(t, appState, gen) - appState.Update(foreground) - require.Contains(t, <-done, "bailing out early") - require.Equal(t, foreground, appState.State()) -} - -type fakeDeliverer struct { - mu sync.Mutex - pending []chat1.OutboxRecord - failures chan []chat1.OutboxRecord - notified atomic.Int32 - polls atomic.Int32 -} - -func newFakeDeliverer(pending int) *fakeDeliverer { - return &fakeDeliverer{ - pending: make([]chat1.OutboxRecord, pending), - failures: make(chan []chat1.OutboxRecord, 1), - } -} - -func (f *fakeDeliverer) setPending(n int) { - f.mu.Lock() - defer f.mu.Unlock() - f.pending = make([]chat1.OutboxRecord, n) -} - -func (f *fakeDeliverer) deps(maxDuration time.Duration) backgroundTaskDeps { - return backgroundTaskDeps{ - activeDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { - f.polls.Add(1) - f.mu.Lock() - defer f.mu.Unlock() - return f.pending, nil - }, - nextFailure: func() (chan []chat1.OutboxRecord, func()) { return f.failures, func() {} }, - notifyFailure: func([]chat1.OutboxRecord) { f.notified.Add(1) }, - debug: func(string, ...interface{}) {}, - pollInterval: time.Millisecond, - maxDuration: maxDuration, - } -} - -func startBackgroundTask(appState *libkb.MobileAppState, taskGen *atomic.Uint64, d *fakeDeliverer, - maxDuration time.Duration, flush func(), -) chan struct{} { - done := make(chan struct{}) - go func() { - runBackgroundTask(context.Background(), appState, taskGen, d.deps(maxDuration), flush) - close(done) - }() - return done -} - -// waitForPolling blocks until the background task is past its window check. -func (f *fakeDeliverer) waitForPolling(t *testing.T) { - t.Helper() - require.Eventually(t, func() bool { return f.polls.Load() > 0 }, 5*time.Second, time.Millisecond) -} - -func requireDone(t *testing.T, done chan struct{}) { - t.Helper() - select { - case <-done: - case <-time.After(5 * time.Second): - require.Fail(t, "background task did not finish") - } -} - -func TestEnterBackground(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - var flushes flushCounter - - require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - state, gen := appState.StateAndGeneration() - require.Equal(t, backgroundActive, state) - require.Equal(t, gen, taskGen.Load()) - require.Equal(t, 1, flushes.count()) - - require.False(t, enterBackground(appState, false, &taskGen, flushes.flush)) - require.Equal(t, background, appState.State()) - require.Zero(t, taskGen.Load()) - require.Equal(t, 2, flushes.count()) -} - -func TestBackgroundTaskReturnsToBackgroundWhenDelivered(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - var flushes flushCounter - require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - - d := newFakeDeliverer(0) - requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, flushes.flush)) - require.Equal(t, background, appState.State()) - require.Equal(t, 2, flushes.count()) - require.Zero(t, taskGen.Load()) - require.Zero(t, d.notified.Load()) -} - -func TestBackgroundTaskReturnsToBackgroundOnFailure(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - require.True(t, enterBackground(appState, true, &taskGen, func() {})) - - d := newFakeDeliverer(1) - d.failures <- make([]chat1.OutboxRecord, 1) - requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, func() {})) - require.Equal(t, background, appState.State()) - require.Equal(t, int32(1), d.notified.Load()) -} - -func TestBackgroundTaskReturnsToBackgroundWhenTimeExpires(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - require.True(t, enterBackground(appState, true, &taskGen, func() {})) - - d := newFakeDeliverer(1) - requireDone(t, startBackgroundTask(appState, &taskGen, d, 20*time.Millisecond, func() {})) - require.Equal(t, background, appState.State()) - require.Equal(t, int32(1), d.notified.Load()) -} - -func TestBackgroundTaskExitsOnForeground(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - require.True(t, enterBackground(appState, true, &taskGen, func() {})) - - d := newFakeDeliverer(1) - done := startBackgroundTask(appState, &taskGen, d, time.Minute, func() {}) - d.waitForPolling(t) - appState.Update(foreground) - requireDone(t, done) - require.Equal(t, foreground, appState.State()) -} - -// willEnterForeground's same-value BACKGROUNDACTIVE doesn't wake the task; -// when deliveries finish afterwards, the task must leave the state alone. -func TestBackgroundTaskExitAfterWillEnterForeground(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - var flushes flushCounter - require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - - d := newFakeDeliverer(1) - done := startBackgroundTask(appState, &taskGen, d, time.Minute, flushes.flush) - d.waitForPolling(t) - appState.Update(backgroundActive) - d.setPending(0) - requireDone(t, done) - require.Equal(t, backgroundActive, appState.State()) - require.Equal(t, 1, flushes.count()) - - appState.Update(foreground) - require.Equal(t, foreground, appState.State()) -} - -func TestBackgroundTaskWithoutWindowEarlyOut(t *testing.T) { - appState := newTestAppState(t, backgroundActive) - var taskGen atomic.Uint64 - d := newFakeDeliverer(0) - requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, func() {})) - require.Equal(t, backgroundActive, appState.State()) - - // A window whose generation was superseded is not the task's to close. - require.True(t, enterBackground(appState, true, &taskGen, func() {})) - appState.Update(backgroundActive) - requireDone(t, startBackgroundTask(appState, &taskGen, d, time.Minute, func() {})) - require.Equal(t, backgroundActive, appState.State()) -} - -func TestBackgroundTaskExpiredInWindow(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - var flushes, notified flushCounter - require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - - expireBackgroundTask(appState, &taskGen, flushes.flush, notified.flush) - require.Equal(t, background, appState.State()) - require.Equal(t, 2, flushes.count()) - require.Equal(t, 1, notified.count()) - require.Zero(t, taskGen.Load()) - - // A second expiration has no window to close. - appState.Update(foreground) - expireBackgroundTask(appState, &taskGen, flushes.flush, notified.flush) - require.Equal(t, foreground, appState.State()) - require.Equal(t, 1, notified.count()) -} - -func TestBackgroundTaskExpiredAfterForeground(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - var flushes, notified flushCounter - require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - - appState.Update(backgroundActive) // willEnterForeground - appState.Update(foreground) // didBecomeActive - expireBackgroundTask(appState, &taskGen, flushes.flush, notified.flush) - require.Equal(t, foreground, appState.State()) - require.Equal(t, 1, flushes.count()) - require.Zero(t, notified.count(), "no pending-message warning while in the foreground") -} - -// The expiration arriving after the task already closed its window must not -// close a window that isn't there. -func TestBackgroundTaskExpiredAfterTaskFinished(t *testing.T) { - appState := newTestAppState(t, foreground) - var taskGen atomic.Uint64 - var notified flushCounter - require.True(t, enterBackground(appState, true, &taskGen, func() {})) - requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) - require.Equal(t, background, appState.State()) - - appState.Update(foreground) - expireBackgroundTask(appState, &taskGen, func() {}, notified.flush) - require.Equal(t, foreground, appState.State()) - require.Zero(t, notified.count()) -} - -// Two windows open concurrently and record their generations in the opposite -// order; the newer window must stay recorded so its task can close it. -func TestBackgroundTaskWindowsRecordedOutOfOrder(t *testing.T) { - stay := func() bool { return true } - openers := map[string]func(*libkb.MobileAppState, *atomic.Uint64){ - "enterBackground": func(appState *libkb.MobileAppState, taskGen *atomic.Uint64) { - enterBackground(appState, true, taskGen, func() {}) - }, - "endPushWindow": func(appState *libkb.MobileAppState, taskGen *atomic.Uint64) { - endPushWindow(appState, beginPushWindow(appState), stay, taskGen, func() {}) - }, - } - for name, openFirst := range openers { - t.Run(name, func(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - paused := make(chan struct{}) - release := make(chan struct{}) - var calls atomic.Int32 - testHookAfterWindowUpdate = func() { - if calls.Add(1) == 1 { - close(paused) - <-release - } - } - t.Cleanup(func() { testHookAfterWindowUpdate = nil }) - - firstDone := make(chan struct{}) - go func() { - openFirst(appState, &taskGen) - close(firstDone) - }() - <-paused - require.True(t, enterBackground(appState, true, &taskGen, func() {})) - _, newest := appState.StateAndGeneration() - close(release) - requireDone(t, firstDone) - require.Equal(t, newest, taskGen.Load()) - - requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) - require.Equal(t, background, appState.State()) - }) - } -} - -func TestPushWindow(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - var flushes flushCounter - stay := func(v bool) func() bool { return func() bool { return v } } - - token := beginPushWindow(appState) - require.Positive(t, token) - require.Equal(t, backgroundActive, appState.State()) - require.False(t, endPushWindow(appState, token, stay(false), &taskGen, flushes.flush)) - require.Equal(t, background, appState.State()) - require.Equal(t, 1, flushes.count()) - - appState.Update(foreground) - require.Zero(t, beginPushWindow(appState)) - require.Equal(t, foreground, appState.State()) - require.False(t, endPushWindow(appState, 0, stay(false), &taskGen, flushes.flush)) - require.False(t, endPushWindow(appState, -1, stay(false), &taskGen, flushes.flush)) - require.Equal(t, foreground, appState.State()) -} - -func TestPushWindowForegroundInBetween(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - var flushes flushCounter - - token := beginPushWindow(appState) - require.Positive(t, token) - appState.Update(foreground) - require.False(t, endPushWindow(appState, token, func() bool { return false }, &taskGen, flushes.flush)) - require.Equal(t, foreground, appState.State()) - require.Zero(t, flushes.count()) - - token = beginPushWindow(appState) - require.Zero(t, token) - - // A foreground and a return to the background in between: the window is - // no longer the push handler's, even though the value matches. - appState.Update(backgroundActive) - token = beginPushWindow(appState) - require.Positive(t, token) - appState.Update(foreground) - require.True(t, enterBackground(appState, true, &taskGen, flushes.flush)) - _, gen := appState.StateAndGeneration() - require.False(t, endPushWindow(appState, token, func() bool { return false }, &taskGen, flushes.flush)) - require.Equal(t, backgroundActive, appState.State()) - require.Equal(t, gen, taskGen.Load(), "the background task window stays open") - require.False(t, endPushWindow(appState, token, func() bool { return true }, &taskGen, flushes.flush)) - require.Equal(t, gen, taskGen.Load()) -} - -func TestPushWindowEndStaleTokenSkipsStayRunning(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - token := beginPushWindow(appState) - appState.Update(foreground) - called := false - require.False(t, endPushWindow(appState, token, func() bool { - called = true - return true - }, &taskGen, func() {})) - require.False(t, called) - require.Equal(t, foreground, appState.State()) -} - -func TestPushWindowOverlapping(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - first := beginPushWindow(appState) - second := beginPushWindow(appState) - require.Positive(t, first) - require.Greater(t, second, first) - - require.False(t, endPushWindow(appState, first, func() bool { return false }, &taskGen, func() {})) - require.Equal(t, backgroundActive, appState.State(), "the later window is still open") - require.False(t, endPushWindow(appState, second, func() bool { return false }, &taskGen, func() {})) - require.Equal(t, background, appState.State()) -} - -func TestPushWindowHandsOverToBackgroundTask(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - token := beginPushWindow(appState) - require.True(t, endPushWindow(appState, token, func() bool { return true }, &taskGen, func() {})) - _, gen := appState.StateAndGeneration() - require.Equal(t, gen, taskGen.Load()) - require.Equal(t, backgroundActive, appState.State()) - - requireDone(t, startBackgroundTask(appState, &taskGen, newFakeDeliverer(0), time.Minute, func() {})) - require.Equal(t, background, appState.State()) -} - -// Owners run concurrently with lifecycle events, then each phase ends on a -// known last event and checks nothing is left stuck: FOREGROUND stays -// FOREGROUND, a background task window closes to BACKGROUND, and a plain -// BACKGROUND stays BACKGROUND. Owner goroutines must all exit. -func TestAppStateOwnersStress(t *testing.T) { - appState := newTestAppState(t, background) - var taskGen atomic.Uint64 - flush := func() {} - notify := func() {} - // Widen the gap between opening a window and recording it, where a - // competing opener can slip in. - testHookAfterWindowUpdate = func() { - if rand.Intn(2) == 0 { - time.Sleep(time.Duration(rand.Intn(200)) * time.Microsecond) - } - } - t.Cleanup(func() { testHookAfterWindowUpdate = nil }) - 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 4 { - runOwner(func(r *rand.Rand) { - token := beginPushWindow(appState) - if r.Intn(2) == 0 { - time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) - } - if endPushWindow(appState, token, func() bool { return r.Intn(3) == 0 }, &taskGen, flush) { - runBackgroundTask(context.Background(), appState, &taskGen, - newFakeDeliverer(0).deps(time.Minute), flush) - } - }) - runOwner(func(r *rand.Rand) { - runBackgroundSyncWindow(appState, time.Duration(r.Intn(200))*time.Microsecond, flush) - }) - runOwner(func(*rand.Rand) { - expireBackgroundTask(appState, &taskGen, flush, notify) - }) - } - - r := rand.New(rand.NewSource(time.Now().UnixNano())) - for range iterations { - switch r.Intn(5) { - case 0: - appState.Update(foreground) - case 1: - appState.Update(backgroundActive) - case 2: - appState.Update(keybase1.MobileAppState_INACTIVE) - case 3: - enterBackground(appState, r.Intn(2) == 0, &taskGen, flush) - case 4: - appState.Update(background) - } - time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) - } - appState.Update(foreground) - close(lifecycleDone) - waitGroupWithin(t, &owners, "owners deadlocked") - require.Equal(t, foreground, appState.State()) - } - - t.Run("ends in foreground", func(t *testing.T) { - chaos(t, 300) - }) - - // Android's onPause and the push service both open a window and start a - // task; the newest window must close once the tasks are done. - t.Run("ends in background task", func(t *testing.T) { - for range 50 { - chaos(t, 20) - var tasks sync.WaitGroup - for range 4 { - tasks.Add(1) - go func() { - defer tasks.Done() - if enterBackground(appState, true, &taskGen, flush) { - runBackgroundTask(context.Background(), appState, &taskGen, - newFakeDeliverer(0).deps(time.Minute), flush) - } - }() - } - waitGroupWithin(t, &tasks, "background tasks deadlocked") - require.Equal(t, background, appState.State()) - } - }) - - t.Run("ends in background", func(t *testing.T) { - chaos(t, 300) - appState.Update(background) - require.Equal(t, background, appState.State()) - }) - - // 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) - } -} diff --git a/go/bind/appstate_test.go b/go/bind/appstate_test.go deleted file mode 100644 index 6dd168dc342a..000000000000 --- a/go/bind/appstate_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 Keybase, Inc. All rights reserved. Use of -// this source code is governed by the included BSD license. - -package keybase - -import ( - "testing" - - "github.com/keybase/client/go/libkb" - "github.com/keybase/client/go/protocol/keybase1" - "github.com/stretchr/testify/require" -) - -func TestUpdateAppStateAndFlushOnlyOnChange(t *testing.T) { - tc := libkb.SetupTest(t, "UpdateAppStateAndFlush", 0) - defer tc.Cleanup() - appState := libkb.NewMobileAppState(tc.G) - - flushes := 0 - flush := func() { flushes++ } - - updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUND, flush) - require.Equal(t, 1, flushes) - _, gen := appState.StateAndGeneration() - - updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUND, flush) - require.Equal(t, 1, flushes, "a repeated BACKGROUND must not flush again") - _, gen2 := appState.StateAndGeneration() - require.Greater(t, gen2, gen) - - appState.Update(keybase1.MobileAppState_FOREGROUND) - updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUNDACTIVE, flush) - require.Equal(t, 2, flushes) - updateAppStateAndFlush(appState, keybase1.MobileAppState_BACKGROUNDACTIVE, flush) - require.Equal(t, 2, flushes) -} diff --git a/go/bind/keybase.go b/go/bind/keybase.go index b04c4e1c3f28..bcf6eac9a5e3 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -31,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" @@ -879,7 +880,7 @@ func SetAppStateForeground() { return } defer kbCtx.Trace("SetAppStateForeground", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + kbCtx.MobileLifecycle.DidBecomeActive() } func SetAppStateBackground() { @@ -887,59 +888,51 @@ func SetAppStateBackground() { return } defer kbCtx.Trace("SetAppStateBackground", nil)() - updateAppStateAndFlush(kbCtx.MobileAppState, keybase1.MobileAppState_BACKGROUND, flushLocalDbs) + kbCtx.MobileLifecycle.DidEnterBackground(func() bool { return false }) } -// updateAppStateAndFlush flushes only when the state actually changes, so -// repeated lifecycle callbacks don't queue a flush each. -func updateAppStateAndFlush(appState *libkb.MobileAppState, state keybase1.MobileAppState, flush func()) { - if appState.Update(state) { - flush() +func SetAppStateInactive() { + if !isInited() { + return } + defer kbCtx.Trace("SetAppStateInactive", nil)() + kbCtx.MobileLifecycle.WillResignActive() } -// 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 { +func SetAppStateBackgroundActive() { + if !isInited() { 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)) + defer kbCtx.Trace("SetAppStateBackgroundActive", nil)() + kbCtx.MobileLifecycle.WillEnterForeground() +} + +// AppWillEnterForeground reports iOS applicationWillEnterForeground. +func AppWillEnterForeground() { + if !isInited() { + return } - go flush("LocalDb", kbCtx.LocalDb) - go flush("LocalChatDb", kbCtx.LocalChatDb) + defer kbCtx.Trace("AppWillEnterForeground", nil)() + kbCtx.MobileLifecycle.WillEnterForeground() } -func SetAppStateInactive() { +// AppDidBecomeActive reports iOS applicationDidBecomeActive, or Android's +// process start. +func AppDidBecomeActive() { if !isInited() { return } - defer kbCtx.Trace("SetAppStateInactive", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + defer kbCtx.Trace("AppDidBecomeActive", nil)() + kbCtx.MobileLifecycle.DidBecomeActive() } -func SetAppStateBackgroundActive() { +// AppWillResignActive reports iOS applicationWillResignActive. +func AppWillResignActive() { if !isInited() { return } - defer kbCtx.Trace("SetAppStateBackgroundActive", nil)() - kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + defer kbCtx.Trace("AppWillResignActive", nil)() + kbCtx.MobileLifecycle.WillResignActive() } func waitForInit(maxDur time.Duration) error { @@ -968,9 +961,7 @@ func BackgroundSync() string { return fmt.Sprintf("waitForInit timeout: %v", err) } defer kbCtx.Trace("BackgroundSync", nil)() - msg := runBackgroundSyncWindow(kbCtx.MobileAppState, backgroundSyncWindowDuration, flushLocalDbs) - kbCtx.Log.Debug("BackgroundSync: %s", msg) - return msg + return kbCtx.MobileLifecycle.BackgroundSync() } // pushPendingMessageFailure sends at most one notification that a message @@ -996,9 +987,7 @@ func AppWillExit(pusher PushNotifier) { return } defer kbCtx.Trace("AppWillExit", nil)() - notifyPendingMessageFailure(pusher) - backgroundTaskGen.Store(0) - updateAppStateAndFlush(kbCtx.MobileAppState, keybase1.MobileAppState_BACKGROUND, flushLocalDbs) + kbCtx.MobileLifecycle.WillTerminate(func() { notifyPendingMessageFailure(pusher) }) } // AppBackgroundTaskExpired is called when the OS is about to suspend the app @@ -1010,9 +999,7 @@ func AppBackgroundTaskExpired(pusher PushNotifier) { return } defer kbCtx.Trace("AppBackgroundTaskExpired", nil)() - expireBackgroundTask(kbCtx.MobileAppState, &backgroundTaskGen, flushLocalDbs, func() { - notifyPendingMessageFailure(pusher) - }) + kbCtx.MobileLifecycle.BackgroundTaskExpired(func() { notifyPendingMessageFailure(pusher) }) } // notifyPendingMessageFailure warns the user that messages still waiting to @@ -1024,7 +1011,8 @@ func notifyPendingMessageFailure(pusher PushNotifier) { } } -func shouldStayRunningInBackground(ctx context.Context) bool { +func shouldStayRunningInBackground() bool { + ctx := context.Background() convs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx) if err != nil { kbCtx.Log.Debug("shouldStayRunningInBackground: failed to get active deliveries: %s", err) @@ -1051,9 +1039,7 @@ func AppDidEnterBackground() bool { return false } defer kbCtx.Trace("AppDidEnterBackground", nil)() - // The OS may still kill us once the background task runs out. - return enterBackground(kbCtx.MobileAppState, shouldStayRunningInBackground(context.Background()), - &backgroundTaskGen, flushLocalDbs) + return kbCtx.MobileLifecycle.DidEnterBackground(shouldStayRunningInBackground) } // AppPushWindowBegin moves the app to BACKGROUNDACTIVE while a push @@ -1066,7 +1052,7 @@ func AppPushWindowBegin() int64 { return -1 } defer kbCtx.Trace("AppPushWindowBegin", nil)() - return beginPushWindow(kbCtx.MobileAppState) + return kbCtx.MobileLifecycle.PushWindowBegin() } // AppPushWindowEnd closes the window opened by AppPushWindowBegin, only if @@ -1078,9 +1064,7 @@ func AppPushWindowEnd(token int64) bool { return false } defer kbCtx.Trace("AppPushWindowEnd", nil)() - return endPushWindow(kbCtx.MobileAppState, token, func() bool { - return shouldStayRunningInBackground(context.Background()) - }, &backgroundTaskGen, flushLocalDbs) + return kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground) } func AppBeginBackgroundTaskNonblock(pusher PushNotifier) { @@ -1098,14 +1082,15 @@ func AppBeginBackgroundTask(pusher PushNotifier) { return } defer kbCtx.Trace("AppBeginBackgroundTask", nil)() - runBackgroundTask(context.Background(), kbCtx.MobileAppState, &backgroundTaskGen, backgroundTaskDeps{ - activeDeliveries: kbChatCtx.MessageDeliverer.ActiveDeliveries, - nextFailure: kbChatCtx.MessageDeliverer.NextFailure, - notifyFailure: func(obrs []chat1.OutboxRecord) { pushPendingMessageFailure(obrs, pusher) }, - debug: kbCtx.Log.Debug, - pollInterval: backgroundTaskPollInterval, - maxDuration: backgroundTaskMaxDuration, - }, flushLocalDbs) + kbCtx.MobileLifecycle.RunBackgroundTask(context.Background(), backgroundTaskDeps(pusher)) +} + +func backgroundTaskDeps(pusher PushNotifier) lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + ActiveDeliveries: kbChatCtx.MessageDeliverer.ActiveDeliveries, + NextFailure: kbChatCtx.MessageDeliverer.NextFailure, + NotifyFailure: func(obrs []chat1.OutboxRecord) { pushPendingMessageFailure(obrs, pusher) }, + } } func startTrace(logFile string) { diff --git a/go/chat/maps/bgactive.go b/go/chat/maps/bgactive.go deleted file mode 100644 index 5de390372ef0..000000000000 --- a/go/chat/maps/bgactive.go +++ /dev/null @@ -1,31 +0,0 @@ -package maps - -import ( - "github.com/keybase/client/go/libkb" - "github.com/keybase/client/go/protocol/keybase1" -) - -// backgroundActiveOwner records a BACKGROUND to BACKGROUNDACTIVE transition -// made for live location, so that tracking can undo exactly that transition. -// Callers serialize access. -type backgroundActiveOwner struct { - gen uint64 -} - -func (o *backgroundActiveOwner) claim(appState *libkb.MobileAppState) { - gen, applied, _ := appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - func(s keybase1.MobileAppState) bool { return s == keybase1.MobileAppState_BACKGROUND }) - if applied { - o.gen = gen - } -} - -// release returns to BACKGROUND only if nothing has updated the app state -// since claim. -func (o *backgroundActiveOwner) release(appState *libkb.MobileAppState) { - if o.gen == 0 { - return - } - appState.UpdateIfGeneration(o.gen, keybase1.MobileAppState_BACKGROUND) - o.gen = 0 -} diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 75babbb3333b..a207c383d7fd 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -32,7 +32,6 @@ type LiveLocationTracker struct { trackers map[types.LiveLocationKey]*locationTrack lastCoord chat1.Coordinate maxCoords int - bgActive backgroundActiveOwner // testing only TestingCoordsAddedCh chan struct{} @@ -97,7 +96,7 @@ func (l *LiveLocationTracker) removeTrackerLocked(ctx context.Context, t *locati delete(l.trackers, t.Key()) l.saveLocked(ctx) if len(l.trackers) == 0 { - l.bgActive.release(l.G().MobileAppState) + l.G().MobileLifecycle.LiveLocationRelease() } } @@ -384,7 +383,7 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co // 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.bgActive.claim(l.G().MobileAppState) + l.G().MobileLifecycle.LiveLocationClaim() } if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") diff --git a/go/chat/maps/bgactive_test.go b/go/chat/maps/livelocation_appstate_test.go similarity index 54% rename from go/chat/maps/bgactive_test.go rename to go/chat/maps/livelocation_appstate_test.go index 9043f9dcfb25..74da1142a4f0 100644 --- a/go/chat/maps/bgactive_test.go +++ b/go/chat/maps/livelocation_appstate_test.go @@ -13,49 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestBackgroundActiveOwner(t *testing.T) { - tc := libkb.SetupTest(t, "BackgroundActiveOwner", 0) - defer tc.Cleanup() - appState := libkb.NewMobileAppState(tc.G) - var owner backgroundActiveOwner - - // Only a move out of BACKGROUND is claimed. - owner.claim(appState) - require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) - owner.release(appState) - require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) - - appState.Update(keybase1.MobileAppState_BACKGROUND) - owner.claim(appState) - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) - // Repeated location updates while already BACKGROUNDACTIVE keep the claim. - owner.claim(appState) - owner.release(appState) - require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) -} - -func TestBackgroundActiveOwnerReleaseAfterForeground(t *testing.T) { - tc := libkb.SetupTest(t, "BackgroundActiveOwnerForeground", 0) - defer tc.Cleanup() - appState := libkb.NewMobileAppState(tc.G) - var owner backgroundActiveOwner - - appState.Update(keybase1.MobileAppState_BACKGROUND) - owner.claim(appState) - appState.Update(keybase1.MobileAppState_FOREGROUND) - owner.release(appState) - require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) - - // Back in the background, someone else set BACKGROUNDACTIVE after the - // claim; ending tracking leaves it alone. - appState.Update(keybase1.MobileAppState_BACKGROUND) - owner.claim(appState) - appState.Update(keybase1.MobileAppState_FOREGROUND) - appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - owner.release(appState) - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) -} - func TestLiveLocationTrackerBackgroundActive(t *testing.T) { t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) tc := libkb.SetupTest(t, "LiveLocationTrackerBackgroundActive", 0) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 5d4afb2101a1..6be2e34d9053 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -407,3 +407,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/globals.go b/go/libkb/globals.go index 68e8f6c9233a..f86d4ac5049a 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -27,6 +27,7 @@ import ( "sync" "time" + "github.com/keybase/client/go/libkb/lifecycle" logger "github.com/keybase/client/go/logger" keybase1 "github.com/keybase/client/go/protocol/keybase1" clockwork "github.com/keybase/clockwork" @@ -69,6 +70,7 @@ type GlobalContext struct { DNSNSFetcher DNSNameServerFetcher // The mobile apps potentially pass an implementor of this interface which is used to grab currently configured DNS name servers MobileNetState *MobileNetState // The kind of network connection for the currently running instance of the app MobileAppState *MobileAppState // The state of focus for the currently running instance of the app + MobileLifecycle *lifecycle.Controller // Turns native lifecycle events into MobileAppState updates DesktopAppState *DesktopAppState // The state of focus for the currently running instance of the app ChatHelper ChatHelper // conveniently send chat messages RPCCanceler *RPCCanceler // register live RPCs so they can be cancelleed en masse @@ -305,6 +307,10 @@ func (g *GlobalContext) Init() *GlobalContext { g.localSigchainGuard = NewLocalSigchainGuard(g) g.MobileNetState = NewMobileNetState(g) g.MobileAppState = NewMobileAppState(g) + g.MobileLifecycle = lifecycle.New(g.MobileAppState, lifecycle.Config{ + Flush: g.flushLocalDbs, + Debug: func(format string, args ...interface{}) { g.Log.Debug(format, args...) }, + }) g.DesktopAppState = NewDesktopAppState(g) g.RPCCanceler = NewRPCCanceler() g.IdentifyDispatch = NewIdentifyDispatch() diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go new file mode 100644 index 000000000000..6487cc3d0b65 --- /dev/null +++ b/go/libkb/lifecycle/controller_test.go @@ -0,0 +1,289 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycle_test + +import ( + "context" + "math/rand" + "runtime" + "sync" + "testing" + "time" + + "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/keybase1" + "github.com/stretchr/testify/require" +) + +const ( + foreground = keybase1.MobileAppState_FOREGROUND + background = keybase1.MobileAppState_BACKGROUND + backgroundActive = keybase1.MobileAppState_BACKGROUNDACTIVE + inactive = keybase1.MobileAppState_INACTIVE +) + +func stay() bool { return true } +func noStay() bool { return false } +func noop() {} + +func noDeliveries() lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + 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) {}, + } +} + +func requireDone(t *testing.T, done chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + require.Fail(t, "did not finish") + } +} + +// Two windows open concurrently and record their generations in the opposite +// order; the newer window must stay recorded so its task can close it. +func TestWindowsRecordedOutOfOrder(t *testing.T) { + openers := map[string]func(*lifecycle.Controller){ + "didEnterBackground": func(c *lifecycle.Controller) { c.DidEnterBackground(stay) }, + "pushWindowEnd": func(c *lifecycle.Controller) { c.PushWindowEnd(c.PushWindowBegin(), stay) }, + } + for name, openFirst := range openers { + t.Run(name, func(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{BackgroundTaskPollInterval: time.Millisecond}) + paused := make(chan struct{}) + release := make(chan struct{}) + calls := 0 + var mu sync.Mutex + lifecycle.SetTestHookAfterWindowUpdate(c, func() { + mu.Lock() + calls++ + first := calls == 1 + mu.Unlock() + if first { + close(paused) + <-release + } + }) + + firstDone := make(chan struct{}) + go func() { + openFirst(c) + close(firstDone) + }() + <-paused + require.True(t, c.DidEnterBackground(stay)) + _, newest := appState.StateAndGeneration() + close(release) + requireDone(t, firstDone) + require.Equal(t, newest, lifecycle.TaskGen(c)) + + c.RunBackgroundTask(context.Background(), noDeliveries()) + require.Equal(t, background, appState.State()) + }) + } +} + +func TestLiveLocationClaimsRecordedOutOfOrder(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{}) + paused := make(chan struct{}) + release := make(chan struct{}) + calls := 0 + var mu sync.Mutex + lifecycle.SetTestHookAfterWindowUpdate(c, func() { + mu.Lock() + calls++ + first := calls == 1 + mu.Unlock() + if first { + close(paused) + <-release + } + }) + firstDone := make(chan struct{}) + go func() { + c.LiveLocationClaim() + close(firstDone) + }() + <-paused + // Another owner's round trip, then a newer claim. + appState.Update(background) + c.LiveLocationClaim() + close(release) + requireDone(t, firstDone) + c.LiveLocationRelease() + require.Equal(t, background, appState.State()) +} + +func TestPushWindowEndStaleTokenSkipsStayRunning(t *testing.T) { + appState, _ := newAppState(t) + appState.Update(background) + c := lifecycle.New(appState, lifecycle.Config{}) + token := c.PushWindowBegin() + c.DidBecomeActive() + called := false + require.False(t, c.PushWindowEnd(token, func() bool { + called = true + return true + })) + require.False(t, called) + require.False(t, c.PushWindowEnd(-1, stay)) + require.Equal(t, foreground, appState.State()) +} + +func TestEventString(t *testing.T) { + require.Equal(t, "willEnterForeground", lifecycle.EventWillEnterForeground.String()) + require.Equal(t, "liveLocationRelease", lifecycle.EventLiveLocationRelease.String()) + require.Equal(t, "Event(99)", lifecycle.Event(99).String()) +} + +// Owners run concurrently with lifecycle events, then each phase ends on a +// known last event and checks nothing is left stuck: FOREGROUND stays +// FOREGROUND, a background task window closes to BACKGROUND, and a plain +// BACKGROUND stays BACKGROUND. Owner goroutines must all exit. +func TestOwnersStress(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, + }) + // Widen the gap between opening a window and recording it, where a + // competing opener can slip in. + lifecycle.SetTestHookAfterWindowUpdate(c, func() { + if rand.Intn(2) == 0 { + time.Sleep(time.Duration(rand.Intn(200)) * time.Microsecond) + } + }) + 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) + } + if c.PushWindowEnd(token, func() bool { return r.Intn(3) == 0 }) { + c.RunBackgroundTask(context.Background(), noDeliveries()) + } + }) + runOwner(func(*rand.Rand) { c.BackgroundSync() }) + runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) + runOwner(func(r *rand.Rand) { + c.LiveLocationClaim() + time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) + c.LiveLocationRelease() + }) + } + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for range iterations { + switch r.Intn(6) { + case 0: + c.DidBecomeActive() + case 1: + c.WillEnterForeground() + case 2: + c.WillResignActive() + case 3: + c.DidEnterBackground(func() bool { return r.Intn(2) == 0 }) + case 4: + c.DidEnterBackground(noStay) + case 5: + if r.Intn(10) == 0 { + c.WillTerminate(noop) + } + } + time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) + } + c.DidBecomeActive() + close(lifecycleDone) + waitGroupWithin(t, &owners, "owners deadlocked") + require.Equal(t, foreground, appState.State()) + } + + t.Run("ends in foreground", func(t *testing.T) { + chaos(t, 300) + }) + + // Android's process stop and the push service both open a window and + // start a task; the newest window must close once the tasks are done. + t.Run("ends in background task", func(t *testing.T) { + for range 50 { + chaos(t, 20) + var tasks sync.WaitGroup + for range 4 { + tasks.Add(1) + go func() { + defer tasks.Done() + if c.DidEnterBackground(stay) { + c.RunBackgroundTask(context.Background(), noDeliveries()) + } + }() + } + waitGroupWithin(t, &tasks, "background tasks deadlocked") + require.Equal(t, background, appState.State()) + } + }) + + t.Run("ends in background", func(t *testing.T) { + chaos(t, 300) + c.DidEnterBackground(noStay) + require.Equal(t, background, appState.State()) + }) + + // 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..a18729ed23d1 --- /dev/null +++ b/go/libkb/lifecycle/export_test.go @@ -0,0 +1,8 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycle + +func SetTestHookAfterWindowUpdate(c *Controller, hook func()) { c.testHookAfterWindowUpdate = hook } + +func TaskGen(c *Controller) uint64 { return c.taskGen.Load() } diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go new file mode 100644 index 000000000000..f270bc204809 --- /dev/null +++ b/go/libkb/lifecycle/lifecycle.go @@ -0,0 +1,409 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +// Package lifecycle turns the mobile app's lifecycle events, as reported by +// native code, into MobileAppState updates. Owners of a transition (background +// sync, background tasks, push windows, live location) undo only their own +// transition, by generation. +// +// It must not import libkb: libkb holds a Controller, and libkb's own tests +// drive it. +package lifecycle + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "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 Event int + +const ( + EventWillEnterForeground Event = iota + EventDidBecomeActive + EventWillResignActive + EventDidEnterBackground + EventWillTerminate + EventBackgroundTaskBegin + EventBackgroundTaskEnd + EventBackgroundTaskExpired + EventPushWindowBegin + EventPushWindowEnd + EventBackgroundSyncBegin + EventBackgroundSyncEnd + EventLiveLocationClaim + EventLiveLocationRelease +) + +var eventNames = map[Event]string{ + EventWillEnterForeground: "willEnterForeground", + EventDidBecomeActive: "didBecomeActive", + EventWillResignActive: "willResignActive", + EventDidEnterBackground: "didEnterBackground", + EventWillTerminate: "willTerminate", + EventBackgroundTaskBegin: "backgroundTaskBegin", + EventBackgroundTaskEnd: "backgroundTaskEnd", + EventBackgroundTaskExpired: "backgroundTaskExpired", + EventPushWindowBegin: "pushWindowBegin", + EventPushWindowEnd: "pushWindowEnd", + EventBackgroundSyncBegin: "backgroundSyncBegin", + EventBackgroundSyncEnd: "backgroundSyncEnd", + EventLiveLocationClaim: "liveLocationClaim", + EventLiveLocationRelease: "liveLocationRelease", +} + +func (e Event) String() string { + if name, ok := eventNames[e]; ok { + return name + } + return fmt.Sprintf("Event(%d)", int(e)) +} + +// AppState is the part of libkb.MobileAppState the controller drives. +type AppState interface { + State() keybase1.MobileAppState + StateAndGeneration() (keybase1.MobileAppState, uint64) + Update(state keybase1.MobileAppState) (changed bool) + UpdateWithCheck(state keybase1.MobileAppState, check func(keybase1.MobileAppState) bool) ( + newGen uint64, applied bool, changed bool) + UpdateIfGeneration(gen uint64, state keybase1.MobileAppState) (newGen uint64, applied bool, 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 after every real change into BACKGROUND, and into a + // background task window, where the OS may suspend or kill the process + // next. It must not block. + Flush func() + Debug func(format string, args ...interface{}) +} + +type Controller struct { + appState AppState + cfg Config + // taskGen is the generation of the open background task window, 0 when + // none is open. + taskGen atomic.Uint64 + // liveLocationGen is the generation of live location's claim, 0 when it + // holds none. + liveLocationGen atomic.Uint64 + // testHookAfterWindowUpdate runs between opening a window and recording + // its generation. + testHookAfterWindowUpdate func() +} + +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{}) {} + } + return &Controller{appState: appState, cfg: cfg} +} + +func (c *Controller) debug(ev Event, format string, args ...interface{}) { + state, gen := c.appState.StateAndGeneration() + c.cfg.Debug("lifecycle: %v: %s (state: %v, generation: %d)", ev, fmt.Sprintf(format, args...), state, gen) +} + +func (c *Controller) update(state keybase1.MobileAppState) { + if c.appState.Update(state) && state == keybase1.MobileAppState_BACKGROUND { + c.cfg.Flush() + } +} + +// recordGen raises owner to gen. Opening a window and recording it are +// separate steps, so concurrent openers can record out of order; only raising +// keeps the newest window recorded. +func (c *Controller) recordGen(owner *atomic.Uint64, gen uint64) { + if c.testHookAfterWindowUpdate != nil { + c.testHookAfterWindowUpdate() + } + for { + cur := owner.Load() + if cur >= gen || owner.CompareAndSwap(cur, gen) { + return + } + } +} + +// undoToBackground returns to BACKGROUND only if nothing has updated the app +// state since the owner's own transition at gen. +func (c *Controller) undoToBackground(gen uint64) (applied bool) { + if gen == 0 { + return false + } + _, applied, changed := c.appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUND) + if changed { + c.cfg.Flush() + } + return applied +} + +func always(keybase1.MobileAppState) bool { return true } + +func isState(want keybase1.MobileAppState) func(keybase1.MobileAppState) bool { + return func(s keybase1.MobileAppState) bool { return s == want } +} + +// WillEnterForeground brings networking up before the UI resumes, without +// claiming the user is looking at the app yet. +func (c *Controller) WillEnterForeground() { + c.update(keybase1.MobileAppState_BACKGROUNDACTIVE) + c.debug(EventWillEnterForeground, "applied") +} + +func (c *Controller) DidBecomeActive() { + c.update(keybase1.MobileAppState_FOREGROUND) + c.debug(EventDidBecomeActive, "applied") +} + +// WillResignActive covers the app being on screen without receiving events: +// Control Center, system alerts, the app switcher, iPad focus loss. +func (c *Controller) WillResignActive() { + c.update(keybase1.MobileAppState_INACTIVE) + c.debug(EventWillResignActive, "applied") +} + +// DidEnterBackground moves to BACKGROUND, or, when stayRunning says work must +// keep going, opens a BACKGROUNDACTIVE window for a background task and +// returns true. +func (c *Controller) DidEnterBackground(stayRunning func() bool) bool { + if !stayRunning() { + c.taskGen.Store(0) + c.update(keybase1.MobileAppState_BACKGROUND) + c.debug(EventDidEnterBackground, "no work to keep running") + return false + } + gen, _, changed := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, always) + c.recordGen(&c.taskGen, gen) + // The OS may still suspend or kill us once the background task runs out. + if changed { + c.cfg.Flush() + } + c.debug(EventDidEnterBackground, "opened background task window at %d", gen) + return true +} + +// WillTerminate forces BACKGROUND regardless of owners: the process is about +// to die. notifyPending warns about messages that won't send. +func (c *Controller) WillTerminate(notifyPending func()) { + notifyPending() + c.taskGen.Store(0) + c.update(keybase1.MobileAppState_BACKGROUND) + c.debug(EventWillTerminate, "applied") +} + +// BackgroundTaskExpired ends the background task window without clobbering a +// state reported after the window opened, such as a return to the foreground. +// notifyPending runs only when the window was still open, since otherwise we +// aren't about to be suspended. +func (c *Controller) BackgroundTaskExpired(notifyPending func()) { + gen := c.taskGen.Swap(0) + applied := c.undoToBackground(gen) + if applied { + notifyPending() + } + c.debug(EventBackgroundTaskExpired, "window %d closed: %v", gen, applied) +} + +// PushWindowBegin moves to BACKGROUNDACTIVE while a push is handled, unless +// the app is in the foreground. It returns the token for PushWindowEnd, or 0 +// if the app is in the foreground. +func (c *Controller) PushWindowBegin() int64 { + gen, applied, _ := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + func(s keybase1.MobileAppState) bool { return s != keybase1.MobileAppState_FOREGROUND }) + if !applied { + c.debug(EventPushWindowBegin, "skipped in the foreground") + return 0 + } + c.debug(EventPushWindowBegin, "opened at %d", gen) + return int64(gen) +} + +// PushWindowEnd closes the window opened at token, only if nothing has updated +// the app state since. It returns true when it hands the window over to a +// background task (as DidEnterBackground does), and false when it moved to +// BACKGROUND or someone else owns the state now. +func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) bool { + if token <= 0 { + return false + } + gen := uint64(token) + if _, cur := c.appState.StateAndGeneration(); cur != gen { + c.debug(EventPushWindowEnd, "window %d superseded", gen) + return false + } + if stayRunning() { + newGen, applied, _ := c.appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) + if !applied { + c.debug(EventPushWindowEnd, "window %d superseded", gen) + return false + } + c.recordGen(&c.taskGen, newGen) + c.debug(EventPushWindowEnd, "window %d handed to background task at %d", gen, newGen) + return true + } + applied := c.undoToBackground(gen) + c.debug(EventPushWindowEnd, "window %d closed: %v", gen, applied) + return false +} + +// BackgroundSync moves BACKGROUND to BACKGROUNDACTIVE for the sync window, +// then undoes that transition unless someone else updated the state meanwhile. +// It returns a status for native logs. +func (c *Controller) BackgroundSync() string { + gen, applied, _ := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + isState(keybase1.MobileAppState_BACKGROUND)) + if !applied { + msg := "skipping, app not in background state: " + c.appState.State().String() + c.debug(EventBackgroundSyncBegin, "%s", msg) + return msg + } + c.debug(EventBackgroundSyncBegin, "opened at %d", gen) + timer := c.cfg.Clock.After(c.cfg.BackgroundSyncWindow) + var msg string + select { + case <-c.appState.NextUpdate(keybase1.MobileAppState_BACKGROUNDACTIVE): + msg = "bailing out early, appstate change: " + c.appState.State().String() + case <-timer: + if c.undoToBackground(gen) { + msg = "completed window" + } else { + msg = "completed window, app state updated meanwhile: " + c.appState.State().String() + } + } + c.debug(EventBackgroundSyncEnd, "%s", msg) + return msg +} + +// LiveLocationClaim moves BACKGROUND to BACKGROUNDACTIVE while live location +// is tracking, so location updates get out. +func (c *Controller) LiveLocationClaim() { + gen, applied, _ := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, + isState(keybase1.MobileAppState_BACKGROUND)) + if !applied { + return + } + c.recordGen(&c.liveLocationGen, gen) + c.debug(EventLiveLocationClaim, "claimed at %d", gen) +} + +// LiveLocationRelease returns to BACKGROUND, flushing like every other return +// to BACKGROUND, only if nothing has updated the app state since the claim. +func (c *Controller) LiveLocationRelease() { + gen := c.liveLocationGen.Swap(0) + if gen == 0 { + return + } + applied := c.undoToBackground(gen) + c.debug(EventLiveLocationRelease, "claim %d released: %v", gen, applied) +} + +type BackgroundTaskDeps struct { + ActiveDeliveries func(context.Context) ([]chat1.OutboxRecord, error) + NextFailure func() (chan []chat1.OutboxRecord, func()) + NotifyFailure func([]chat1.OutboxRecord) +} + +// RunBackgroundTask waits while the background task window opened by +// DidEnterBackground or PushWindowEnd is still current, until outgoing +// messages are delivered, one fails, time runs out or ctx is done; then it +// returns to BACKGROUND unless someone else has updated the app state since +// the window opened. +func (c *Controller) RunBackgroundTask(ctx context.Context, deps BackgroundTaskDeps) { + gen := c.taskGen.Load() + state, cur := c.appState.StateAndGeneration() + if state != keybase1.MobileAppState_BACKGROUNDACTIVE || gen == 0 || cur != gen { + c.debug(EventBackgroundTaskBegin, "no background task window, early out") + return + } + c.debug(EventBackgroundTaskBegin, "window %d", gen) + 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(ctx) + g.Go(func() error { + select { + case <-c.appState.NextUpdate(state): + return errors.New("app state change") + 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 { + successCount := 0 + for { + select { + case <-clock.After(c.cfg.BackgroundTaskPollInterval): + obrs, err := deps.ActiveDeliveries(ctx) + if err != nil { + c.cfg.Debug("lifecycle: failed to query active deliveries: %s", err) + continue + } + if len(obrs) == 0 { + // 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++ + } + if clock.Now().Round(0).Sub(beginTime) >= c.cfg.BackgroundTaskMaxDuration { + deps.NotifyFailure(obrs) + return errors.New("time expired") + } + case <-ctx.Done(): + return ctx.Err() + } + } + }) + err := g.Wait() + // A matching CAS also clears the window, so a later expiration is a no-op. + closed := c.taskGen.CompareAndSwap(gen, 0) && c.undoToBackground(gen) + c.debug(EventBackgroundTaskEnd, "window %d done because: %v, closed: %v", gen, err, closed) +} 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..b04279c83a8d --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -0,0 +1,384 @@ +// 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" +) + +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 each platform. +func (p Platform) InitialState() keybase1.MobileAppState { + if p == Android { + return keybase1.MobileAppState_BACKGROUNDACTIVE + } + return keybase1.MobileAppState_BACKGROUND +} + +type Action int + +const ( + // Nothing reports no event, as when a silent push launches the app + // without a scene or an Android dialog pauses the activity. + Nothing Action = iota + 1 + + // Native lifecycle events. + WillEnterForeground + DidBecomeActive + WillResignActive + DidEnterBackground + WillTerminate + BackgroundTaskExpired + PushWindowBegin + PushWindowEnd + LiveLocationClaim + 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 + + // BackgroundTaskStart starts the blocking RunBackgroundTask call and + // waits until it is polling (returns true) or has exited early (false). + BackgroundTaskStart + // 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", + LiveLocationClaim: "LiveLocationClaim", + LiveLocationRelease: "LiveLocationRelease", + BackgroundSyncStart: "BackgroundSyncStart", + BackgroundSyncTimerFires: "BackgroundSyncTimerFires", + BackgroundSyncWait: "BackgroundSyncWait", + BackgroundTaskStart: "BackgroundTaskStart", + 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 + // Gen is how much the generation moves: 1 for an accepted update, even a + // same-value one, 0 for a rejected or skipped one. + Gen int + // Flush: local DBs were flushed. + Flush bool + // 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 + 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 + + cancel context.CancelFunc + ctx context.Context + 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 platform's initial state and starts +// recording. Close it when done. +func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *Harness { + appState.Update(platform.InitialState()) + h := &Harness{ + T: t, + AppState: appState, + Clock: NewFakeClock(), + failures: make(chan []chat1.OutboxRecord, 1), + tokens: make(map[int]int64), + syncDone: closedChan(), + taskDone: closedChan(), + } + h.ctx, h.cancel = context.WithCancel(context.Background()) + 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() { + h.cancel() + 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) stayRunning() bool { return h.stay.Load() } + +func (h *Harness) deps() lifecycle.BackgroundTaskDeps { + return lifecycle.BackgroundTaskDeps{ + 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{} { + h.Clock.ForgetAfters() + 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() + _, gen := h.AppState.StateAndGeneration() + flushes, warnings := h.Flushes(), h.Warnings() + ret := h.perform(step) + h.Recorder.Sync(t) + state, newGen := h.AppState.StateAndGeneration() + if state != step.Want { + t.Fatalf("%v: state %v, want %v", step.Do, state, step.Want) + } + if got := newGen - gen; got != uint64(step.Gen) { + t.Fatalf("%v: generation moved by %d, want %d", step.Do, got, step.Gen) + } + if got := h.Flushes() - flushes; got != boolInt(step.Flush) { + t.Fatalf("%v: %d flushes, want %d", step.Do, got, boolInt(step.Flush)) + } + 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: + c.WillEnterForeground() + case DidBecomeActive: + c.DidBecomeActive() + case WillResignActive: + c.WillResignActive() + case DidEnterBackground: + return c.DidEnterBackground(h.stayRunning) + case WillTerminate: + c.WillTerminate(h.warn) + case BackgroundTaskExpired: + c.BackgroundTaskExpired(h.warn) + case PushWindowBegin: + h.tokens[step.Slot] = c.PushWindowBegin() + return h.tokens[step.Slot] > 0 + case PushWindowEnd: + return c.PushWindowEnd(h.tokens[step.Slot], h.stayRunning) + case LiveLocationClaim: + c.LiveLocationClaim() + case LiveLocationRelease: + c.LiveLocationRelease() + case BackgroundSyncStart: + 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 BackgroundTaskStart: + h.taskDone = h.goRun(func() { c.RunBackgroundTask(h.ctx, h.deps()) }) + return h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone) + 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, "RunBackgroundTask") + case BackgroundTaskFails: + h.failures <- make([]chat1.OutboxRecord, 1) + h.wait(h.taskDone, "RunBackgroundTask") + case BackgroundTaskTimesUp: + h.Clock.Advance(maxDuration) + h.wait(h.taskDone, "RunBackgroundTask") + case BackgroundTaskWait: + h.wait(h.taskDone, "RunBackgroundTask") + 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 +} + +// 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) + } +} diff --git a/go/libkb/lifecycle/lifecycletest/recorder.go b/go/libkb/lifecycle/lifecycletest/recorder.go new file mode 100644 index 000000000000..dceef268f567 --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/recorder.go @@ -0,0 +1,111 @@ +// 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, so call Sync at quiescent points to observe every change. +type Recorder struct { + src Source + mu sync.Mutex + states []keybase1.MobileAppState + 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 { + select { + case <-r.src.NextUpdate(state): + 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 observed the source's current state. +// 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.Last() != r.src.State() { + if time.Now().After(deadline) { + t.Fatalf("recorder stuck at %v, state is %v", r.Last(), r.src.State()) + } + time.Sleep(time.Millisecond) + } +} diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go new file mode 100644 index 000000000000..753a748aa960 --- /dev/null +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -0,0 +1,429 @@ +// 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, gen int) Step { + return Step{Do: do, Want: want, Gen: gen} +} + +func (s Step) flush() Step { + s.Flush = true + 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 } + +// iosLaunch brings a freshly started iOS service (BACKGROUND) to the +// foreground: the scene connects and becomes active. +var iosLaunch = []Step{ + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), +} + +// iosToBackgroundTask backgrounds a foreground app with a message still +// sending, and starts the background task. +var iosToBackgroundTask = []Step{ + step(WorkStarts, fg, 0), + step(WillResignActive, ina, 1), + step(DidEnterBackground, bga, 1).flush().returns(true), + step(BackgroundTaskStart, bga, 0).returns(true), +} + +var androidLaunch = []Step{ + step(DidBecomeActive, fg, 1), +} + +// 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: iosLaunch, + Observed: states(bg, bga, fg), + }, + { + Name: "ios background launch by silent push stays in the background, then foreground", + Platform: IOS, + Steps: steps([]Step{ + step(Nothing, bg, 0), + step(BackgroundTaskExpired, bg, 0), + }, iosLaunch), + Observed: states(bg, bga, fg), + }, + { + Name: "ios background launch by BGAppRefresh, then foreground", + Platform: IOS, + Steps: steps([]Step{ + step(BackgroundSyncStart, bga, 1).returns(true), + step(BackgroundSyncTimerFires, bg, 1).flush(), + }, iosLaunch), + Observed: states(bg, bga, bg, bga, fg), + }, + { + Name: "ios home and return", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillResignActive, ina, 1), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, bg, bga, fg), + }, + { + Name: "ios quick background and foreground cycles with duplicate events", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillResignActive, ina, 1), + step(WillResignActive, ina, 1), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(DidEnterBackground, bg, 1).returns(false), + step(WillEnterForeground, bga, 1), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + step(DidBecomeActive, fg, 1), + // Backgrounding abandoned before didEnterBackground. + step(WillResignActive, ina, 1), + step(DidBecomeActive, fg, 1), + step(WillResignActive, ina, 1), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, bg, bga, fg, ina, fg, ina, bg, bga, fg), + }, + { + Name: "ios control center or system alert keeps things up", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillResignActive, ina, 1), + step(DidBecomeActive, fg, 1), + step(WillResignActive, ina, 1), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, fg, ina, fg), + }, + { + Name: "ipad focus loss keeps things up", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillResignActive, ina, 1), + step(WillResignActive, ina, 1), + step(DidBecomeActive, fg, 1), + step(WillResignActive, ina, 1), + step(DidBecomeActive, fg, 1), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, fg, ina, fg), + }, + { + Name: "ios lock and unlock", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillResignActive, ina, 1), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, bg, bga, fg), + }, + { + Name: "ios BackgroundSync window racing willEnterForeground and didBecomeActive", + Platform: IOS, + Steps: []Step{ + step(BackgroundSyncStart, bga, 1).returns(true), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + step(BackgroundSyncWait, fg, 0), + }, + Observed: states(bg, bga, fg), + }, + { + Name: "ios slow didBecomeActive after the BackgroundSync window ends", + Platform: IOS, + Steps: []Step{ + step(BackgroundSyncStart, bga, 1).returns(true), + step(WillEnterForeground, bga, 1), + step(BackgroundSyncTimerFires, bga, 0), + step(DidBecomeActive, fg, 1), + }, + Observed: states(bg, bga, fg), + }, + { + Name: "ios BackgroundSync skips outside the background", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(BackgroundSyncStart, fg, 0).returns(false), + step(WillResignActive, ina, 1), + step(BackgroundSyncStart, ina, 0).returns(false), + }), + Observed: states(bg, bga, fg, ina), + }, + { + Name: "ios background task completes", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(BackgroundTaskDelivered, bg, 1).flush(), + step(BackgroundTaskExpired, bg, 0), + }, iosLaunch), + Observed: states(bg, bga, fg, ina, bga, bg, bga, fg), + }, + { + Name: "ios background task fails", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(BackgroundTaskFails, bg, 1).flush().warn(), + }), + Observed: states(bg, bga, fg, ina, bga, bg), + }, + { + Name: "ios background task runs out of time", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(BackgroundTaskTimesUp, bg, 1).flush().warn(), + }), + Observed: states(bg, bga, fg, ina, bga, bg), + }, + { + Name: "ios background task expires", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(BackgroundTaskExpired, bg, 1).flush().warn(), + step(BackgroundTaskWait, bg, 0), + step(BackgroundTaskExpired, bg, 0), + }), + Observed: states(bg, bga, fg, ina, bga, bg), + }, + { + Name: "ios background task expires after return to foreground", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + step(BackgroundTaskWait, fg, 0), + step(BackgroundTaskExpired, fg, 0), + }), + Observed: states(bg, bga, fg, ina, bga, fg), + }, + { + Name: "ios background task expires between willEnterForeground and didBecomeActive", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(WillEnterForeground, bga, 1), + step(BackgroundTaskExpired, bga, 0), + // The same-value update doesn't wake the task; it finishes + // later and leaves the state alone. + step(BackgroundTaskDelivered, bga, 0), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, bga, fg), + }, + { + Name: "ios live location across background", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(BackgroundTaskDelivered, bg, 1).flush(), + // A location update wakes the app while tracking. + step(LiveLocationClaim, bga, 1), + step(LiveLocationClaim, bga, 0), + // Tracking ends. + step(LiveLocationRelease, bg, 1).flush(), + step(LiveLocationRelease, bg, 0), + step(LiveLocationClaim, bga, 1), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + step(LiveLocationRelease, fg, 0), + // Claims only from BACKGROUND. + step(LiveLocationClaim, fg, 0), + }), + Observed: states(bg, bga, fg, ina, bga, bg, bga, bg, bga, fg), + }, + { + Name: "ios termination from the background", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillResignActive, ina, 1), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(WillTerminate, bg, 1).warn(), + }), + Observed: states(bg, bga, fg, ina, bg), + }, + { + Name: "ios termination from the foreground", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WillTerminate, bg, 1).flush().warn(), + }), + Observed: states(bg, bga, fg, bg), + }, + { + Name: "ios termination during a background task", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(WillTerminate, bg, 1).flush().warn(), + step(BackgroundTaskWait, bg, 0), + step(BackgroundTaskExpired, bg, 0), + }), + Observed: states(bg, bga, fg, ina, bga, bg), + }, + { + Name: "android cold launch", + Platform: Android, + Steps: androidLaunch, + Observed: states(bga, fg), + }, + { + Name: "android process stop and start", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(DidBecomeActive, fg, 1), + step(WorkStarts, fg, 0), + step(DidEnterBackground, bga, 1).flush().returns(true), + step(BackgroundTaskStart, bga, 0).returns(true), + step(DidBecomeActive, fg, 1), + step(BackgroundTaskWait, fg, 0), + }), + Observed: states(bga, fg, bg, fg, bga, fg), + }, + { + Name: "android dialog or picker pause keeps the foreground", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(Nothing, fg, 0), + step(PushWindowBegin, fg, 0).returns(false), + step(PushWindowEnd, fg, 0).returns(false), + step(Nothing, fg, 0), + }), + Observed: states(bga, fg), + }, + { + Name: "android push window in the background", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(PushWindowEnd, bg, 1).flush().returns(false), + }), + Observed: states(bga, fg, bg, bga, bg), + }, + { + Name: "android push at cold start", + Platform: Android, + Steps: []Step{ + step(PushWindowBegin, bga, 1).returns(true), + step(PushWindowEnd, bg, 1).flush().returns(false), + }, + Observed: states(bga, bg), + }, + { + Name: "android push window racing process start", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(DidBecomeActive, fg, 1), + step(PushWindowEnd, fg, 0).returns(false), + // Foreground and back to the background while the push is + // handled: the value matches, but the window isn't the push's. + step(PushWindowBegin, fg, 0).returns(false), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(DidBecomeActive, fg, 1), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowEnd, bg, 0).returns(false), + }), + Observed: states(bga, fg, bg, bga, fg, bg, bga, fg, bg), + }, + { + Name: "android push window hands over to a background task", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(WorkStarts, bga, 0), + step(PushWindowEnd, bga, 1).returns(true), + step(BackgroundTaskStart, bga, 0).returns(true), + step(BackgroundTaskDelivered, bg, 1).flush(), + }), + Observed: states(bga, fg, bg, bga, bg), + }, + { + Name: "android overlapping push windows", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).slot(0).returns(true), + step(PushWindowBegin, bga, 1).slot(1).returns(true), + step(PushWindowEnd, bga, 0).slot(0).returns(false), + step(PushWindowEnd, bg, 1).slot(1).flush().returns(false), + }), + Observed: states(bga, fg, bg, bga, bg), + }, + { + Name: "android WorkManager BackgroundSync at cold start", + Platform: Android, + Steps: []Step{ + step(BackgroundSyncStart, bga, 0).returns(false), + }, + Observed: states(bga), + }, + { + Name: "android WorkManager BackgroundSync racing a push window", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(BackgroundSyncStart, bga, 1).returns(true), + step(PushWindowBegin, bga, 1).returns(true), + step(PushWindowEnd, bg, 1).flush().returns(false), + step(BackgroundSyncWait, bg, 0), + }), + Observed: states(bga, fg, bg, bga, bg), + }, + { + Name: "android termination", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(WillTerminate, bg, 1).flush().warn(), + }), + Observed: states(bga, fg, bg), + }, +} diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go new file mode 100644 index 000000000000..2d768128b70e --- /dev/null +++ b/go/libkb/lifecycle/scenario_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 Keybase, Inc. All rights reserved. Use of +// this source code is governed by the included BSD license. + +package lifecycle_test + +import ( + "context" + "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 { + before := appState.State() + ctx, key := g.RPCCanceler.RegisterContext(context.Background(), libkb.RPCCancelerReasonBackground) + h.Do(step) + canceled := ctx.Err() != nil + g.RPCCanceler.UnregisterContext(key) + wantCancel := step.Want == keybase1.MobileAppState_BACKGROUND && before != 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, Gen: 1, Returns: lifecycletest.ReturnTrue}, + }, + "background task": { + {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_BACKGROUND}, + {Do: lifecycletest.DidEnterBackground, Want: bga, Gen: 1, Flush: true, Returns: lifecycletest.ReturnTrue}, + {Do: lifecycletest.BackgroundTaskStart, Want: bga, Returns: lifecycletest.ReturnTrue}, + }, + } + for name, steps := range cases { + t.Run(name, func(t *testing.T) { + appState, _ := newAppState(t) + h := lifecycletest.NewHarness(t, appState, lifecycletest.IOS) + for _, step := range steps { + h.Do(step) + } + closed := make(chan struct{}) + go func() { + h.Close() + close(closed) + }() + select { + case <-closed: + case <-time.After(5 * time.Second): + require.Fail(t, "Close hung") + } + }) + } +} From b4982d8e135c1fa30a22b3420a5c449a1353de76 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:07:48 -0400 Subject: [PATCH 009/127] test(appstate): cover background task window guards and sync the recorder on change channels --- go/libkb/lifecycle/lifecycletest/recorder.go | 38 ++++++-- .../lifecycle/lifecycletest/recorder_test.go | 86 +++++++++++++++++++ go/libkb/lifecycle/lifecycletest/scenarios.go | 41 ++++++++- 3 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 go/libkb/lifecycle/lifecycletest/recorder_test.go diff --git a/go/libkb/lifecycle/lifecycletest/recorder.go b/go/libkb/lifecycle/lifecycletest/recorder.go index dceef268f567..447e31d229f5 100644 --- a/go/libkb/lifecycle/lifecycletest/recorder.go +++ b/go/libkb/lifecycle/lifecycletest/recorder.go @@ -23,13 +23,17 @@ type Source interface { // 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, so call Sync at quiescent points to observe every change. +// 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 - stop chan struct{} - done chan struct{} + // 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 { @@ -47,8 +51,12 @@ func NewRecorder(src Source) *Recorder { 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 <-r.src.NextUpdate(state): + case <-ch: case <-r.stop: return } @@ -97,15 +105,33 @@ func (r *Recorder) Teardowns() int { return n } -// Sync waits until the recorder has observed the source's current state. +// 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.Last() != r.src.State() { + 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 index 753a748aa960..ddc22530f0b3 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -256,6 +256,32 @@ var Scenarios = []Scenario{ }), Observed: states(bg, bga, fg, ina, bga, fg), }, + { + Name: "ios background task finishes after willEnterForeground", + Platform: IOS, + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + step(WillEnterForeground, bga, 1), + // The same-value update doesn't wake the task; when it finishes, + // the window is no longer current, so it leaves the state alone. + step(BackgroundTaskDelivered, bga, 0), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, bga, fg), + }, + { + Name: "ios background task superseded before it starts", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(WorkStarts, fg, 0), + step(WillResignActive, ina, 1), + step(DidEnterBackground, bga, 1).flush().returns(true), + step(WillEnterForeground, bga, 1), + // Returning false means it exited without polling deliveries. + step(BackgroundTaskStart, bga, 0).returns(false), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bg, bga, fg, ina, bga, fg), + }, { Name: "ios live location across background", Platform: IOS, @@ -335,6 +361,16 @@ var Scenarios = []Scenario{ }), Observed: states(bga, fg), }, + { + Name: "android background task without a window", + Platform: Android, + Steps: []Step{ + step(WorkStarts, bga, 0), + // Cold start is BACKGROUNDACTIVE, but no window was opened. + step(BackgroundTaskStart, bga, 0).returns(false), + }, + Observed: states(bga), + }, { Name: "android push window in the background", Platform: Android, @@ -399,7 +435,10 @@ var Scenarios = []Scenario{ Observed: states(bga, fg, bg, bga, bg), }, { - Name: "android WorkManager BackgroundSync at cold start", + // Current behavior, pinned until Task 9 revisits it: Android starts in + // BACKGROUNDACTIVE, so a WorkManager cold start skips the sync and + // nothing moves the state to BACKGROUND. + Name: "android WorkManager BackgroundSync at cold start skips (current behavior)", Platform: Android, Steps: []Step{ step(BackgroundSyncStart, bga, 0).returns(false), From c68b7f9e024d9aa6d2b0f6b4c29e4b8e58a529a0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:21:41 -0400 Subject: [PATCH 010/127] fix(kbhttp): restart dead servers, keep one token, and tear down only in background Serve exiting on its own now clears the server so it can start again, and the manager restarts it on any non-BACKGROUND state. The token is created once per process, handlers are registered before the listener serves, and the manager's server, endpoints and monitor state are read under one lock. The monitor seeds from the current state, so a background launch never starts the server, and it exits on shutdown. Bootstrap status reads the address and token together. --- go/kbhttp/manager/manager.go | 187 ++++++++---- go/kbhttp/manager/manager_test.go | 464 ++++++++++++++++++++++++++++++ go/kbhttp/srv.go | 18 ++ go/kbhttp/srv_test.go | 77 +++++ go/service/config.go | 13 +- 5 files changed, 702 insertions(+), 57 deletions(-) create mode 100644 go/kbhttp/manager/manager_test.go diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 50b14ae21425..b2e6c53908b7 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -29,24 +29,58 @@ type srvEndpoint struct { type Srv struct { libkb.Contextified + // token is set once in NewSrv and kept across restarts, so URLs handed + // out before a restart keep working. + token string + listenerSource func() kbhttp.ListenerSource + + // mu guards everything below and serializes starts and stops. + mu sync.Mutex httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint - token string - startMu sync.Mutex + shutdown bool + // monitorState is the state the monitor last acted on, and monitorWait + // the change channel it is waiting on for that state; tests use them to + // wait until the monitor has caught up. + monitorState keybase1.MobileAppState + monitorWait <-chan struct{} + + shutdownCh chan struct{} + monitorDone chan struct{} } func NewSrv(g *libkb.GlobalContext) *Srv { + listenerSource := func() kbhttp.ListenerSource { + return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) + } + return newSrv(g, listenerSource, runtime.GOOS != "android") +} + +// newSrv starts the server unless the app is in BACKGROUND. With +// followAppState false the server is started unconditionally and stays up. +func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, followAppState bool) *Srv { + token, _ := libkb.RandHexString("", 32) h := &Srv{ - Contextified: libkb.NewContextified(g), - endpoints: make(map[string]srvEndpoint), + Contextified: libkb.NewContextified(g), + token: token, + listenerSource: listenerSource, + endpoints: make(map[string]srvEndpoint), + shutdownCh: make(chan struct{}), + monitorDone: make(chan struct{}), } - h.initHTTPSrv() - h.startHTTPSrv() + h.httpSrv = kbhttp.NewSrv(g.GetLog(), listenerSource()) g.PushShutdownHook(func(mctx libkb.MetaContext) error { - h.httpSrv.Stop() + h.stop() return nil }) - go h.monitorAppState() + if !followAppState { + close(h.monitorDone) + h.startHTTPSrv() + return h + } + state := g.MobileAppState.State() + h.reconcile(state) + go h.monitorAppState(state) return h } @@ -54,26 +88,33 @@ func (r *Srv) debug(ctx context.Context, msg string, args ...any) { r.G().Log.CDebugf(ctx, "Srv: %s", fmt.Sprintf(msg, args...)) } -func (r *Srv) initHTTPSrv() { - startPort := r.G().GetEnv().GetAttachmentHTTPStartPort() - r.httpSrv = kbhttp.NewSrv(r.G().GetLog(), kbhttp.NewRandomPortRangeListenerSource(startPort, 18000)) -} - +// startHTTPSrv starts the server if it isn't serving, including after its +// listener died underneath it. func (r *Srv) startHTTPSrv() { - r.startMu.Lock() - defer r.startMu.Unlock() ctx := context.Background() - token, _ := libkb.RandHexString("", 32) + info, started := r.start(ctx) + if !started { + return + } + r.G().NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) +} + +func (r *Srv) start(ctx context.Context) (info keybase1.HttpSrvInfo, started bool) { + r.mu.Lock() + defer r.mu.Unlock() + if r.shutdown || r.httpSrv.Active() { + return info, false + } maxTries := 2 success := false for range maxTries { - if err := r.httpSrv.Start(); err != nil { + if err := r.httpSrv.StartWithHandlers(r.registerEndpointsLocked); 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() + r.httpSrv = kbhttp.NewSrv(r.G().GetLog(), r.listenerSource()) continue } r.debug(ctx, "startHTTPSrv: failed to start HTTP server: %s", err) @@ -84,53 +125,94 @@ func (r *Srv) startHTTPSrv() { } if !success { r.debug(ctx, "startHTTPSrv: exhausted attempts to start HTTP server, giving up") - return - } - for endpoint, serveDesc := range r.endpoints { - r.HandleFunc(endpoint, serveDesc.tokenMode, serveDesc.serve) + return info, false } 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{ + return keybase1.HttpSrvInfo{ Address: addr, Token: r.token, - }) + }, true } -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" { +func (r *Srv) stopHTTPSrv() { + r.mu.Lock() + defer r.mu.Unlock() + r.httpSrv.Stop() +} + +func (r *Srv) stop() { + r.mu.Lock() + defer r.mu.Unlock() + if r.shutdown { + return + } + r.shutdown = true + close(r.shutdownCh) + r.httpSrv.Stop() +} + +// reconcile tears the server down only in BACKGROUND. INACTIVE (Control +// Center, system alerts, the app switcher) keeps it up, and every other state +// restarts it if it isn't serving. +func (r *Srv) reconcile(state keybase1.MobileAppState) { + if state == keybase1.MobileAppState_BACKGROUND { + r.stopHTTPSrv() return } + r.startHTTPSrv() +} + +func (r *Srv) monitorAppState(state keybase1.MobileAppState) { + defer close(r.monitorDone) + r.debug(context.Background(), "monitorAppState: starting up in %v", state) 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() + next := r.G().MobileAppState.NextUpdate(state) + r.mu.Lock() + r.monitorState, r.monitorWait = state, next + r.mu.Unlock() + select { + case <-next: + case <-r.shutdownCh: + return } + state = r.G().MobileAppState.State() + r.reconcile(state) } } 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) { + r.mu.Lock() + defer r.mu.Unlock() + r.endpoints[endpoint] = srvEndpoint{ + tokenMode: tokenMode, + serve: serve, + } + // A stopped server has no mux; startHTTPSrv registers every endpoint. + if r.httpSrv.Active() { + r.httpSrv.HandleFunc("/"+endpoint, r.checkToken(tokenMode, serve)) + } +} + +func (r *Srv) registerEndpointsLocked(mux *http.ServeMux) { + for endpoint, desc := range r.endpoints { + mux.HandleFunc("/"+endpoint, r.checkToken(desc.tokenMode, desc.serve)) + } +} + +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)) { @@ -143,25 +225,32 @@ 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 { + r.mu.Lock() + defer r.mu.Unlock() return r.httpSrv.Active() } func (r *Srv) Addr() (string, error) { - r.startMu.Lock() - defer r.startMu.Unlock() + r.mu.Lock() + defer r.mu.Unlock() return r.httpSrv.Addr() } func (r *Srv) Token() string { - r.startMu.Lock() - defer r.startMu.Unlock() return r.token } + +// Info returns the address and token together, for handing both to a client. +func (r *Srv) Info() (keybase1.HttpSrvInfo, error) { + r.mu.Lock() + defer r.mu.Unlock() + addr, err := r.httpSrv.Addr() + if err != nil { + return keybase1.HttpSrvInfo{}, err + } + return keybase1.HttpSrvInfo{Address: addr, Token: r.token}, nil +} diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go new file mode 100644 index 000000000000..d09fe3eb6daf --- /dev/null +++ b/go/kbhttp/manager/manager_test.go @@ -0,0 +1,464 @@ +package manager + +import ( + "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, +// and remembers the last listener 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}, +} + +func setup(t *testing.T, state keybase1.MobileAppState, followAppState bool) (*Srv, *listeners) { + tc := libkb.SetupTest(t, "kbhttp", 2) + t.Cleanup(tc.Cleanup) + tc.G.MobileAppState.Update(state) + l := &listeners{} + srv := newSrv(tc.G, l.source, followAppState) + srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { + fmt.Fprint(w, "ok") + }) + return srv, l +} + +// fetch returns the HTTP status, or 0 with an error when no response came +// back. +func fetch(info keybase1.HttpSrvInfo) (int, error) { + resp, err := client.Get(fmt.Sprintf("http://%s/test?token=%s", info.Address, 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 +} + +// waitMonitor waits until the monitor has acted on the current state and is +// waiting for the next change. +func waitMonitor(t *testing.T, srv *Srv) { + t.Helper() + require.Eventually(t, func() bool { + srv.mu.Lock() + state, wait := srv.monitorState, srv.monitorWait + srv.mu.Unlock() + if wait == nil || wait != srv.G().MobileAppState.NextUpdate(state) { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "monitor did not catch up") +} + +func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { + t.Helper() + require.True(t, srv.Active(), "server not active") + 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, srv.Active(), "server still active") + _, err := srv.Info() + require.Error(t, err) +} + +func TestDeadListenerRestartsOnTransition(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + requireServing(t, srv) + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } { + l.kill(t) + require.Eventually(t, func() bool { return !srv.Active() }, 5*time.Second, time.Millisecond, + "dead server still reported active") + srv.G().MobileAppState.Update(next) + waitMonitor(t, srv) + requireServing(t, srv) + } +} + +func TestInactiveKeepsServingBackgroundStops(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + first := requireServing(t, srv) + require.Equal(t, 1, l.Calls()) + + srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + waitMonitor(t, srv) + require.Equal(t, first, requireServing(t, srv)) + require.Equal(t, 1, l.Calls(), "INACTIVE restarted the server") + + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitMonitor(t, srv) + requireStopped(t, srv) + _, err := fetch(first) + require.Error(t, err) + + srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + waitMonitor(t, srv) + again := requireServing(t, srv) + require.Equal(t, 2, l.Calls()) + require.Equal(t, first.Token, again.Token, "token changed across a restart") + require.Equal(t, first.Token, srv.Token()) + // A URL handed out before the restart still works on the pinned port. + _, err = fetch(first) + 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") + requireStopped(t, srv) + waitMonitor(t, srv) + require.Equal(t, 0, l.Calls()) + + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + waitMonitor(t, srv) + requireServing(t, srv) +} + +func TestNotFollowingAppStateStaysUp(t *testing.T) { + srv, _ := setup(t, keybase1.MobileAppState_BACKGROUND, false) + requireServing(t, srv) + srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + requireServing(t, srv) +} + +func TestScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + followAppState := sc.Platform == lifecycletest.IOS + srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, followAppState) + lifecycletest.Play(t, srv.G().MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + if followAppState { + waitMonitor(t, srv) + } + if followAppState && step.Want == keybase1.MobileAppState_BACKGROUND { + if srv.Active() { + t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) + } + return + } + if !srv.Active() { + t.Fatalf("step %d %v: server down in %v", i, step.Do, step.Want) + } + info, err := srv.Info() + require.NoError(t, err) + if _, err := fetch(info); err != nil { + t.Fatalf("step %d %v: %v", i, step.Do, err) + } + }) + }) + } +} + +func TestPinnedPortTakenPicksNewAddress(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + first := requireServing(t, srv) + + // Each reader calls one accessor only, so no other locked call between + // its reads hides an unlocked read of the replaced server from the race + // detector. + stop := make(chan struct{}) + var readers sync.WaitGroup + for _, read := range []func(){ + func() { _ = srv.Active() }, + func() { _, _ = srv.Addr() }, + func() { _, _ = srv.Info() }, + } { + readers.Add(1) + go func() { + defer readers.Done() + for { + select { + case <-stop: + return + default: + } + read() + runtime.Gosched() + } + }() + } + + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitMonitor(t, srv) + requireStopped(t, srv) + squatter, err := net.Listen("tcp", first.Address) + require.NoError(t, err) + defer squatter.Close() + + srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + waitMonitor(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) + waitMonitor(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 { + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitMonitor(t, srv) + srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + waitMonitor(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) +} + +func TestStressTransitionsAndRequests(t *testing.T) { + tc := libkb.SetupTest(t, "kbhttp", 1) + defer tc.Cleanup() + baseline := runtime.NumGoroutine() + + l := &listeners{} + srv := newSrv(tc.G, l.source, true) + srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { + fmt.Fprint(w, "ok") + }) + waitMonitor(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.Active() + _, _ = 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() + } + }() + for w := range 4 { + writers.Add(1) + go func() { + defer writers.Done() + rng := rand.New(rand.NewSource(int64(w))) + for range 300 { + 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): + t.Fatal("deadlock: transitions and requests did not finish") + } + select { + case err := <-bad: + t.Fatalf("bad response during transitions: %v", err) + default: + } + + // Make a real change so the monitor must wake for it. + final := keybase1.MobileAppState_INACTIVE + if tc.G.MobileAppState.State() == final { + final = keybase1.MobileAppState_FOREGROUND + } + tc.G.MobileAppState.Update(final) + waitMonitor(t, srv) + require.Equal(t, token, requireServing(t, srv).Token) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitMonitor(t, srv) + requireStopped(t, srv) + t.Logf("%d good responses, %d listeners", ok.Load(), l.Calls()) + + srv.stop() + select { + case <-srv.monitorDone: + case <-time.After(10 * time.Second): + t.Fatal("monitor did not exit on shutdown") + } + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + require.False(t, srv.Active(), "server restarted after 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..d6610f2e7466 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, @@ -185,6 +195,14 @@ func (h *Srv) Start() (err error) { if err := server.Serve(listener); err != nil { h.log.Debug("kbhttp.Srv: server died: %s", err) } + h.Lock() + // Serve can return without Stop (the listener was closed underneath + // us), so forget the dead server or Start could never run again. A + // Stop and a newer Start may already have replaced it. + if h.server == server { + h.server = nil + } + h.Unlock() close(doneCh) }(h.server, h.doneCh) return nil diff --git a/go/kbhttp/srv_test.go b/go/kbhttp/srv_test.go index e25ff18a782a..0b403e25bb8f 100644 --- a/go/kbhttp/srv_test.go +++ b/go/kbhttp/srv_test.go @@ -6,8 +6,11 @@ package kbhttp import ( "fmt" "io" + "net" "net/http" + "sync" "testing" + "time" "github.com/keybase/client/go/logger" "github.com/stretchr/testify/require" @@ -37,3 +40,77 @@ 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() +} + +func TestSrvRestartsAfterListenerDies(t *testing.T) { + source := &capturingListenerSource{} + srv := NewSrv(logger.NewTestLogger(t), source) + get := func() error { + addr, err := srv.Addr() + if err != nil { + return err + } + resp, err := http.Get(fmt.Sprintf("http://%s/test", addr)) //nolint:gosec // G107: Test code making request to own test server + 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.Eventually(t, func() bool { return !srv.Active() }, 5*time.Second, 10*time.Millisecond, + "server still reports active after its listener died") + _, err := srv.Addr() + require.Error(t, err) + + require.NoError(t, srv.StartWithHandlers(register)) + require.NoError(t, get()) + <-srv.Stop() + require.False(t, srv.Active()) +} + +// The old Serve goroutine exiting after a Stop and a newer Start must not +// forget the new server. +func TestSrvOldServeExitKeepsNewServer(t *testing.T) { + srv := NewSrv(logger.NewTestLogger(t), NewAutoPortListenerSource()) + require.NoError(t, srv.Start()) + oldDone := srv.Stop() + require.NoError(t, srv.Start()) + <-oldDone + require.True(t, srv.Active()) + <-srv.Stop() +} diff --git a/go/service/config.go b/go/service/config.go index 70be37b8f22e..e1896d952821 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -362,15 +362,12 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r 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) + info, infoErr := h.svc.httpSrv.Info() + if infoErr != nil { + m.Debug("GetBootstrapStatus: failed to get HTTP server address: %s", infoErr) } 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(), - } + m.Debug("GetBootstrapStatus: http server: addr: %s token: %s", info.Address, info.Token) + res.HttpSrvInfo = &info break } time.Sleep(50 * time.Millisecond) From 05db75239192f8d0eceeb633d671f2eda5fe9124 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:31:48 -0400 Subject: [PATCH 011/127] fix(kbhttp): restart after unexpected Serve exits, follow transitions on Android, and shorten logged tokens A server whose Serve returns without Stop now reports it, and the manager restarts it at most once per app-state generation unless it should be down. Android runs the monitor too, restarting a dead server on every transition while never stopping it. Starts after shutdown are covered by a test, and the process-lifetime token is logged only by prefix. --- go/kbhttp/manager/manager.go | 93 +++++++++++++------ go/kbhttp/manager/manager_test.go | 146 ++++++++++++++++++++++++++---- go/kbhttp/srv.go | 17 +++- go/kbhttp/srv_test.go | 20 ++++ go/service/config.go | 3 +- 5 files changed, 231 insertions(+), 48 deletions(-) diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index b2e6c53908b7..11b53f36307f 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -33,12 +33,21 @@ type Srv struct { // out before a restart keep working. token string listenerSource func() kbhttp.ListenerSource + // stopInBackground is false on Android, where the server stays up in + // every state. + stopInBackground bool // mu guards everything below and serializes starts and stops. mu sync.Mutex httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint shutdown bool + // exitRestartGen is one past the app-state generation of the last restart + // after an unexpected exit, so a listener that keeps dying restarts at + // most once per generation. + exitRestartGen uint64 + // exits counts handled unexpected exits, for tests. + exits int // monitorState is the state the monitor last acted on, and monitorWait // the change channel it is waiting on for that state; tests use them to // wait until the monitor has caught up. @@ -56,28 +65,22 @@ func NewSrv(g *libkb.GlobalContext) *Srv { return newSrv(g, listenerSource, runtime.GOOS != "android") } -// newSrv starts the server unless the app is in BACKGROUND. With -// followAppState false the server is started unconditionally and stays up. -func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, followAppState bool) *Srv { +func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, stopInBackground bool) *Srv { token, _ := libkb.RandHexString("", 32) h := &Srv{ - Contextified: libkb.NewContextified(g), - token: token, - listenerSource: listenerSource, - endpoints: make(map[string]srvEndpoint), - shutdownCh: make(chan struct{}), - monitorDone: make(chan struct{}), + Contextified: libkb.NewContextified(g), + token: token, + listenerSource: listenerSource, + stopInBackground: stopInBackground, + endpoints: make(map[string]srvEndpoint), + shutdownCh: make(chan struct{}), + monitorDone: make(chan struct{}), } - h.httpSrv = kbhttp.NewSrv(g.GetLog(), listenerSource()) + h.httpSrv = h.newHTTPSrv() g.PushShutdownHook(func(mctx libkb.MetaContext) error { h.stop() return nil }) - if !followAppState { - close(h.monitorDone) - h.startHTTPSrv() - return h - } state := g.MobileAppState.State() h.reconcile(state) go h.monitorAppState(state) @@ -88,6 +91,47 @@ func (r *Srv) debug(ctx context.Context, msg string, args ...any) { r.G().Log.CDebugf(ctx, "Srv: %s", 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) newHTTPSrv() *kbhttp.Srv { + srv := kbhttp.NewSrv(r.G().GetLog(), r.listenerSource()) + srv.OnUnexpectedExit(r.serverExited) + return srv +} + +func (r *Srv) wantUp(state keybase1.MobileAppState) bool { + return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND +} + +// serverExited restarts a server whose listener died without a Stop, for +// example one the OS reclaimed while the app was suspended without ever +// reaching BACKGROUND. +func (r *Srv) serverExited() { + ctx := context.Background() + state, gen := r.G().MobileAppState.StateAndGeneration() + r.mu.Lock() + restart := r.wantUp(state) && r.exitRestartGen != gen+1 + if restart { + r.exitRestartGen = gen + 1 + } + r.mu.Unlock() + if restart { + r.debug(ctx, "serverExited: restarting in %v", state) + r.startHTTPSrv() + } else { + r.debug(ctx, "serverExited: not restarting in %v (generation %d)", state, gen) + } + r.mu.Lock() + r.exits++ + r.mu.Unlock() +} + // startHTTPSrv starts the server if it isn't serving, including after its // listener died underneath it. func (r *Srv) startHTTPSrv() { @@ -114,7 +158,7 @@ func (r *Srv) start(ctx context.Context) (info keybase1.HttpSrvInfo, started boo // 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.httpSrv = kbhttp.NewSrv(r.G().GetLog(), r.listenerSource()) + r.httpSrv = r.newHTTPSrv() continue } r.debug(ctx, "startHTTPSrv: failed to start HTTP server: %s", err) @@ -131,11 +175,7 @@ func (r *Srv) start(ctx context.Context) (info keybase1.HttpSrvInfo, started boo if err != nil { r.debug(ctx, "startHTTPSrv: failed to get address after start?: %s", err) } - tokenPrefix := r.token - if len(tokenPrefix) > 8 { - tokenPrefix = tokenPrefix[:8] + "..." - } - r.debug(ctx, "startHTTPSrv: addr: %s token: %s", addr, tokenPrefix) + r.debug(ctx, "startHTTPSrv: addr: %s token: %s", addr, TokenPrefix(r.token)) return keybase1.HttpSrvInfo{ Address: addr, Token: r.token, @@ -159,11 +199,12 @@ func (r *Srv) stop() { r.httpSrv.Stop() } -// reconcile tears the server down only in BACKGROUND. INACTIVE (Control -// Center, system alerts, the app switcher) keeps it up, and every other state -// restarts it if it isn't serving. +// 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 restarts it if it isn't +// serving. func (r *Srv) reconcile(state keybase1.MobileAppState) { - if state == keybase1.MobileAppState_BACKGROUND { + if !r.wantUp(state) { r.stopHTTPSrv() return } @@ -217,7 +258,7 @@ func (r *Srv) checkToken(tokenMode SrvTokenMode, 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 } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index d09fe3eb6daf..486b46cbd0fd 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -1,6 +1,7 @@ package manager import ( + "errors" "fmt" "io" "math/rand" @@ -26,6 +27,17 @@ type listeners struct { sync.Mutex calls int last net.Listener + // failing makes new listeners fail on their first Accept, so Serve + // returns right away. + failing atomic.Bool +} + +type failingListener struct { + net.Listener +} + +func (failingListener) Accept() (net.Conn, error) { + return nil, errors.New("listener failed") } type trackedSource struct { @@ -40,6 +52,9 @@ func (s trackedSource) GetListener() (net.Listener, string, error) { s.l.calls++ if err == nil { s.l.last = listener + if s.l.failing.Load() { + listener = failingListener{listener} + } } return listener, address, err } @@ -65,12 +80,12 @@ var client = &http.Client{ Transport: &http.Transport{DisableKeepAlives: true}, } -func setup(t *testing.T, state keybase1.MobileAppState, followAppState bool) (*Srv, *listeners) { +func setup(t *testing.T, state keybase1.MobileAppState, stopInBackground bool) (*Srv, *listeners) { tc := libkb.SetupTest(t, "kbhttp", 2) t.Cleanup(tc.Cleanup) tc.G.MobileAppState.Update(state) l := &listeners{} - srv := newSrv(tc.G, l.source, followAppState) + srv := newSrv(tc.G, l.source, stopInBackground) srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { fmt.Fprint(w, "ok") }) @@ -115,6 +130,34 @@ func waitMonitor(t *testing.T, srv *Srv) { }, 10*time.Second, time.Millisecond, "monitor did not catch up") } +func exits(srv *Srv) int { + srv.mu.Lock() + defer srv.mu.Unlock() + return srv.exits +} + +func waitExits(t *testing.T, srv *Srv, n int) { + t.Helper() + require.Eventually(t, func() bool { return exits(srv) >= n }, 10*time.Second, time.Millisecond, + "unexpected exit %d was not handled", n) + require.Equal(t, n, exits(srv)) +} + +// killUntilDown kills the listener until an unexpected exit is not +// restarted, because this app-state generation already had its restart. +func killUntilDown(t *testing.T, srv *Srv, l *listeners) { + t.Helper() + for range 2 { + n := exits(srv) + l.kill(t) + waitExits(t, srv, n+1) + if !srv.Active() { + return + } + } + t.Fatal("server kept restarting after unexpected exits") +} + func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { t.Helper() require.True(t, srv.Active(), "server not active") @@ -141,15 +184,64 @@ func TestDeadListenerRestartsOnTransition(t *testing.T) { keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE, } { - l.kill(t) - require.Eventually(t, func() bool { return !srv.Active() }, 5*time.Second, time.Millisecond, - "dead server still reported active") + killUntilDown(t, srv, l) srv.G().MobileAppState.Update(next) waitMonitor(t, srv) requireServing(t, srv) } } +func TestDeadListenerRestartsWithoutTransition(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + first := requireServing(t, srv) + l.kill(t) + waitExits(t, srv, 1) + again := requireServing(t, srv) + require.Equal(t, first.Token, again.Token) + require.Equal(t, 2, l.Calls()) +} + +func TestUnexpectedExitRestartsOncePerGeneration(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + requireServing(t, srv) + + l.failing.Store(true) + l.kill(t) + // The restart's listener fails at once; its exit must not restart again. + waitExits(t, srv, 2) + time.Sleep(50 * time.Millisecond) + require.Equal(t, 2, exits(srv), "restart loop on a failing listener") + require.Equal(t, 2, l.Calls()) + requireStopped(t, srv) + + // A new generation allows one more restart after the monitor's own. + srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + waitMonitor(t, srv) + waitExits(t, srv, 4) + time.Sleep(50 * time.Millisecond) + require.Equal(t, 4, exits(srv), "restart loop on a failing listener") + require.Equal(t, 4, l.Calls()) + + l.failing.Store(false) + srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + waitMonitor(t, srv) + requireServing(t, srv) +} + +func TestNothingStartsAfterShutdown(t *testing.T) { + srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + requireServing(t, srv) + srv.stop() + requireStopped(t, srv) + srv.reconcile(keybase1.MobileAppState_FOREGROUND) + require.False(t, srv.Active(), "reconcile restarted the server after shutdown") + srv.serverExited() + require.False(t, srv.Active(), "an unexpected exit restarted the server after shutdown") +} + func TestInactiveKeepsServingBackgroundStops(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) waitMonitor(t, srv) @@ -170,11 +262,11 @@ func TestInactiveKeepsServingBackgroundStops(t *testing.T) { srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) waitMonitor(t, srv) again := requireServing(t, srv) - require.Equal(t, 2, l.Calls()) + // 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()) - // A URL handed out before the restart still works on the pinned port. - _, err = fetch(first) + _, err = fetch(keybase1.HttpSrvInfo{Address: again.Address, Token: first.Token}) require.NoError(t, err) } @@ -190,24 +282,32 @@ func TestBackgroundLaunchStartsOnlyWhenLeavingBackground(t *testing.T) { requireServing(t, srv) } -func TestNotFollowingAppStateStaysUp(t *testing.T) { - srv, _ := setup(t, keybase1.MobileAppState_BACKGROUND, false) +func TestNotStoppingInBackgroundStaysUp(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_BACKGROUND, false) + waitMonitor(t, srv) requireServing(t, srv) - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUND, + } { + srv.G().MobileAppState.Update(next) + waitMonitor(t, srv) + requireServing(t, srv) + } + killUntilDown(t, srv, l) + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + waitMonitor(t, srv) requireServing(t, srv) } func TestScenarioReplay(t *testing.T) { for _, sc := range lifecycletest.Scenarios { t.Run(sc.Name, func(t *testing.T) { - followAppState := sc.Platform == lifecycletest.IOS - srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, followAppState) + stopInBackground := sc.Platform == lifecycletest.IOS + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, stopInBackground) lifecycletest.Play(t, srv.G().MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { - if followAppState { - waitMonitor(t, srv) - } - if followAppState && step.Want == keybase1.MobileAppState_BACKGROUND { + waitMonitor(t, srv) + if !srv.wantUp(step.Want) { if srv.Active() { t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) } @@ -221,6 +321,14 @@ func TestScenarioReplay(t *testing.T) { if _, err := fetch(info); err != nil { t.Fatalf("step %d %v: %v", i, step.Do, err) } + // Leave the server dead before a step that moves to another + // up state, which must bring it back. + if i+1 < len(sc.Steps) { + next := sc.Steps[i+1].Want + if next != step.Want && srv.wantUp(next) { + killUntilDown(t, srv, l) + } + } }) }) } @@ -453,8 +561,6 @@ func TestStressTransitionsAndRequests(t *testing.T) { case <-time.After(10 * time.Second): t.Fatal("monitor did not exit on shutdown") } - tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - require.False(t, srv.Active(), "server restarted after shutdown") deadline := time.Now().Add(10 * time.Second) for runtime.NumGoroutine() > baseline+5 && time.Now().Before(deadline) { diff --git a/go/kbhttp/srv.go b/go/kbhttp/srv.go index d6610f2e7466..abc4e6fb6359 100644 --- a/go/kbhttp/srv.go +++ b/go/kbhttp/srv.go @@ -148,6 +148,7 @@ type Srv struct { listenerSource ListenerSource server *http.Server doneCh chan struct{} + onExit func() } // NewSrv creates a new HTTP server with the given listener @@ -159,6 +160,15 @@ func NewSrv(log logger.Logger, listenerSource ListenerSource) *Srv { } } +// OnUnexpectedExit sets f to run whenever the server stops serving without +// Stop, as when its listener is closed underneath it. f runs without the +// server's lock held, so it may call back into the server. +func (h *Srv) OnUnexpectedExit(f func()) { + h.Lock() + defer h.Unlock() + h.onExit = f +} + // Start starts listening on the server's listener source. func (h *Srv) Start() (err error) { return h.StartWithHandlers(nil) @@ -199,11 +209,16 @@ func (h *Srv) StartWithHandlers(register func(mux *http.ServeMux)) (err error) { // Serve can return without Stop (the listener was closed underneath // us), so forget the dead server or Start could never run again. A // Stop and a newer Start may already have replaced it. - if h.server == server { + unexpected := h.server == server + if unexpected { h.server = nil } + onExit := h.onExit h.Unlock() close(doneCh) + if unexpected && onExit != nil { + onExit() + } }(h.server, h.doneCh) return nil } diff --git a/go/kbhttp/srv_test.go b/go/kbhttp/srv_test.go index 0b403e25bb8f..0e270b643490 100644 --- a/go/kbhttp/srv_test.go +++ b/go/kbhttp/srv_test.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "sync" + "sync/atomic" "testing" "time" @@ -114,3 +115,22 @@ func TestSrvOldServeExitKeepsNewServer(t *testing.T) { require.True(t, srv.Active()) <-srv.Stop() } + +func TestSrvOnUnexpectedExit(t *testing.T) { + source := &capturingListenerSource{} + srv := NewSrv(logger.NewTestLogger(t), source) + var exits atomic.Int32 + srv.OnUnexpectedExit(func() { + // Must not deadlock: the callback runs without the server's lock. + _ = srv.Active() + exits.Add(1) + }) + + require.NoError(t, srv.Start()) + <-srv.Stop() + require.NoError(t, srv.Start()) + source.kill() + require.Eventually(t, func() bool { return exits.Load() >= 1 }, 5*time.Second, time.Millisecond) + time.Sleep(50 * time.Millisecond) + require.Equal(t, int32(1), exits.Load(), "Stop reported as an unexpected exit") +} diff --git a/go/service/config.go b/go/service/config.go index e1896d952821..902ca1f1967f 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" @@ -366,7 +367,7 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r if infoErr != nil { m.Debug("GetBootstrapStatus: failed to get HTTP server address: %s", infoErr) } else { - m.Debug("GetBootstrapStatus: http server: addr: %s token: %s", info.Address, info.Token) + m.Debug("GetBootstrapStatus: http server: addr: %s token: %s", info.Address, manager.TokenPrefix(info.Token)) res.HttpSrvInfo = &info break } From 6c50bbefc1776d3cae1f4c40fc8a009e9a4cc83e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:35:51 -0400 Subject: [PATCH 012/127] fix(kbhttp): decide and restart after an unexpected exit under the manager lock Reading the app state outside the lock let a BACKGROUND applied by the monitor land between the read and the restart, leaving the server up in the background. --- go/kbhttp/manager/manager.go | 32 +++++++++++++++---------- go/kbhttp/manager/manager_test.go | 40 ++++++++++++++++++++++++++----- go/kbhttp/srv.go | 5 ++-- go/kbhttp/srv_test.go | 6 ++--- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 11b53f36307f..7d615e325987 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -48,6 +48,9 @@ type Srv struct { exitRestartGen uint64 // exits counts handled unexpected exits, for tests. exits int + // beforeExitRestart, if set, runs in serverExited between reading the + // app state and acting on it. Tests only. + beforeExitRestart func() // monitorState is the state the monitor last acted on, and monitorWait // the change channel it is waiting on for that state; tests use them to // wait until the monitor has caught up. @@ -114,38 +117,43 @@ func (r *Srv) wantUp(state keybase1.MobileAppState) bool { // reaching BACKGROUND. func (r *Srv) serverExited() { ctx := context.Background() - state, gen := r.G().MobileAppState.StateAndGeneration() r.mu.Lock() - restart := r.wantUp(state) && r.exitRestartGen != gen+1 - if restart { - r.exitRestartGen = gen + 1 + // Read the state and start under mu, so a BACKGROUND the monitor applies + // concurrently either comes first (seen here) or stops what starts here. + state, gen := r.G().MobileAppState.StateAndGeneration() + if r.beforeExitRestart != nil { + r.beforeExitRestart() } - r.mu.Unlock() - if restart { + var info keybase1.HttpSrvInfo + started := false + if r.wantUp(state) && r.exitRestartGen != gen+1 { + r.exitRestartGen = gen + 1 r.debug(ctx, "serverExited: restarting in %v", state) - r.startHTTPSrv() + info, started = r.startLocked(ctx) } else { r.debug(ctx, "serverExited: not restarting in %v (generation %d)", state, gen) } - r.mu.Lock() r.exits++ r.mu.Unlock() + if started { + r.G().NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + } } // startHTTPSrv starts the server if it isn't serving, including after its // listener died underneath it. func (r *Srv) startHTTPSrv() { ctx := context.Background() - info, started := r.start(ctx) + r.mu.Lock() + info, started := r.startLocked(ctx) + r.mu.Unlock() if !started { return } r.G().NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) } -func (r *Srv) start(ctx context.Context) (info keybase1.HttpSrvInfo, started bool) { - r.mu.Lock() - defer r.mu.Unlock() +func (r *Srv) startLocked(ctx context.Context) (info keybase1.HttpSrvInfo, started bool) { if r.shutdown || r.httpSrv.Active() { return info, false } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index 486b46cbd0fd..e570d3ab0df0 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -210,19 +210,17 @@ func TestUnexpectedExitRestartsOncePerGeneration(t *testing.T) { l.failing.Store(true) l.kill(t) // The restart's listener fails at once; its exit must not restart again. + // Each exit decides and starts under mu, so once two exits are handled + // the listener count is final. waitExits(t, srv, 2) - time.Sleep(50 * time.Millisecond) - require.Equal(t, 2, exits(srv), "restart loop on a failing listener") - require.Equal(t, 2, l.Calls()) + require.Equal(t, 2, l.Calls(), "restart loop on a failing listener") requireStopped(t, srv) // A new generation allows one more restart after the monitor's own. srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) waitMonitor(t, srv) waitExits(t, srv, 4) - time.Sleep(50 * time.Millisecond) - require.Equal(t, 4, exits(srv), "restart loop on a failing listener") - require.Equal(t, 4, l.Calls()) + require.Equal(t, 4, l.Calls(), "restart loop on a failing listener") l.failing.Store(false) srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) @@ -230,6 +228,36 @@ func TestUnexpectedExitRestartsOncePerGeneration(t *testing.T) { requireServing(t, srv) } +// A BACKGROUND applied while an unexpected exit is deciding whether to +// restart must not leave the server up. +func TestUnexpectedExitRacingBackground(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitMonitor(t, srv) + requireServing(t, srv) + + srv.mu.Lock() + srv.beforeExitRestart = func() { + // serverExited has read FOREGROUND. The monitor is idle, so mu is + // held here only if serverExited holds it; otherwise let the monitor + // fully apply BACKGROUND before serverExited acts on its stale read. + holdsMu := !srv.mu.TryLock() + if !holdsMu { + srv.mu.Unlock() + } + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + if !holdsMu { + waitMonitor(t, srv) + } + } + srv.mu.Unlock() + + l.kill(t) + waitExits(t, srv, 1) + waitMonitor(t, srv) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, srv.G().MobileAppState.State()) + requireStopped(t, srv) +} + func TestNothingStartsAfterShutdown(t *testing.T) { srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, true) waitMonitor(t, srv) diff --git a/go/kbhttp/srv.go b/go/kbhttp/srv.go index abc4e6fb6359..a553fe669c0a 100644 --- a/go/kbhttp/srv.go +++ b/go/kbhttp/srv.go @@ -162,7 +162,8 @@ func NewSrv(log logger.Logger, listenerSource ListenerSource) *Srv { // OnUnexpectedExit sets f to run whenever the server stops serving without // Stop, as when its listener is closed underneath it. f runs without the -// server's lock held, so it may call back into the server. +// server's lock held, so it may call back into the server, and before that +// server's done channel closes. func (h *Srv) OnUnexpectedExit(f func()) { h.Lock() defer h.Unlock() @@ -215,10 +216,10 @@ func (h *Srv) StartWithHandlers(register func(mux *http.ServeMux)) (err error) { } onExit := h.onExit h.Unlock() - close(doneCh) if unexpected && onExit != nil { onExit() } + close(doneCh) }(h.server, h.doneCh) return nil } diff --git a/go/kbhttp/srv_test.go b/go/kbhttp/srv_test.go index 0e270b643490..f2132c8e5271 100644 --- a/go/kbhttp/srv_test.go +++ b/go/kbhttp/srv_test.go @@ -127,10 +127,10 @@ func TestSrvOnUnexpectedExit(t *testing.T) { }) require.NoError(t, srv.Start()) + // The done channel closes only after any exit callback has run. <-srv.Stop() + require.Equal(t, int32(0), exits.Load(), "Stop reported as an unexpected exit") require.NoError(t, srv.Start()) source.kill() - require.Eventually(t, func() bool { return exits.Load() >= 1 }, 5*time.Second, time.Millisecond) - time.Sleep(50 * time.Millisecond) - require.Equal(t, int32(1), exits.Load(), "Stop reported as an unexpected exit") + require.Eventually(t, func() bool { return exits.Load() == 1 }, 5*time.Second, time.Millisecond) } From 951ebe992e724f5c9f1b51d797d3c1e3c72e249c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:48:52 -0400 Subject: [PATCH 013/127] fix(gregor): gate connects on app state and keep the connection up in INACTIVE Startup, login and reconnect connects no longer connect while BACKGROUND; the URI is remembered so the monitor connects on leaving BACKGROUND. The monitor seeds from the current state, and it and every connect decide under one lock, so a BACKGROUND racing a connect still disconnects. Only BACKGROUND or a desktop suspend disconnects. Also read the URI and each connection's shutdown channel under the connection lock, so a ping loop exits with its own connection, and guard the non-TLS transport against dial/close races. --- go/service/gregor.go | 111 +++----- go/service/gregor_conn.go | 173 ++++++++++++ go/service/gregor_conn_test.go | 497 +++++++++++++++++++++++++++++++++ go/service/main.go | 13 +- go/service/rpc.go | 55 ++-- 5 files changed, 750 insertions(+), 99 deletions(-) create mode 100644 go/service/gregor_conn.go create mode 100644 go/service/gregor_conn_test.go diff --git a/go/service/gregor.go b/go/service/gregor.go index 5fe2ba3edbc9..76b1f0c39ad0 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -200,6 +200,9 @@ type gregorHandler struct { reachability *reachability chatLog utils.DebugLabeler + // connGate decides when to connect and disconnect + connGate *gregorConnGate + // This mutex protects the con object connMutex sync.Mutex conn *rpc.Connection @@ -250,6 +253,7 @@ func newGregorHandler(g *globals.Context) *gregorHandler { pushStateCh: make(chan struct{}, 100), forcePingCh: make(chan struct{}, 5), } + gh.connGate = newGregorConnGate(g.ExternalG(), gh, gh.chatLog.Debug, gh.forcePing) return gh } @@ -258,63 +262,18 @@ 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 { + g.connMutex.Lock() + defer g.connMutex.Unlock() return g.uri } @@ -437,7 +396,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, in which case it +// connects once the app leaves BACKGROUND. +func (g *gregorHandler) Connect(uri *rpc.FMPURI) error { + return g.connGate.connect(libkb.WithLogTag(context.Background(), "GRGRCONN"), uri, false) +} + +// ConnectFresh is Connect, resetting any live 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")() @@ -1391,8 +1362,11 @@ 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, + g.connMutex.Lock() + shutdownCh := g.shutdownCh + g.connMutex.Unlock() select { - case <-g.shutdownCh: + case <-shutdownCh: return uid, did, token, nil, loggedInMaybe default: // if we were going to block, then that means we are still alive @@ -1473,16 +1447,7 @@ func (g *gregorHandler) isReachable(ctx context.Context) bool { } 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 + return g.connGate.reconnect(ctx) } func (g *gregorHandler) forcePing(ctx context.Context) { @@ -1493,7 +1458,7 @@ 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, shutdownCh chan struct{}, shutdownCancel context.CancelFunc) { var err error doneCh := make(chan error) timeout := g.G().Env.GetGregorPingTimeout() @@ -1525,7 +1490,7 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCancel select { case err = <-doneCh: - case <-g.shutdownCh: + case <-shutdownCh: g.chatLog.Debug(ctx, "ping loop: id: %x shutdown received", id) shutdownCancel() return @@ -1550,7 +1515,9 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCancel } } -func (g *gregorHandler) pingLoop(ctx context.Context) { +// pingLoop runs until shutdownCh, the channel of the connection it was +// started for, closes. +func (g *gregorHandler) pingLoop(ctx context.Context, shutdownCh chan struct{}) { id, _ := libkb.RandBytes(4) duration := g.G().Env.GetGregorPingInterval() timeout := g.G().Env.GetGregorPingTimeout() @@ -1568,10 +1535,10 @@ func (g *gregorHandler) pingLoop(ctx context.Context) { select { case <-g.forcePingCh: g.chatLog.Debug(pingCtx, "ping loop: forced attempt") - g.pingOnce(pingCtx, id, shutdownCancel) + g.pingOnce(pingCtx, id, shutdownCh, shutdownCancel) case <-ticker.C: - g.pingOnce(pingCtx, id, shutdownCancel) - case <-g.shutdownCh: + g.pingOnce(pingCtx, id, shutdownCh, shutdownCancel) + case <-shutdownCh: g.chatLog.Debug(pingCtx, "ping loop: id: %x shutdown received", id) shutdownCancel() return @@ -1627,7 +1594,7 @@ func (g *gregorHandler) connectTLS(ctx context.Context) error { // 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) + go g.pingLoop(ctx, g.shutdownCh) return nil } @@ -1660,7 +1627,7 @@ func (g *gregorHandler) connectNoTLS(ctx context.Context) error { // 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) + go g.pingLoop(ctx, g.shutdownCh) return nil } diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go new file mode 100644 index 000000000000..a9385c7b63b0 --- /dev/null +++ b/go/service/gregor_conn.go @@ -0,0 +1,173 @@ +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 +} + +// 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. +type gregorConnGate struct { + mobile *libkb.MobileAppState + 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 in BACKGROUND, so the monitor connects once the app leaves + // BACKGROUND. + uri *rpc.FMPURI + // beforeConnect, if set, runs in connect between reading the app state + // and acting on it. Tests only. + beforeConnect func() + // 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{} + + startOnce sync.Once + stopOnce sync.Once + stopCh chan struct{} + monitorDone chan struct{} +} + +func newGregorConnGate(g *libkb.GlobalContext, conn gregorConnector, + debug func(ctx context.Context, format string, args ...any), onForeground func(ctx context.Context), +) *gregorConnGate { + return &gregorConnGate{ + mobile: g.MobileAppState, + desktop: g.DesktopAppState, + conn: conn, + debug: debug, + onForeground: onForeground, + stopCh: make(chan struct{}), + monitorDone: make(chan struct{}), + } +} + +func (c *gregorConnGate) canConnect(state keybase1.MobileAppState) bool { + return state != keybase1.MobileAppState_BACKGROUND +} + +// 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 unless the app is in BACKGROUND. With reset, an +// existing connection is reset first so it authenticates again. +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 && c.conn.IsConnected() { + if err := c.conn.Reset(); err != nil { + return err + } + } + state := c.mobile.State() + if c.beforeConnect != nil { + c.beforeConnect() + } + if !c.canConnect(state) { + c.debug(ctx, "connect: not connecting in %v", state) + return nil + } + return c.conn.connectNow(uri) +} + +// reconnect drops a live connection and connects again, unless the app is +// now in BACKGROUND. didShutdown reports whether a connection was dropped. +func (c *gregorConnGate) reconnect(ctx context.Context) (didShutdown bool, err error) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.conn.IsConnected() { + c.debug(ctx, "Reconnect: skipping reconnect, already disconnected") + return false, nil + } + c.debug(ctx, "Reconnect: reconnecting to server") + c.conn.Shutdown(ctx) + if state := c.mobile.State(); !c.canConnect(state) { + c.debug(ctx, "Reconnect: not connecting in %v", state) + return true, nil + } + return true, c.conn.connectNow(c.uri) +} + +func (c *gregorConnGate) reconcile(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + state, suspended := c.mobile.State(), c.desktop.Suspended() + if !c.canConnect(state) || suspended { + c.debug(ctx, "reconcile: disconnecting in %v (suspended: %v)", state, suspended) + c.conn.Shutdown(ctx) + return + } + // Nothing asked to connect yet, for example before login. + if c.uri == nil { + return + } + c.debug(ctx, "reconcile: connecting in %v", state) + if err := c.conn.connectNow(c.uri); err != nil { + c.debug(ctx, "reconcile: error connecting: %s", err) + } +} + +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.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..8b90fad0f63b --- /dev/null +++ b/go/service/gregor_conn_test.go @@ -0,0 +1,497 @@ +package service + +import ( + "context" + "fmt" + "math/rand" + "net" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/chat" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/go-framed-msgpack-rpc/rpc" + "github.com/stretchr/testify/require" +) + +type fakeGregorConn struct { + sync.Mutex + 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.up { + f.up = true + f.uri = uri + f.connects++ + } + return nil +} + +func (f *fakeGregorConn) Shutdown(context.Context) { + f.Lock() + defer f.Unlock() + if f.up { + f.up = false + f.shutdowns++ + } +} + +func (f *fakeGregorConn) Reset() error { + f.Shutdown(context.Background()) + f.Lock() + defer f.Unlock() + f.resets++ + return nil +} + +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 +} + +type gregorConnTest struct { + tc libkb.TestContext + gate *gregorConnGate + 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{} + gate := newGregorConnGate(tc.G, 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, 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}, 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}, 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: 1}, 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()) +} + +// 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.gate.beforeConnect = 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)) + + didShutdown, err := c.gate.reconnect(context.Background()) + require.NoError(t, err) + require.True(t, didShutdown) + 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)) + didShutdown, err = c.gate.reconnect(context.Background()) + require.NoError(t, err) + require.True(t, didShutdown) + c.requireUp(t, false, "reconnect connected in BACKGROUND") + + didShutdown, err = c.gate.reconnect(context.Background()) + require.NoError(t, err) + require.False(t, didShutdown) + c.requireUp(t, false, "reconnect connected while disconnected") +} + +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()) +} + +// 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, sc.Platform.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) + } + }) + }) + } +} + +func TestGregorConnStress(t *testing.T) { + tc := libkb.SetupTest(t, "gregorconn", 1) + defer tc.Cleanup() + baseline := runtime.NumGoroutine() + + conn := &fakeGregorConn{} + gate := newGregorConnGate(tc.G, conn, func(context.Context, string, ...any) {}, func(context.Context) {}) + gate.start() + c := &gregorConnTest{tc: tc, gate: gate, 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) % 3 { + case 0: + _ = gate.connect(ctx, uri, false) + case 1: + _ = gate.connect(ctx, uri, true) + default: + _, _ = gate.reconnect(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): + t.Fatal("deadlock: transitions and connects did not finish") + } + + // Make real changes so the monitor must wake for them. + final := keybase1.MobileAppState_INACTIVE + if tc.G.MobileAppState.State() == final { + final = keybase1.MobileAppState_FOREGROUND + } + c.update(t, final) + c.requireUp(t, true, "down after settling in "+final.String()) + c.update(t, keybase1.MobileAppState_BACKGROUND) + c.requireUp(t, false, "up after settling in BACKGROUND") + 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 the connection's own goroutines: OnConnect +// reads the URI, the ping loop watches its shutdown channel, and the +// transport dials. +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: + } + _ = h.GetURI() + 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, h.GetURI()) +} + +// 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. +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()) +} diff --git a/go/service/main.go b/go/service/main.go index e780fb65be58..145282286ee9 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -1071,16 +1071,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. + return d.gregor.ConnectFresh(uri) } // ReleaseLock releases the locking pidfile by closing, unlocking and diff --git a/go/service/rpc.go b/go/service/rpc.go index 15c9994e64db..cbce0a32e442 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: closing waits for the +// receiver, whose handlers may call IsConnected. 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 } From cddc642f988ea02e5b9273c8389a3000121966cd Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 13:56:02 -0400 Subject: [PATCH 014/127] fix(gregor): keep gregor down after logout and replace a stale connection on login Logout now resets through the connection gate and forgets the URI, so no app-state transition reconnects while logged out. A login resets any existing connection, including one left unconnected by a failed auth, so connectNow dials again instead of skipping on a non-nil conn. --- go/service/gregor.go | 8 +- go/service/gregor_conn.go | 18 +++- go/service/gregor_conn_test.go | 147 +++++++++++++++++++++++++++++---- go/service/main.go | 2 +- go/service/rpc.go | 4 +- 5 files changed, 157 insertions(+), 22 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 76b1f0c39ad0..b7d6c232543a 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -402,7 +402,7 @@ func (g *gregorHandler) Connect(uri *rpc.FMPURI) error { return g.connGate.connect(libkb.WithLogTag(context.Background(), "GRGRCONN"), uri, false) } -// ConnectFresh is Connect, resetting any live connection first so it +// 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) @@ -1345,6 +1345,12 @@ func (g *gregorHandler) Shutdown(ctx context.Context) { 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) diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index a9385c7b63b0..3caff162c047 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -89,13 +89,15 @@ func (c *gregorConnGate) stop() { c.stopOnce.Do(func() { close(c.stopCh) }) } -// connect connects to uri unless the app is in BACKGROUND. With reset, an -// existing connection is reset first so it authenticates again. +// connect connects to uri unless the app is in BACKGROUND. 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 && c.conn.IsConnected() { + if reset { if err := c.conn.Reset(); err != nil { return err } @@ -111,6 +113,16 @@ func (c *gregorConnGate) connect(ctx context.Context, uri *rpc.FMPURI, reset boo return c.conn.connectNow(uri) } +// 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() +} + // reconnect drops a live connection and connects again, unless the app is // now in BACKGROUND. didShutdown reports whether a connection was dropped. func (c *gregorConnGate) reconnect(ctx context.Context) (didShutdown bool, err error) { diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index 8b90fad0f63b..089fe6265409 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -19,8 +19,11 @@ import ( "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 @@ -31,8 +34,8 @@ type fakeGregorConn struct { func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { f.Lock() defer f.Unlock() - if !f.up { - f.up = true + if !f.exists { + f.exists, f.up = true, true f.uri = uri f.connects++ } @@ -42,8 +45,8 @@ func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { func (f *fakeGregorConn) Shutdown(context.Context) { f.Lock() defer f.Unlock() - if f.up { - f.up = false + if f.exists { + f.exists, f.up = false, false f.shutdowns++ } } @@ -56,6 +59,14 @@ func (f *fakeGregorConn) Reset() error { 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() @@ -170,7 +181,7 @@ func TestGregorConnLoginInBackground(t *testing.T) { 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}, c.conn.counts()) + 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") @@ -178,7 +189,7 @@ func TestGregorConnLoginInBackground(t *testing.T) { 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}, c.conn.counts()) + 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") @@ -186,7 +197,7 @@ func TestGregorConnLoginInBackground(t *testing.T) { // 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: 1}, c.conn.counts()) + require.Equal(t, fakeGregorCounts{up: true, connects: 3, shutdowns: 2, resets: 3}, c.conn.counts()) require.Equal(t, first, c.conn.lastURI()) } @@ -236,6 +247,72 @@ func TestGregorConnDuplicateEvents(t *testing.T) { 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) + _, err := c.gate.reconnect(context.Background()) + require.NoError(t, err) + 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) { @@ -324,6 +401,27 @@ func TestGregorConnScenarioReplay(t *testing.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) + } }) }) } @@ -359,11 +457,13 @@ func TestGregorConnStress(t *testing.T) { return default: } - switch (i + w) % 3 { + 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.reconnect(ctx) } @@ -398,15 +498,14 @@ func TestGregorConnStress(t *testing.T) { t.Fatal("deadlock: transitions and connects did not finish") } - // Make real changes so the monitor must wake for them. - final := keybase1.MobileAppState_INACTIVE - if tc.G.MobileAppState.State() == final { - final = keybase1.MobileAppState_FOREGROUND - } - c.update(t, final) - c.requireUp(t, true, "down after settling in "+final.String()) + 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) @@ -477,6 +576,24 @@ func hasConn(h *gregorHandler) bool { } // 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") +} + func TestGregorHandlerConnectInBackground(t *testing.T) { tc, g := setupGregorTest(t) defer tc.Cleanup() diff --git a/go/service/main.go b/go/service/main.go index 145282286ee9..951c0babfaac 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -1032,7 +1032,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") diff --git a/go/service/rpc.go b/go/service/rpc.go index cbce0a32e442..d0c9838d6c89 100644 --- a/go/service/rpc.go +++ b/go/service/rpc.go @@ -53,8 +53,8 @@ func (t *connTransport) IsConnected() bool { return transport != nil && transport.IsConnected() } -// Finalize and Close close transports outside mu: closing waits for the -// receiver, whose handlers may call 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() { t.mu.Lock() old := t.transport From 076bd963af5bfd97f32a103cc88ee94db9914632 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 14:03:31 -0400 Subject: [PATCH 015/127] fix(gregor): install a gregor client only for the current connection An OnConnect that passed its connection check before a logout or reconnect could still install a client for the dropped connection. The install now rechecks the connection under the lock Shutdown takes. Also cover how a connection whose auth failed terminally recovers: the ping loop redials at the ping interval and FOREGROUND redials at once. --- go/service/gregor.go | 32 ++++++++- go/service/gregor_conn_test.go | 128 +++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index b7d6c232543a..ef53a02454c8 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -231,8 +231,11 @@ type gregorHandler struct { forcePingCh chan struct{} // Testing - testingEvents *testingEvents - transportForTesting *connTransport + testingEvents *testingEvents + // beforeGregorClientInstall, if set, runs in resetGregorClientFor after + // the client is built and before it is installed. + beforeGregorClientInstall func() + transportForTesting *connTransport } var ( @@ -331,6 +334,16 @@ func (g *gregorHandler) shutdownGregorClient(ctx context.Context) { } func (g *gregorHandler) resetGregorClient(ctx context.Context, uid gregor1.UID, deviceID gregor1.DeviceID) (gcli *grclient.Client, err error) { + return g.resetGregorClientFor(ctx, nil, uid, deviceID) +} + +// resetGregorClientFor installs a new client for uid. With conn set, it +// installs only while conn is still the current connection, checked under +// the lock Shutdown takes, so an OnConnect that loses a race with a logout +// or a reconnect doesn't install a client for the old connection. +func (g *gregorHandler) resetGregorClientFor(ctx context.Context, conn *rpc.Connection, + 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 if uid != nil && deviceID != nil { @@ -345,6 +358,19 @@ func (g *gregorHandler) resetGregorClient(ctx context.Context, uid gregor1.UID, g.Debug(ctx, "restore local state failed: %s", err) } } + if g.beforeGregorClientInstall != nil { + g.beforeGregorClientInstall() + } + if conn != nil { + g.connMutex.Lock() + defer g.connMutex.Unlock() + if conn != g.conn { + if gcli != nil { + gcli.Stop() + } + return nil, chat.ErrDuplicateConnection + } + } g.gregorCliMu.Lock() gcliOld := g.gregorCli g.gregorCli = gcli @@ -769,7 +795,7 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, if err != nil { return err } - gcli, err := g.resetGregorClient(ctx, uid, deviceID) + gcli, err := g.resetGregorClientFor(ctx, conn, uid, deviceID) if err != nil { return fmt.Errorf("failed to get gregor client: %s", err) } diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index 089fe6265409..d24a79b2dba9 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -14,6 +14,7 @@ import ( "github.com/keybase/client/go/chat" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "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" @@ -612,3 +613,130 @@ func TestGregorHandlerConnectInBackground(t *testing.T) { 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") +} + +// An OnConnect that passed its connection check before a logout must not +// install a gregor client for the dropped connection. +func TestGregorClientInstallRacingLogout(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + ctx := context.Background() + + h := newGregorHandler(g) + require.NoError(t, h.Connect(closedPortURI(t))) + h.connMutex.Lock() + conn := h.conn + h.connMutex.Unlock() + uid := gregor1.UID(make([]byte, 16)) + deviceID := gregor1.DeviceID(make([]byte, 16)) + + gcli, err := h.resetGregorClientFor(ctx, conn, uid, deviceID) + require.NoError(t, err) + require.NotNil(t, gcli) + _, err = h.getGregorCli() + require.NoError(t, err, "current connection did not install its client") + + h.beforeGregorClientInstall = func() { require.NoError(t, h.Disconnect()) } + _, err = h.resetGregorClientFor(ctx, conn, uid, deviceID) + require.ErrorIs(t, err, chat.ErrDuplicateConnection) + _, err = h.getGregorCli() + require.Error(t, err, "installed a client for a connection logout dropped") +} From b804cfce49a52c61c001969e6c06918637c3f089 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 14:15:23 -0400 Subject: [PATCH 016/127] fix(gregor): keep OnConnect's post-sync steps from outliving their connection After SyncAll returns, a logout or reconnect could still let the old connection push its badges, mark the chat syncer connected, run the gregor state sync, and clear first connect for the next account. Each step now applies only while its connection is current: badge pushes hold a lock Shutdown takes, first connect is checked and written under the connection lock, and a syncer mark that loses to a Shutdown is undone unless a newer connection has marked it since. Wrap the client-install error with %w so a lost install is not retried. --- go/service/gregor.go | 178 +++++++++++++++++--- go/service/gregor_conn_test.go | 290 +++++++++++++++++++++++++++++++++ go/service/gregor_test.go | 25 +-- 3 files changed, 456 insertions(+), 37 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index ef53a02454c8..46236c66efa8 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -196,13 +196,21 @@ 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 connGate *gregorConnGate + // connTailMu serializes Shutdown with the steps OnConnect applies after + // syncing that can't be undone (badge pushes), so none lands after a + // Shutdown for the connection it came from. Taken before connMutex. + connTailMu sync.Mutex + // syncerConn is the connection that last marked the chat syncer + // connected, under connMutex. + syncerConn *rpc.Connection + // This mutex protects the con object connMutex sync.Mutex conn *rpc.Connection @@ -235,9 +243,31 @@ type gregorHandler struct { // beforeGregorClientInstall, if set, runs in resetGregorClientFor after // the client is built and before it is installed. beforeGregorClientInstall func() - transportForTesting *connTransport + // authParamsForTest, if set, replaces authParams in OnConnect. + authParamsForTest func(ctx context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) + // onConnectStep, if set, runs before each step of onConnectSynced. + onConnectStep func(step onConnectStep) + 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) + +type onConnectStep int + +const ( + onConnectStepChatBadges onConnectStep = iota + onConnectStepSyncer + onConnectStepServerSync + onConnectStepGregorBadges + onConnectStepConnected +) + var ( _ libkb.GregorState = (*gregorHandler)(nil) _ libkb.GregorListener = (*gregorHandler)(nil) @@ -791,13 +821,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.resetGregorClientFor(ctx, conn, 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) @@ -838,6 +873,73 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, return fmt.Errorf("error authenticating: %s", err) } + return g.onConnectSynced(ctx, conn, chatCli, timeoutCli, uid, gcli, syncAllRes) +} + +func (g *gregorHandler) runOnConnectStep(step onConnectStep) { + if g.onConnectStep != nil { + g.onConnectStep(step) + } +} + +func (g *gregorHandler) isCurrentConn(conn *rpc.Connection) bool { + g.connMutex.Lock() + defer g.connMutex.Unlock() + return conn == g.conn +} + +// ifCurrentConnTail runs f and reports true if conn is still the current +// connection, holding connTailMu so a Shutdown can't land while f runs. +func (g *gregorHandler) ifCurrentConnTail(conn *rpc.Connection, f func()) bool { + g.connTailMu.Lock() + defer g.connTailMu.Unlock() + current := g.isCurrentConn(conn) + if current { + f() + } + return current +} + +// connectSyncer marks the chat syncer connected for conn and syncs it. +// Syncer.Connected can't run under a lock Shutdown takes, since the sync +// calls the server through this handler, so a Shutdown can land while it +// runs; conn then undoes its own mark, unless a newer connection has marked +// the syncer since. +func (g *gregorHandler) connectSyncer(ctx context.Context, conn *rpc.Connection, chatCli chat1.RemoteInterface, + uid gregor1.UID, syncRes *chat1.SyncChatRes, +) error { + g.connMutex.Lock() + if conn != g.conn { + g.connMutex.Unlock() + return chat.ErrDuplicateConnection + } + g.syncerConn = conn + g.connMutex.Unlock() + + err := g.G().Syncer.Connected(ctx, chatCli, uid, syncRes) + + g.connMutex.Lock() + defer g.connMutex.Unlock() + if conn != g.conn { + if g.syncerConn == conn { + g.chatLog.Debug(ctx, "connection dropped during chat sync, marking the syncer disconnected") + g.G().Syncer.Disconnected(ctx) + g.syncerConn = nil + } + return chat.ErrDuplicateConnection + } + if err != nil { + return fmt.Errorf("error running chat sync: %s", err) + } + return nil +} + +// onConnectSynced applies a SyncAll result for conn. A logout or reconnect +// can drop conn at any point, so each step applies only while conn is still +// current, and OnConnect then fails with ErrDuplicateConnection. +func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connection, 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 @@ -845,40 +947,54 @@ 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) + g.runOnConnectStep(onConnectStepChatBadges) + if !g.ifCurrentConnTail(conn, 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 { - return fmt.Errorf("error running chat sync: %s", err) + g.runOnConnectStep(onConnectStepSyncer) + if err := g.connectSyncer(ctx, conn, chatCli, uid, &syncAllRes.Chat); err != nil { + return err } // Sync down events since we have been dead + g.runOnConnectStep(onConnectStepServerSync) + if !g.isCurrentConn(conn) { + 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. + g.runOnConnectStep(onConnectStepGregorBadges) + if !g.ifCurrentConnTail(conn, 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 @@ -892,12 +1008,20 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, } }(g.makeReconnectOobm()) - // No longer first connect if we are now connected + // No longer first connect if we are now connected. Checked and written + // under connMutex: Reset sets first connect back to true after its + // Shutdown, so a logout either lands first and this is skipped, or + // overwrites this. + g.runOnConnectStep(onConnectStepConnected) + g.connMutex.Lock() + defer g.connMutex.Unlock() + if conn != g.conn { + return chat.ErrDuplicateConnection + } g.chatLog.Debug(ctx, "setting first connect to false") g.setFirstConnect(false) g.setConnectedAt(time.Now()) g.chatLog.Debug(ctx, "OnConnect complete") - return nil } @@ -1354,6 +1478,8 @@ func (g *gregorHandler) handleOutOfBandMessage(ctx context.Context, obm gregor.O func (g *gregorHandler) Shutdown(ctx context.Context) { defer g.chatLog.Trace(ctx, nil, "Shutdown")() + g.connTailMu.Lock() + defer g.connTailMu.Unlock() g.connMutex.Lock() defer g.connMutex.Unlock() diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index d24a79b2dba9..1a840bd6309c 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "math/rand" "net" @@ -12,8 +13,12 @@ import ( "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" @@ -740,3 +745,288 @@ func TestGregorClientInstallRacingLogout(t *testing.T) { _, err = h.getGregorCli() require.Error(t, err, "installed a client for a connection logout dropped") } + +type fakeSyncer struct { + types.Syncer + mu sync.Mutex + connected bool + connects int + // beforeMark, if set, runs once inside Connected before the syncer is + // marked connected, as a logout landing just before the mark would. + beforeMark func() + // onConnected, if set, runs once inside Connected, after the syncer is + // marked connected, as a logout landing during the sync would. + onConnected func() +} + +func (s *fakeSyncer) IsConnected(context.Context) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.connected +} + +func (s *fakeSyncer) Connected(context.Context, chat1.RemoteInterface, gregor1.UID, *chat1.SyncChatRes) error { + s.mu.Lock() + before := s.beforeMark + s.beforeMark = nil + s.mu.Unlock() + if before != nil { + before() + } + s.mu.Lock() + 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() + defer s.mu.Unlock() + s.connected = false +} + +func (s *fakeSyncer) connectCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.connects +} + +type fakeBadger struct { + mu sync.Mutex + loggedOut bool + pushes int + pushesAfterLogout int + // onPush, if set, runs once inside a push. + onPush func() +} + +func (b *fakeBadger) push() { + b.mu.Lock() + b.pushes++ + if b.loggedOut { + b.pushesAfterLogout++ + } + 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) logout() { + b.mu.Lock() + defer b.mu.Unlock() + b.loggedOut = true +} + +func (b *fakeBadger) counts() (pushes, afterLogout int) { + b.mu.Lock() + defer b.mu.Unlock() + return b.pushes, b.pushesAfterLogout +} + +func currentConn(h *gregorHandler) *rpc.Connection { + h.connMutex.Lock() + defer h.connMutex.Unlock() + return h.conn +} + +type onConnectTailTest struct { + h *gregorHandler + conn *rpc.Connection + 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()) }) + conn := currentConn(h) + uid := gregor1.UID(make([]byte, 16)) + gcli, err := h.resetGregorClientFor(context.Background(), conn, uid, gregor1.DeviceID(make([]byte, 16))) + require.NoError(t, err) + return &onConnectTailTest{ + h: h, conn: conn, gcli: gcli, syncer: syncer, badger: badger, uid: uid, + syncRes: chat1.SyncAllResult{Notification: chat1.NewSyncAllNotificationResWithState(gregor1.State{})}, + } +} + +func (c *onConnectTailTest) run() error { + return c.h.onConnectSynced(context.Background(), c.conn, chat1.RemoteClient{}, nil, c.uid, c.gcli, c.syncRes) +} + +// logout does what Service.OnLogout does to gregor, and marks every badge +// push from then on as leaked. +func (c *onConnectTailTest) logout(t *testing.T) { + require.NoError(t, c.h.Disconnect()) + c.badger.logout() +} + +func TestGregorOnConnectTailApplies(t *testing.T) { + c := setupOnConnectTail(t) + require.NoError(t, c.run()) + pushes, _ := c.badger.counts() + require.Equal(t, 2, pushes) + 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 logout landing before any step of OnConnect's tail leaves no trace of +// the old connection. +func TestGregorOnConnectTailRacingLogout(t *testing.T) { + for _, tt := range []struct { + name string + step onConnectStep + syncerConnect int + stateSyncs int + }{ + {"chat badges", onConnectStepChatBadges, 0, 0}, + {"syncer", onConnectStepSyncer, 0, 0}, + {"server sync", onConnectStepServerSync, 1, 0}, + {"gregor badges", onConnectStepGregorBadges, 1, 1}, + {"connected", onConnectStepConnected, 1, 1}, + } { + t.Run(tt.name, func(t *testing.T) { + c := setupOnConnectTail(t) + c.h.onConnectStep = func(step onConnectStep) { + if step == tt.step { + c.logout(t) + } + } + require.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) + _, afterLogout := c.badger.counts() + require.Zero(t, afterLogout, "badges pushed after logout") + require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected after logout") + require.Equal(t, tt.syncerConnect, c.syncer.connectCalls(), "chat sync ran after logout") + require.Len(t, c.h.replayCh, tt.stateSyncs, "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, before or after the syncer marks itself +// connected, leaves the syncer disconnected. +func TestGregorOnConnectLogoutDuringChatSync(t *testing.T) { + for _, beforeMark := range []bool{true, false} { + t.Run(fmt.Sprintf("before mark %v", beforeMark), func(t *testing.T) { + c := setupOnConnectTail(t) + if beforeMark { + c.syncer.beforeMark = func() { c.logout(t) } + } else { + c.syncer.onConnected = func() { c.logout(t) } + } + require.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) + require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected after logout") + require.True(t, c.h.isFirstConnect()) + }) + } +} + +// The undo leaves alone a syncer that a newer connection has marked since. +func TestGregorOnConnectLogoutDuringChatSyncKeepsNewerConn(t *testing.T) { + c := setupOnConnectTail(t) + c.syncer.onConnected = func() { + c.logout(t) + // A newer connection, installed by hand so no dial's callbacks + // touch the syncer. + newer := &rpc.Connection{} + c.h.connMutex.Lock() + c.h.conn = newer + c.h.shutdownCh = make(chan struct{}) + c.h.connMutex.Unlock() + require.NoError(t, c.h.connectSyncer(context.Background(), newer, chat1.RemoteClient{}, c.uid, &chat1.SyncChatRes{})) + } + require.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) + require.True(t, c.syncer.IsConnected(context.Background()), "undo disconnected the newer connection's syncer") +} + +// 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(), chat.ErrDuplicateConnection) + <-logoutDone + pushes, _ := c.badger.counts() + require.Equal(t, 1, pushes) +} + +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 that loses the client install to a logout fails with an error +// the connection does not retry. +func TestGregorOnConnectRacingLogoutIsNotRetried(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + h := newGregorHandler(g) + require.NoError(t, h.Connect(closedPortURI(t))) + defer h.Shutdown(context.Background()) + conn := currentConn(h) + + 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 + } + h.beforeGregorClientInstall = func() { require.NoError(t, h.Disconnect()) } + + 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 logout dropped") + _, err = h.getGregorCli() + require.Error(t, err, "installed a client for a connection logout dropped") +} 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") From fbc7553cf954385db31edc1461c553eee4da0b53 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 15:09:22 -0400 Subject: [PATCH 017/127] fix(chat): keep background work consistent with app state across restarts and races - convloader: the app-state monitor runs per Start/Stop run, seeds its suspension from the current state, and keeps it apart from the Suspend/Resume refcount; runs get their own queue, channels and group - archive: resumes decide under the registry lock and only in FOREGROUND, launch each paused job once, pause jobs that register after a pause, and use per-resume contexts - search indexer: attemptSync does not start a sync outside FOREGROUND - attachment URLs are empty, with no query suffix, while the server is stopped --- go/chat/archive.go | 178 +++++++--- go/chat/archive_appstate_test.go | 362 ++++++++++++++++++++ go/chat/attachment_httpsrv.go | 4 + go/chat/attachment_httpsrv_appstate_test.go | 68 ++++ go/chat/convloader.go | 210 ++++++++---- go/chat/convloader_appstate_test.go | 280 +++++++++++++++ go/chat/search/indexer.go | 15 +- go/chat/search/indexer_appstate_test.go | 210 ++++++++++++ 8 files changed, 1203 insertions(+), 124 deletions(-) create mode 100644 go/chat/archive_appstate_test.go create mode 100644 go/chat/attachment_httpsrv_appstate_test.go create mode 100644 go/chat/convloader_appstate_test.go create mode 100644 go/chat/search/indexer_appstate_test.go diff --git a/go/chat/archive.go b/go/chat/archive.go index 69af6e9cdfb1..720381d05197 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -42,16 +42,37 @@ type ChatArchiveRegistry struct { flushDelay time.Duration stopCh chan struct{} clock clockwork.Clock - eg errgroup.Group + // eg holds the current run's goroutines. 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. + launching map[chat1.ArchiveJobID]*archiveLaunch + // pauseEpoch counts background pauses. A launched job that registers + // after one is paused right away, since the pause could not reach it. + pauseEpoch uint64 + // monitorState is the state the current run's monitor last acted on, + // and monitorWait the change channel it waits on for that state; tests + // use them to wait until the monitor has caught up. + monitorState keybase1.MobileAppState + monitorWait <-chan struct{} + // 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 } +type archiveLaunch struct { + pauseEpoch uint64 +} + type ArchiveJobNotFoundError struct { jobID chat1.ArchiveJobID } @@ -80,6 +101,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]*archiveLaunch), jobHistory: chat1.ArchiveChatHistory{JobHistory: make(map[chat1.ArchiveJobID]chat1.ArchiveChatJob)}, edb: encrypteddb.New(g.ExternalG(), dbFn, keyFn), } @@ -169,60 +191,101 @@ func (r *ChatArchiveRegistry) resumeAllBgJobs(ctx context.Context, stopCh chan s } r.Lock() defer r.Unlock() + // Decide under the lock the monitor pauses under: a pause either comes + // first and is seen here, or bumps pauseEpoch and pauses what launches. + if state := r.G().MobileAppState.State(); state != keybase1.MobileAppState_FOREGROUND { + 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()) +// 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 + } + launch := &archiveLaunch{pauseEpoch: r.pauseEpoch} + 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) + } + }() +} + +func (r *ChatArchiveRegistry) monitorAppState(stopCh chan struct{}, eg *errgroup.Group, + state keybase1.MobileAppState, cancelInitialResume context.CancelFunc, +) error { + // cancelResume cancels the resume scheduled for the last FOREGROUND. + cancelResume := cancelInitialResume + defer func() { cancelResume() }() for { + next := r.G().MobileAppState.NextUpdate(state) + r.Lock() + if r.stopCh == stopCh { + r.monitorState, r.monitorWait = state, next + } + r.Unlock() 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) - }() - } + case <-next: + } + state = r.G().MobileAppState.State() + r.Debug(context.Background(), "monitorAppState: next state -> %v", state) + cancelResume() + switch state { + case keybase1.MobileAppState_FOREGROUND: + ctx, cancel := context.WithCancel(context.Background()) + cancelResume = cancel + eg.Go(func() error { + if err := r.resumeAllBgJobs(ctx, stopCh); err != nil { + r.Debug(ctx, err.Error()) + } + return nil + }) + default: + cancelResume = func() {} + func() { + ctx := context.Background() + var err error + defer r.Trace(ctx, &err, "monitorAppState")() + r.Lock() + defer r.Unlock() + err = r.bgPauseAllJobsLocked(ctx) + }() } } } -// Resumes previously BACKGROUND_PAUSED jobs, after a delay. +// Resumes previously BACKGROUND_PAUSED jobs, after a delay, if the app is in +// the foreground by then. func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { defer r.Trace(ctx, nil, "Start")() r.Lock() @@ -233,20 +296,24 @@ func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { r.uid = uid r.started = true r.stopCh = make(chan struct{}) - stopCh := r.stopCh - r.eg.Go(func() error { + r.eg = new(errgroup.Group) + stopCh, eg := r.stopCh, r.eg + state := r.G().MobileAppState.State() + resumeCtx, cancelResume := context.WithCancel(context.Background()) + eg.Go(func() error { return r.flushLoop(stopCh) }) - r.eg.Go(func() error { - return r.resumeAllBgJobs(context.Background(), stopCh) + eg.Go(func() error { + return r.resumeAllBgJobs(resumeCtx, stopCh) }) - r.eg.Go(func() error { - return r.monitorAppState(stopCh) + eg.Go(func() error { + return r.monitorAppState(stopCh, eg, state, cancelResume) }) } func (r *ChatArchiveRegistry) bgPauseAllJobsLocked(ctx context.Context) (err error) { defer r.Trace(ctx, &err, "bgPauseAllJobsLocked")() + r.pauseEpoch++ err = r.initLocked(ctx) if err != nil { return err @@ -284,9 +351,11 @@ func (r *ChatArchiveRegistry) Stop(ctx context.Context) chan struct{} { } r.started = false close(r.stopCh) + r.monitorWait = nil + 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 +464,19 @@ 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 } + if launch, ok := r.launching[jobID]; ok { + delete(r.launching, jobID) + if launch.pauseEpoch != r.pauseEpoch { + r.Debug(ctx, "Set: %v was paused while starting", jobID) + cancel() + job.Status = chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED + break + } + } + r.runningJobs[jobID] = cancel } r.jobHistory.JobHistory[jobID] = job.DeepCopy() @@ -463,14 +542,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..bb36fe212d3f --- /dev/null +++ b/go/chat/archive_appstate_test.go @@ -0,0 +1,362 @@ +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 waitArchiveMonitor(t *testing.T, r *ChatArchiveRegistry) { + t.Helper() + require.Eventually(t, func() bool { + r.Lock() + state, wait := r.monitorState, r.monitorWait + r.Unlock() + if wait == nil || wait != r.G().MobileAppState.NextUpdate(state) { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "monitor did not catch up") +} + +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) + r.Lock() + r.started = true + r.Unlock() + stopCh := make(chan struct{}) + defer close(stopCh) + + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + assert.NoError(t, r.resumeAllBgJobs(context.Background(), stopCh)) + }) + } + wg.Wait() + 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 pause that lands after a job launched, but before it registered, pauses +// it on registration. +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") + } + } + waitArchiveMonitor(t, r) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitArchiveMonitor(t, r) + close(runner.release) + requireArchiveJobsPaused(t, r, runner) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + requireArchiveJobsRunning(t, r) +} + +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})) + waitArchiveMonitor(t, r) + 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) + waitArchiveMonitor(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) { + waitArchiveMonitor(t, r) + if step.Want == keybase1.MobileAppState_FOREGROUND { + requireArchiveJobsRunning(t, r) + } else { + requireArchiveJobsPaused(t, r, runner) + } + }) + }) + } +} + +// Each FOREGROUND schedules a resume that the next transition cancels while +// it waits out its delay; the canceled resume must still see its own context. +func TestArchiveCanceledResumesKeepTheirContext(t *testing.T) { + r, runner, tc := setupAppStateArchive(t, true) + r.resumeJobsDelay = time.Hour + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + for range 20 { + for _, state := range []keybase1.MobileAppState{ + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_FOREGROUND, + } { + tc.G.MobileAppState.Update(state) + waitArchiveMonitor(t, r) + } + } + requireArchiveStopped(t, r) + launches, _ := runner.counts() + require.Empty(t, launches) +} + +// Rapid transitions race resumes against pauses and the monitor's resume +// contexts against the goroutines using them. +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) + waitArchiveMonitor(t, r) + 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..05fff2a37601 100644 --- a/go/chat/attachment_httpsrv.go +++ b/go/chat/attachment_httpsrv.go @@ -149,6 +149,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..986f5b30e8ba --- /dev/null +++ b/go/chat/attachment_httpsrv_appstate_test.go @@ -0,0 +1,68 @@ +package chat + +import ( + "context" + "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) {} + +func TestAttachmentURLsEmptyWhileServerStopped(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 + } + + require.Eventually(t, httpSrv.Active, 10*time.Second, time.Millisecond) + up := get() + for _, url := range []string{up.full, up.preview, up.emoji, up.emojiNoAnim, up.emojiNoAnimOnly} { + require.True(t, strings.HasPrefix(url, "http://"), "url %q while serving", url) + } + require.Contains(t, up.preview, "&prev=true") + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + require.Eventually(t, func() bool { return !httpSrv.Active() }, 10*time.Second, time.Millisecond) + require.Equal(t, urls{}, get()) + + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + require.Eventually(t, httpSrv.Active, 10*time.Second, time.Millisecond) + require.True(t, strings.HasPrefix(get().full, "http://")) +} diff --git a/go/chat/convloader.go b/go/chat/convloader.go index 1523ac249411..61b3b18ea27a 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -115,15 +115,20 @@ type BackgroundConvLoader struct { utils.DebugLabeler sync.Mutex - uid gregor1.UID - started bool - queue *jobQueue - stopCh chan struct{} + uid gregor1.UID + started bool + queue *jobQueue + stopCh chan struct{} + // suspendCh belongs to the current run, so a loop of a stopped run + // cannot take a suspension meant for its successor. suspendCh chan chan struct{} resumeCh chan struct{} loadCh chan *clTask identNotifier types.IdentifyNotifier - eg errgroup.Group + // eg holds the current run's goroutines. 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 clock clockwork.Clock resumeWait time.Duration @@ -131,6 +136,14 @@ type BackgroundConvLoader struct { activeLoads map[string]activeLoad suspendCount int + // appSuspended is the app-state monitor's own suspension, kept apart + // from suspendCount so an unbalanced Resume cannot release it. + appSuspended bool + // monitorState is the state the current run's monitor last acted on, + // and monitorWait the change channel it waits on for that state; tests + // use them to wait until the monitor has caught up. + monitorState keybase1.MobileAppState + monitorWait <-chan struct{} // for testing, make this and can check conv load successes loads chan chat1.ConversationID @@ -145,7 +158,7 @@ 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), + eg: new(errgroup.Group), identNotifier: NewCachingIdentifyNotifier(g), clock: clockwork.NewRealClock(), resumeWait: time.Second, @@ -154,9 +167,6 @@ func NewBackgroundConvLoader(g *globals.Context) *BackgroundConvLoader { } b.identNotifier.ResetOnGUIConnect() b.newQueue() - stopCh := b.stopCh - go func() { _ = b.monitorAppState(stopCh) }() - return b } @@ -170,39 +180,61 @@ 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") +// 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 +} - suspended := false - state := keybase1.MobileAppState_FOREGROUND +func (b *BackgroundConvLoader) setAppStateLocked(ctx context.Context, state keybase1.MobileAppState) { + suspend := suspendInAppState(state) + if suspend == b.appSuspended { + return + } + wasSuspended := b.suspendedLocked() + b.appSuspended = suspend + if suspend { + b.Debug(ctx, "setAppState: suspending load thread in %v", state) + b.cancelActiveLoadsLocked() + } else { + b.Debug(ctx, "setAppState: resuming load thread in %v", state) + } + b.signalSuspendLocked(ctx, wasSuspended) +} + +func (b *BackgroundConvLoader) monitorAppState(stopCh chan struct{}, state keybase1.MobileAppState) error { + ctx := context.Background() + b.Debug(ctx, "monitorAppState: starting up in %v", state) for { + next := b.G().MobileAppState.NextUpdate(state) + b.Lock() + if b.stopCh == stopCh { + b.monitorState, b.monitorWait = state, next + } + b.Unlock() 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 <-next: case <-stopCh: b.Debug(ctx, "monitorAppState: shutting down") return nil } + b.Lock() + if b.stopCh != stopCh { + b.Unlock() + return nil + } + // Read and apply under the lock, so Start and Stop never interleave + // with a decision made on a stale state. + state = b.G().MobileAppState.State() + b.setAppStateLocked(ctx, state) + b.Unlock() + if b.appStateCh != nil { + select { + case b.appStateCh <- struct{}{}: + case <-stopCh: + return nil + } + } } } @@ -215,16 +247,41 @@ func (b *BackgroundConvLoader) Start(ctx context.Context, uid gregor1.UID) { return } b.Debug(ctx, "Start") + var prevRun *errgroup.Group if b.started { - close(b.stopCh) - b.stopCh = make(chan struct{}) + prevRun = b.endRunLocked() } 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) }) + stopCh, eg, queue, loadCh := b.stopCh, b.eg, b.queue, b.loadCh + if prevRun != nil { + // Stop waits for the replaced run too. + eg.Go(prevRun.Wait) + } + b.suspendCh = make(chan chan struct{}, 10) + suspendCh := b.suspendCh + // Hand a suspension that outlived the last run to this run's loop. + if b.suspendedLocked() && b.resumeCh != nil { + suspendCh <- b.resumeCh + } + state := b.G().MobileAppState.State() + b.setAppStateLocked(ctx, state) + eg.Go(func() error { return b.loop(uid, stopCh, suspendCh, queue, loadCh) }) + eg.Go(func() error { return b.loadLoop(uid, stopCh, loadCh) }) + eg.Go(func() error { return b.monitorAppState(stopCh, state) }) +} + +// endRunLocked stops the current run's goroutines and returns their group. +// The app-state suspension is left as is; the next Start seeds it again. +func (b *BackgroundConvLoader) endRunLocked() *errgroup.Group { + eg := b.eg + b.started = false + close(b.stopCh) + b.stopCh = make(chan struct{}) + b.eg = new(errgroup.Group) + b.monitorWait = nil + return eg } func (b *BackgroundConvLoader) Stop(ctx context.Context) chan struct{} { @@ -234,11 +291,9 @@ func (b *BackgroundConvLoader) Stop(ctx context.Context) chan struct{} { b.cancelActiveLoadsLocked() ch := make(chan struct{}) if b.started { - b.started = false - close(b.stopCh) - b.stopCh = make(chan struct{}) + eg := b.endRunLocked() go func() { - _ = b.eg.Wait() + _ = eg.Wait() close(ch) }() } else { @@ -276,14 +331,16 @@ func (b *BackgroundConvLoader) cancelActiveLoadsLocked() (canceled bool) { return canceled } -func (b *BackgroundConvLoader) Suspend(ctx context.Context) (canceled bool) { - defer b.Trace(ctx, nil, "Suspend")() - b.Lock() - defer b.Unlock() - if !b.started { - return false - } - if b.suspendCount == 0 { +func (b *BackgroundConvLoader) suspendedLocked() bool { + return b.suspendCount > 0 || b.appSuspended +} + +// signalSuspendLocked tells the loop about a change in suspension, given +// whether it was suspended before the change. +func (b *BackgroundConvLoader) signalSuspendLocked(ctx context.Context, wasSuspended bool) { + suspended := b.suspendedLocked() + switch { + case suspended && !wasSuspended: b.Debug(ctx, "Suspend: sending on suspendCh") b.resumeCh = make(chan struct{}) select { @@ -291,8 +348,23 @@ func (b *BackgroundConvLoader) Suspend(ctx context.Context) (canceled bool) { default: b.Debug(ctx, "Suspend: failed to suspend loop") } + case !suspended && wasSuspended && b.resumeCh != nil: + b.Debug(ctx, "Resume: closing resumeCh") + close(b.resumeCh) + b.resumeCh = nil } +} + +func (b *BackgroundConvLoader) Suspend(ctx context.Context) (canceled bool) { + defer b.Trace(ctx, nil, "Suspend")() + b.Lock() + defer b.Unlock() + if !b.started { + return false + } + wasSuspended := b.suspendedLocked() b.suspendCount++ + b.signalSuspendLocked(ctx, wasSuspended) return b.cancelActiveLoadsLocked() } @@ -300,21 +372,19 @@ func (b *BackgroundConvLoader) Resume(ctx context.Context) bool { defer b.Trace(ctx, nil, "Resume")() b.Lock() defer b.Unlock() - if b.suspendCount > 0 { - b.suspendCount-- - if b.suspendCount == 0 && b.resumeCh != nil { - b.Debug(ctx, "Resume: closing resumeCh") - close(b.resumeCh) - return true - } + if b.suspendCount == 0 { + return false } - return false + wasSuspended := b.suspendedLocked() + b.suspendCount-- + b.signalSuspendLocked(ctx, wasSuspended) + return b.suspendCount == 0 } func (b *BackgroundConvLoader) isSuspended() bool { b.Lock() defer b.Unlock() - return b.suspendCount > 0 + return b.suspendedLocked() } func (b *BackgroundConvLoader) isRunning() bool { @@ -337,7 +407,9 @@ 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{}, suspendCh chan chan struct{}, + queue *jobQueue, loadCh chan *clTask, +) error { bgctx := context.Background() b.Debug(bgctx, "loop: starting conv loader loop for %s", uid) @@ -364,8 +436,8 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}) error 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,7 +455,7 @@ 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 ch := <-suspendCh: b.Debug(bgctx, "loop: pulled queue task, but suspended, so waiting") if !waitForResume(ch) { return nil @@ -391,11 +463,11 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}) error } 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 ch := <-suspendCh: b.Debug(bgctx, "loop: received suspend") if !waitForResume(ch) { return nil @@ -407,12 +479,12 @@ 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{}, loadCh chan *clTask) error { bgctx := context.Background() b.Debug(bgctx, "loadLoop: starting for uid: %s", uid) for { select { - case task := <-b.loadCh: + case task := <-loadCh: switch { case !b.isRunning(): b.Debug(bgctx, "loadLoop: shutting down for %s", uid) diff --git a/go/chat/convloader_appstate_test.go b/go/chat/convloader_appstate_test.go new file mode 100644 index 000000000000..24efaad7f923 --- /dev/null +++ b/go/chat/convloader_appstate_test.go @@ -0,0 +1,280 @@ +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/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/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 waitConvLoaderMonitor(t *testing.T, b *BackgroundConvLoader) { + t.Helper() + require.Eventually(t, func() bool { + b.Lock() + state, wait := b.monitorState, b.monitorWait + b.Unlock() + if wait == nil || wait != b.G().MobileAppState.NextUpdate(state) { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "monitor did not catch up") +} + +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) +} + +func TestConvLoaderMonitorSurvivesStopStart(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + uid := gregor1.UID([]byte{1, 2, 3, 4}) + appState := tc.G.MobileAppState + for i := range 3 { + appState.Update(keybase1.MobileAppState_FOREGROUND) + b.Start(context.TODO(), uid) + require.False(t, b.isSuspended(), "run %d: suspended at a foreground Start", i) + waitConvLoaderMonitor(t, b) + + appState.Update(keybase1.MobileAppState_BACKGROUND) + waitConvLoaderMonitor(t, b) + require.True(t, b.isSuspended(), "run %d: not suspended in BACKGROUND", i) + + appState.Update(keybase1.MobileAppState_INACTIVE) + waitConvLoaderMonitor(t, b) + require.False(t, b.isSuspended(), "run %d: suspended in INACTIVE", i) + + appState.Update(keybase1.MobileAppState_BACKGROUND) + waitConvLoaderMonitor(t, b) + require.True(t, b.isSuspended(), "run %d: not suspended in BACKGROUND", i) + requireConvLoaderStopped(t, b) + + // A Start in BACKGROUND seeds its suspension before any change. + b.Start(context.TODO(), uid) + require.True(t, b.isSuspended(), "run %d: not suspended at a background Start", i) + waitConvLoaderMonitor(t, b) + appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + waitConvLoaderMonitor(t, b) + require.False(t, b.isSuspended(), "run %d: suspended in BACKGROUNDACTIVE", i) + 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.True(t, b.isSuspended()) + + 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 must not release the monitor's suspension. +func TestConvLoaderResumeKeepsAppStateSuspension(t *testing.T) { + b, _, 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())) + require.True(t, b.isSuspended()) + b.Suspend(context.TODO()) + require.True(t, b.Resume(context.TODO())) + require.True(t, b.isSuspended()) +} + +// A Start over a running loader replaces its run; Stop still waits for the +// replaced run's goroutines. +func TestConvLoaderStopWaitsForReplacedRun(t *testing.T) { + b, _, _ := setupAppStateConvLoader(t) + clock := clockwork.NewFakeClock() + b.clock = clock + uid := gregor1.UID([]byte{1, 2, 3, 4}) + b.Start(context.TODO(), uid) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + // the first run's loop has pulled the job and waits out its delay + clock.BlockUntil(1) + b.Start(context.TODO(), uid) + stopped := b.Stop(context.TODO()) + select { + case <-stopped: + require.FailNow(t, "Stop finished while the replaced run was still running") + case <-time.After(200 * time.Millisecond): + } + clock.Advance(time.Second) + select { + case <-stopped: + case <-time.After(10 * time.Second): + require.FailNow(t, "Stop did not finish") + } +} + +func TestConvLoaderScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) + defer requireConvLoaderStopped(t, b) + lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + waitConvLoaderMonitor(t, b) + if got, want := b.isSuspended(), step.Want == keybase1.MobileAppState_BACKGROUND; got != want { + t.Fatalf("step %d %v: suspended %v in %v", i, step.Do, got, step.Want) + } + }) + }) + } +} + +func TestConvLoaderAppStateStress(t *testing.T) { + b, _, 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") + } + + for _, state := range states { + tc.G.MobileAppState.Update(state) + b.Start(context.TODO(), uid) + waitConvLoaderMonitor(t, b) + b.Lock() + appSuspended := b.appSuspended + b.Unlock() + require.Equal(t, state == keybase1.MobileAppState_BACKGROUND, appSuspended, "in %v", state) + } + 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") +} diff --git a/go/chat/search/indexer.go b/go/chat/search/indexer.go index ac373804c636..307861184a88 100644 --- a/go/chat/search/indexer.go +++ b/go/chat/search/indexer.go @@ -81,6 +81,8 @@ 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 } var _ types.Indexer = (*Indexer)(nil) @@ -243,7 +245,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 +262,11 @@ func (idx *Indexer) SyncLoop(stopCh chan struct{}) error { if netState.IsLimited() { return } + // A change after this read wakes the loop, which cancels the sync. + if state := idx.G().MobileAppState.State(); state != keybase1.MobileAppState_FOREGROUND { + idx.Debug(ctx, "not running SelectiveSync in %v", state) + return + } l.Lock() defer l.Unlock() if cancelFn != nil { @@ -267,9 +274,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..16f53e58a9d0 --- /dev/null +++ b/go/chat/search/indexer_appstate_test.go @@ -0,0 +1,210 @@ +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) +} + +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") + // BgTicker.Stop leaves its tick goroutine blocked on the stopped ticker. + baseline++ + 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") +} From 70d0d2a5b1af38c251a53b24c28dc10e64dd9298 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 15:24:08 -0400 Subject: [PATCH 018/127] fix(chat): close remaining app-state races in the indexer, archive and conv loader - search indexer: a sync started before the loop saw FOREGROUND is canceled by a following BACKGROUND - archive: a resume from a stopped run does not launch jobs in the next run - convloader: a replaced run retries only into its own queue and drops the retry once stopped - BgTicker: Stop ends the tick goroutine --- go/chat/archive.go | 11 +++ go/chat/archive_appstate_test.go | 31 ++++++++- go/chat/convloader.go | 47 +++++++++---- go/chat/convloader_appstate_test.go | 91 +++++++++++++++++++++++++ go/chat/convloader_test.go | 12 ++-- go/chat/search/indexer.go | 14 +++- go/chat/search/indexer_appstate_test.go | 30 +++++++- go/libkb/bgticker.go | 29 +++++++- go/libkb/bgticker_test.go | 31 +++++++++ 9 files changed, 270 insertions(+), 26 deletions(-) diff --git a/go/chat/archive.go b/go/chat/archive.go index 720381d05197..1bbeecebb6ba 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -64,6 +64,9 @@ type ChatArchiveRegistry struct { // 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 + // beforeResumeDecision, if set, runs in resumeAllBgJobs after its delay + // and before it takes the lock. Tests only. + beforeResumeDecision func() edb *encrypteddb.EncryptedDB jobHistory chat1.ArchiveChatHistory @@ -189,8 +192,16 @@ func (r *ChatArchiveRegistry) resumeAllBgJobs(ctx context.Context, stopCh chan s return ctx.Err() case <-time.After(r.resumeJobsDelay): } + if r.beforeResumeDecision != nil { + r.beforeResumeDecision() + } r.Lock() defer r.Unlock() + // The delay can win over a closed stopCh, and a later Start (possibly for + // another user) can run before the lock is taken. + if r.stopCh != stopCh { + return nil + } // Decide under the lock the monitor pauses under: a pause either comes // first and is seen here, or bumps pauseEpoch and pauses what launches. if state := r.G().MobileAppState.State(); state != keybase1.MobileAppState_FOREGROUND { diff --git a/go/chat/archive_appstate_test.go b/go/chat/archive_appstate_test.go index bb36fe212d3f..c5abbe73bc44 100644 --- a/go/chat/archive_appstate_test.go +++ b/go/chat/archive_appstate_test.go @@ -168,10 +168,11 @@ func requireArchiveJobsPaused(t *testing.T, r *ChatArchiveRegistry, runner *arch func TestArchiveConcurrentResumesLaunchOnce(t *testing.T) { r, runner, _ := setupAppStateArchive(t, false) + stopCh := make(chan struct{}) r.Lock() r.started = true + r.stopCh = stopCh r.Unlock() - stopCh := make(chan struct{}) defer close(stopCh) var wg sync.WaitGroup @@ -224,6 +225,34 @@ func TestArchivePauseBeforeRegistration(t *testing.T) { requireArchiveJobsRunning(t, r) } +// A resume whose delay 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. The context is left uncanceled: the stopped run's monitor may +// not have exited to cancel it yet. +func TestArchiveStaleResumeAfterRestart(t *testing.T) { + r, runner, _ := setupAppStateArchive(t, true) + oldStopCh := make(chan struct{}) + r.Lock() + r.started = true + r.stopCh = oldStopCh + r.Unlock() + r.beforeResumeDecision = func() { + r.Lock() + r.started = false + close(oldStopCh) + r.Unlock() + r.resumeJobsDelay = time.Hour + r.Start(context.TODO(), gregor1.UID([]byte{5, 6, 7, 8})) + } + require.NoError(t, r.resumeAllBgJobs(context.Background(), oldStopCh)) + defer requireArchiveStopped(t, r) + select { + case id := <-runner.launched: + require.FailNow(t, fmt.Sprintf("stale resume launched %v", id)) + case <-time.After(300 * time.Millisecond): + } +} + func TestArchiveStartInBackgroundDoesNotResume(t *testing.T) { r, runner, tc := setupAppStateArchive(t, true) for _, state := range []keybase1.MobileAppState{ diff --git a/go/chat/convloader.go b/go/chat/convloader.go index 61b3b18ea27a..b74af652bf64 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -268,7 +268,7 @@ func (b *BackgroundConvLoader) Start(ctx context.Context, uid gregor1.UID) { state := b.G().MobileAppState.State() b.setAppStateLocked(ctx, state) eg.Go(func() error { return b.loop(uid, stopCh, suspendCh, queue, loadCh) }) - eg.Go(func() error { return b.loadLoop(uid, stopCh, loadCh) }) + eg.Go(func() error { return b.loadLoop(uid, stopCh, queue, loadCh) }) eg.Go(func() error { return b.monitorAppState(stopCh, state) }) } @@ -396,8 +396,26 @@ 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, and drops +// it once that run has stopped, so it never reaches a later run (or user). +func (b *BackgroundConvLoader) requeue(ctx context.Context, stopCh chan struct{}, queue *jobQueue, task clTask) { + select { + case <-stopCh: + b.Debug(ctx, "requeue: run stopped, dropping task: %s", task.job) + return + default: + } + 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 } @@ -479,28 +497,27 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}, suspe } } -func (b *BackgroundConvLoader) loadLoop(uid gregor1.UID, stopCh chan struct{}, loadCh chan *clTask) 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 := <-loadCh: - switch { - case !b.isRunning(): + select { + 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: + } + if b.isSuspended() { + b.Debug(bgctx, "loadLoop: suspended, re-enqueueing task: %s", task.job) + b.requeue(bgctx, stopCh, queue, *task) + } else { 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) - } + if nextTask := b.load(bgctx, *task, uid); nextTask != nil { + b.requeue(bgctx, stopCh, queue, *nextTask) } } b.clock.Sleep(b.loadWait) diff --git a/go/chat/convloader_appstate_test.go b/go/chat/convloader_appstate_test.go index 24efaad7f923..5a09cfeeb5bd 100644 --- a/go/chat/convloader_appstate_test.go +++ b/go/chat/convloader_appstate_test.go @@ -191,6 +191,97 @@ func TestConvLoaderStopWaitsForReplacedRun(t *testing.T) { } } +// A suspension that outlives a run parks the next run's loop before it +// takes anything off the queue. +func TestConvLoaderSuspensionCarriesIntoNextRun(t *testing.T) { + b, _, tc := setupAppStateConvLoader(t) + clock := clockwork.NewFakeClock() + b.clock = clock + uid := gregor1.UID([]byte{1, 2, 3, 4}) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + b.Start(context.TODO(), uid) + requireConvLoaderStopped(t, b) + b.Start(context.TODO(), uid) + defer requireConvLoaderStopped(t, b) + require.Eventually(t, func() bool { + b.Lock() + defer b.Unlock() + return len(b.suspendCh) == 0 + }, 10*time.Second, time.Millisecond, "loop did not take the suspension") + + 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") + } + b.Start(context.TODO(), newUID) + defer requireConvLoaderStopped(t, b) + close(pulls.release) + + // 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 TestConvLoaderScenarioReplay(t *testing.T) { for _, sc := range lifecycletest.Scenarios { t.Run(sc.Name, func(t *testing.T) { diff --git a/go/chat/convloader_test.go b/go/chat/convloader_test.go index 46ea15ade033..91a11479acb6 100644 --- a/go/chat/convloader_test.go +++ b/go/chat/convloader_test.go @@ -135,14 +135,18 @@ func TestConvLoaderAppState(t *testing.T) { 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.appStateCh = appStateCh + 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 diff --git a/go/chat/search/indexer.go b/go/chat/search/indexer.go index 307861184a88..5b3908a5e3ac 100644 --- a/go/chat/search/indexer.go +++ b/go/chat/search/indexer.go @@ -83,6 +83,9 @@ type Indexer struct { 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) @@ -262,11 +265,20 @@ func (idx *Indexer) SyncLoop(stopCh chan struct{}) error { if netState.IsLimited() { return } - // A change after this read wakes the loop, which cancels the sync. + 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 { diff --git a/go/chat/search/indexer_appstate_test.go b/go/chat/search/indexer_appstate_test.go index 16f53e58a9d0..9fb5d76ba652 100644 --- a/go/chat/search/indexer_appstate_test.go +++ b/go/chat/search/indexer_appstate_test.go @@ -148,6 +148,34 @@ func TestSyncLoopBackgroundCancelsSync(t *testing.T) { 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) { @@ -200,8 +228,6 @@ func TestSyncLoopAppStateStress(t *testing.T) { s.requireActive(1, "no sync in FOREGROUND") s.stop() s.requireActive(0, "sync outlived the loop") - // BgTicker.Stop leaves its tick goroutine blocked on the stopped ticker. - baseline++ deadline := time.Now().Add(10 * time.Second) for runtime.NumGoroutine() > baseline && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) 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") +} From 4bf222020c76ae1c49958886945e9d85e38c5788 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 15:37:32 -0400 Subject: [PATCH 019/127] fix(appstate): restart the leveldb cleaner monitor on reopen and stop avatar monitors with their source The leveldb cleaner's app-state monitor now starts when its db opens, seeded from the current state, and ends when the db closes, so it comes back after Close or Nuke. A reopened cleaner also cleans again instead of failing as shut down. A clean keeps running only across a transition into BACKGROUNDACTIVE, as before. The avatar sources' flush-on-background monitors end on StopBackgroundTasks and seed from the current state, and the populate workers read their own channel so a restart does not race them. The ephemeral keygen loop seeds from the current state. --- go/avatars/appstate.go | 71 +++++++ go/avatars/appstate_test.go | 135 ++++++++++++++ go/avatars/fullcaching.go | 30 ++- go/avatars/urlcaching.go | 20 +- go/ephemeral/keygen_loop_test.go | 60 ++++++ go/ephemeral/lib.go | 33 ++-- go/libkb/leveldb.go | 2 +- go/libkb/leveldb_cleaner.go | 89 ++++++--- go/libkb/leveldb_cleaner_test.go | 305 +++++++++++++++++++++++++++++++ 9 files changed, 670 insertions(+), 75 deletions(-) create mode 100644 go/avatars/appstate.go create mode 100644 go/avatars/appstate_test.go create mode 100644 go/ephemeral/keygen_loop_test.go create mode 100644 go/libkb/leveldb_cleaner_test.go diff --git a/go/avatars/appstate.go b/go/avatars/appstate.go new file mode 100644 index 000000000000..b14236929210 --- /dev/null +++ b/go/avatars/appstate.go @@ -0,0 +1,71 @@ +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, and monitorState/monitorWait record the state + // the monitor last acted on and the change channel it waits on for it; + // tests use them to wait until the monitor has caught up. + flushes int + monitorState keybase1.MobileAppState + monitorWait <-chan struct{} +} + +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{}) + go f.monitor(m, m.G().MobileAppState.State(), flush, f.stopCh, f.doneCh) +} + +// stop ends the monitor 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 +} + +func (f *backgroundFlusher) monitor(m libkb.MetaContext, state keybase1.MobileAppState, flush func(libkb.MetaContext), + stopCh, doneCh chan struct{}, +) { + defer close(doneCh) + for { + next := m.G().MobileAppState.NextUpdate(state) + f.mu.Lock() + f.monitorState, f.monitorWait = state, next + f.mu.Unlock() + select { + case <-next: + case <-stopCh: + return + } + state = m.G().MobileAppState.State() + if state == keybase1.MobileAppState_BACKGROUND { + flush(m) + f.mu.Lock() + f.flushes++ + f.mu.Unlock() + } + } +} diff --git a/go/avatars/appstate_test.go b/go/avatars/appstate_test.go new file mode 100644 index 000000000000..6ab62c8fc7c7 --- /dev/null +++ b/go/avatars/appstate_test.go @@ -0,0 +1,135 @@ +package avatars + +import ( + "runtime" + "testing" + "time" + + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// waitFlusher waits until f's monitor has acted on the current state and is +// waiting for the next change. +func waitFlusher(t *testing.T, g *libkb.GlobalContext, f *backgroundFlusher) { + t.Helper() + require.Eventually(t, func() bool { + f.mu.Lock() + state, wait := f.monitorState, f.monitorWait + f.mu.Unlock() + if wait == nil || wait != g.MobileAppState.NextUpdate(state) { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "monitor did not catch up") +} + +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) + waitFlusher(t, tc.G, s.flusher()) + require.Equal(t, 0, flushes(s.flusher()), "flushed without a transition into BACKGROUND") + + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + } { + tc.G.MobileAppState.Update(next) + waitFlusher(t, tc.G, s.flusher()) + } + require.Equal(t, 1, flushes(s.flusher())) + }) +} + +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) + waitFlusher(t, tc.G, s.flusher()) + 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..392c0b91cd5e 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, "monitorAppState: backgrounded") + if err := c.diskLRU.Flush(m.Ctx(), m.G()); err != nil { + c.debug(m, "monitorAppState: 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..59c7550a6dce 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, "monitorAppState: backgrounded") + 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/ephemeral/keygen_loop_test.go b/go/ephemeral/keygen_loop_test.go new file mode 100644 index 000000000000..6fee409da7e3 --- /dev/null +++ b/go/ephemeral/keygen_loop_test.go @@ -0,0 +1,60 @@ +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") + } +} diff --git a/go/ephemeral/lib.go b/go/ephemeral/lib.go index e17ac4b2696d..97ee2064e8da 100644 --- a/go/ephemeral/lib.go +++ b/go/ephemeral/lib.go @@ -118,30 +118,37 @@ 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, 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): 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. 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 } } diff --git a/go/libkb/leveldb.go b/go/libkb/leveldb.go index 3ae088436682..0e73ea161c3e 100644 --- a/go/libkb/leveldb.go +++ b/go/libkb/leveldb.go @@ -193,7 +193,7 @@ func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) l.db = db l.dbMu.Unlock() if db != nil { - l.cleaner.setDb(db) + l.cleaner.start(db) } }) diff --git a/go/libkb/leveldb_cleaner.go b/go/libkb/leveldb_cleaner.go index 891329c23049..58ff9dd6cd37 100644 --- a/go/libkb/leveldb_cleaner.go +++ b/go/libkb/leveldb_cleaner.go @@ -71,6 +71,14 @@ type levelDbCleaner struct { db *leveldb.DB stopCh chan struct{} cancelCh chan struct{} + // monitoring is whether an app-state monitor runs for the current stopCh. + monitoring bool + // monitors counts running monitor goroutines, and monitorState and + // monitorWait record the state the monitor last acted on and the change + // channel it waits on for that state; tests use them. + monitors int + monitorState keybase1.MobileAppState + monitorWait <-chan struct{} isShutdown bool } @@ -90,7 +98,7 @@ func newLevelDbCleanerWithConfig(mctx MetaContext, dbName string, config DbClean 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)), @@ -101,11 +109,6 @@ func newLevelDbCleanerWithConfig(mctx MetaContext, dbName string, config DbClean stopCh: make(chan struct{}), cancelCh: make(chan struct{}), } - if isMobile { - stopCh := c.stopCh - go c.monitorAppState(stopCh) - } - return c } func (c *levelDbCleaner) getCache() *lru.Cache { @@ -127,30 +130,66 @@ func (c *levelDbCleaner) Stop() { close(c.stopCh) c.stopCh = make(chan struct{}) } + c.monitoring = false +} + +// start attaches the cleaner to a newly opened db, undoing a previous +// Stop/Shutdown from closing it, and on mobile starts the app-state monitor. +func (c *levelDbCleaner) start(db *leveldb.DB) { + c.Lock() + defer c.Unlock() + c.db = db + c.cacheMu.Lock() + if c.isShutdown { + if cache, err := lru.New(c.config.CacheCapacity); err == nil { + c.cache = cache + c.isShutdown = false + } + } + c.cacheMu.Unlock() + if !c.isMobile || c.monitoring { + return + } + c.monitoring = true + c.monitors++ + go c.monitorAppState(c.stopCh, c.G().MobileAppState.State()) } -func (c *levelDbCleaner) monitorAppState(stopCh chan struct{}) { - c.log("monitorAppState") - state := keybase1.MobileAppState_FOREGROUND +// monitorAppState cancels a running clean whenever the app moves to any state +// other than BACKGROUNDACTIVE. A clean may start in any state; it keeps +// running only across a transition into BACKGROUNDACTIVE, so it gives way +// when the app comes to the foreground and before it is suspended. +func (c *levelDbCleaner) monitorAppState(stopCh chan struct{}, state keybase1.MobileAppState) { + c.log("monitorAppState: starting in %v", state) + defer func() { + c.Lock() + defer c.Unlock() + c.monitors-- + }() for { + next := c.G().MobileAppState.NextUpdate(state) + c.Lock() + c.monitorState, c.monitorWait = state, next + c.Unlock() 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 <-next: case <-stopCh: c.log("monitorAppState: stop") return } + state = c.G().MobileAppState.State() + if state == keybase1.MobileAppState_BACKGROUNDACTIVE { + continue + } + c.log("monitorAppState: attempting cancel, state: %v", state) + c.Lock() + if c.stopCh != stopCh { + c.Unlock() + return + } + close(c.cancelCh) + c.cancelCh = make(chan struct{}) + c.Unlock() } } @@ -158,12 +197,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) } diff --git a/go/libkb/leveldb_cleaner_test.go b/go/libkb/leveldb_cleaner_test.go new file mode 100644 index 000000000000..2cd1f7ac65e2 --- /dev/null +++ b/go/libkb/leveldb_cleaner_test.go @@ -0,0 +1,305 @@ +package libkb + +import ( + "fmt" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + 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, true) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func testCleanerConfig() DbCleanerConfig { + config := DefaultMobileDbCleanerConfig + config.CacheCapacity = 10 + return config +} + +func (c *levelDbCleaner) snapshot() (cancelCh chan struct{}, monitors int) { + c.Lock() + defer c.Unlock() + return c.cancelCh, c.monitors +} + +// waitCleanerMonitor waits until the cleaner's monitor has acted on the +// current state and is waiting for the next change. +func waitCleanerMonitor(t *testing.T, c *levelDbCleaner) { + t.Helper() + require.Eventually(t, func() bool { + c.Lock() + state, wait, monitors := c.monitorState, c.monitorWait, c.monitors + c.Unlock() + if monitors != 1 || wait == nil || wait != c.G().MobileAppState.NextUpdate(state) { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "cleaner monitor did not catch up") +} + +func isClosed(ch chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +var cleanerStates = []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, +} + +// requireCancelOnTransition moves to next and checks that a clean running +// across the transition is canceled unless next is BACKGROUNDACTIVE. +func requireCancelOnTransition(t *testing.T, db *LevelDb, next keybase1.MobileAppState) { + t.Helper() + cancelCh, _ := db.cleaner.snapshot() + db.G().MobileAppState.Update(next) + waitCleanerMonitor(t, db.cleaner) + want := next != keybase1.MobileAppState_BACKGROUNDACTIVE + require.Equal(t, want, isClosed(cancelCh), "transition to %v", next) +} + +func TestLevelDbCleanerCancelsOutsideBackgroundActive(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-cancel", 0) + defer tc.Cleanup() + db := newMobileCleanerDb(t, &tc, testCleanerConfig()) + require.NoError(t, db.ForceOpen()) + waitCleanerMonitor(t, db.cleaner) + for _, from := range cleanerStates { + for _, to := range cleanerStates { + if from == to { + continue + } + tc.G.MobileAppState.Update(from) + waitCleanerMonitor(t, db.cleaner) + requireCancelOnTransition(t, db, to) + } + } +} + +// A clean in progress stops at a transition out of BACKGROUNDACTIVE and runs +// to completion across a transition into it. +func TestLevelDbCleanerRunningCleanFollowsAppState(t *testing.T) { + for _, next := range cleanerStates { + t.Run(next.String(), func(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-running", 0) + defer tc.Cleanup() + config := testCleanerConfig() + config.SleepInterval = 100 * time.Millisecond + db := newMobileCleanerDb(t, &tc, config) + start := keybase1.MobileAppState_INACTIVE + if next == start { + start = keybase1.MobileAppState_FOREGROUND + } + tc.G.MobileAppState.Update(start) + const numKeys = 3500 + for i := range numKeys { + require.NoError(t, db.Put(DbKey{Key: fmt.Sprintf("k%05d", i), Typ: 0}, nil, []byte{1})) + } + waitCleanerMonitor(t, db.cleaner) + db.cleaner.clearCache() + + done := make(chan error, 1) + go func() { done <- db.cleaner.clean(true /* force */) }() + require.Eventually(t, func() bool { + db.cleaner.Lock() + defer db.cleaner.Unlock() + return db.cleaner.running + }, 10*time.Second, time.Millisecond) + tc.G.MobileAppState.Update(next) + waitCleanerMonitor(t, db.cleaner) + require.NoError(t, <-done) + + _, found, err := db.Get(DbKey{Key: fmt.Sprintf("k%05d", numKeys-1), Typ: 0}) + require.NoError(t, err) + require.Equal(t, next != keybase1.MobileAppState_BACKGROUNDACTIVE, found, + "last key after a clean across a transition to %v", next) + }) + } +} + +func TestLevelDbCleanerSeedsFromState(t *testing.T) { + for _, initial := range cleanerStates { + t.Run(initial.String(), func(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-seed", 0) + defer tc.Cleanup() + tc.G.MobileAppState.Update(initial) + db := newMobileCleanerDb(t, &tc, testCleanerConfig()) + cancelCh, monitors := db.cleaner.snapshot() + require.Zero(t, monitors, "monitor running before the db opened") + require.NoError(t, db.ForceOpen()) + waitCleanerMonitor(t, db.cleaner) + require.False(t, isClosed(cancelCh), "canceled without a transition from %v", initial) + }) + } +} + +func TestLevelDbCleanerMonitorSurvivesReopen(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-reopen", 0) + defer tc.Cleanup() + db := newMobileCleanerDb(t, &tc, testCleanerConfig()) + require.NoError(t, db.ForceOpen()) + waitCleanerMonitor(t, db.cleaner) + requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUND) + + reopens := map[string]func(){ + "nuke": func() { + _, err := db.Nuke() + require.NoError(t, err) + require.NoError(t, db.ForceOpen()) + }, + "close": func() { + 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()) + }, + } + for _, name := range []string{"nuke", "close", "nuke"} { + reopens[name]() + _, monitors := db.cleaner.snapshot() + require.Equal(t, 1, monitors, "after %s", name) + waitCleanerMonitor(t, db.cleaner) + requireCancelOnTransition(t, db, keybase1.MobileAppState_FOREGROUND) + requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUNDACTIVE) + requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUND) + + // A reopened cleaner cleans again. + 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) + } + require.NoError(t, db.Close()) + require.Eventually(t, func() bool { + _, monitors := db.cleaner.snapshot() + return monitors == 0 + }, 10*time.Second, time.Millisecond, "monitor outlived Close") +} + +func TestLevelDbCleanerScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-scenario", 0) + defer tc.Cleanup() + h := lifecycletest.NewHarness(t, tc.G.MobileAppState, sc.Platform) + defer h.Close() + db := newMobileCleanerDb(t, &tc, testCleanerConfig()) + require.NoError(t, db.ForceOpen()) + waitCleanerMonitor(t, db.cleaner) + prev := sc.Platform.InitialState() + for i, step := range sc.Steps { + cancelCh, _ := db.cleaner.snapshot() + h.Do(step) + waitCleanerMonitor(t, db.cleaner) + db.cleaner.Lock() + monitorState := db.cleaner.monitorState + db.cleaner.Unlock() + require.Equal(t, step.Want, monitorState, "step %d %v", i, step.Do) + canceled := isClosed(cancelCh) + switch { + case step.Want != prev && step.Want != keybase1.MobileAppState_BACKGROUNDACTIVE: + require.True(t, canceled, "step %d %v: clean not canceled in %v", i, step.Do, step.Want) + case step.Want == keybase1.MobileAppState_BACKGROUNDACTIVE && step.Gen <= 1: + require.False(t, canceled, "step %d %v: clean canceled in BACKGROUNDACTIVE", i, step.Do) + case step.Want == prev && step.Gen <= 1: + require.False(t, canceled, "step %d %v: clean canceled without a transition", i, step.Do) + } + prev = step.Want + } + h.CheckObserved(sc.Observed) + }) + } +} + +// Nukes, closes and reopens racing app-state changes leave one working +// monitor while the db is open and none after it closes. +func TestLevelDbCleanerMonitorStress(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-stress", 0) + defer tc.Cleanup() + baseline := runtime.NumGoroutine() + db := newMobileCleanerDb(t, &tc, testCleanerConfig()) + + var wg sync.WaitGroup + stop := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + tc.G.MobileAppState.Update(cleanerStates[i%len(cleanerStates)]) + } + }() + for w := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for i := range 50 { + switch (w + i) % 3 { + case 0: + _, _ = db.Nuke() + case 1: + _ = db.Close() + default: + _ = db.Put(DbKey{Key: fmt.Sprintf("w%d-%d", w, i), Typ: 0}, nil, []byte{1}) + } + _ = db.ForceOpen() + } + }() + } + time.Sleep(10 * time.Millisecond) + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for range 50 { + _ = db.ForceOpen() + } + }() + } + time.Sleep(200 * time.Millisecond) + close(stop) + wg.Wait() + + // A racing Close leaves one failed open before the next open succeeds. + require.Eventually(t, func() bool { return db.ForceOpen() == nil }, 10*time.Second, time.Millisecond) + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + waitCleanerMonitor(t, db.cleaner) + requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUND) + + require.NoError(t, db.Close()) + require.Eventually(t, func() bool { + _, monitors := db.cleaner.snapshot() + return monitors == 0 && runtime.NumGoroutine() <= baseline+5 + }, 10*time.Second, 10*time.Millisecond, "monitor or goroutines outlived Close") +} From 987ef6706d023e7195ce2313663b1dfa2b12309c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 15:48:58 -0400 Subject: [PATCH 020/127] fix(kbfs): end paused background loops on shutdown and keep a cellular pause across app-state changes The quota reclamation, disk cache cleaning and search indexing loops wait for FOREGROUND without watching shutdown, so shutting down while backgrounded leaked them or hung the indexer's Shutdown. They now return on shutdown while paused. The prefetcher paused for the app state and for a cell network in separate waits that each unpaused on exit and ignored the other reason. One wait now watches both and unpauses only when neither holds. --- go/kbfs/libkbfs/app_state_test.go | 259 +++++++++++++++++++++++ go/kbfs/libkbfs/folder_block_manager.go | 12 +- go/kbfs/libkbfs/prefetcher.go | 89 ++++---- go/kbfs/search/indexer.go | 9 +- go/kbfs/search/indexer_app_state_test.go | 93 ++++++++ 5 files changed, 406 insertions(+), 56 deletions(-) create mode 100644 go/kbfs/libkbfs/app_state_test.go create mode 100644 go/kbfs/search/indexer_app_state_test.go 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..5511dd428289 100644 --- a/go/kbfs/libkbfs/folder_block_manager.go +++ b/go/kbfs/libkbfs/folder_block_manager.go @@ -1318,7 +1318,11 @@ func (fbm *folderBlockManager) reclaimQuotaInBackground() { for state != keybase1.MobileAppState_FOREGROUND { fbm.log.CDebugf(context.Background(), "Pausing QR while not foregrounded: state=%s", state) - <-fbm.appStateUpdater.NextAppStateUpdate(state) + select { + case <-fbm.appStateUpdater.NextAppStateUpdate(state): + case <-fbm.shutdownChan: + return + } state = fbm.appStateUpdater.AppState() } fbm.log.CDebugf( @@ -1594,7 +1598,11 @@ func (fbm *folderBlockManager) cleanDiskCachesInBackground() { fbm.log.CDebugf(context.Background(), "Pausing sync-cache cleaning while not foregrounded: "+ "state=%s", state) - <-fbm.appStateUpdater.NextAppStateUpdate(state) + select { + case <-fbm.appStateUpdater.NextAppStateUpdate(state): + case <-fbm.shutdownChan: + return + } state = fbm.appStateUpdater.AppState() } fbm.log.CDebugf(context.Background(), 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..3b0e7dce1028 100644 --- a/go/kbfs/search/indexer.go +++ b/go/kbfs/search/indexer.go @@ -1410,7 +1410,14 @@ outerLoop: i.log.CDebugf(ctx, "Pausing indexing while not foregrounded: state=%s", state) - <-kbCtx.NextAppStateUpdate(state) + select { + case <-kbCtx.NextAppStateUpdate(state): + case <-ctx.Done(): + return + case <-i.shutdownCh: + i.cancelLoop() + return + } state = kbCtx.AppState() } i.log.CDebugf(ctx, "Resuming indexing while foregrounded") 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") +} From 64f7f417be0eb56e7be2e107417f0b0042f5b332 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 15:48:58 -0400 Subject: [PATCH 021/127] fix(kbfs): run the local HTTP server in every app state but BACKGROUND The server restarted on every FOREGROUND, which broke in-flight requests after an INACTIVE blip, never stopped in BACKGROUND, and registered its handler after serving, so a restart could answer 404. It now starts only outside BACKGROUND (including at a background launch), stops on BACKGROUND, starts a dead server on any other transition or once after an unexpected exit, and registers handlers before accepting connections. --- go/kbfs/libhttpserver/appstate.go | 184 +++++++++ go/kbfs/libhttpserver/appstate_test.go | 526 +++++++++++++++++++++++++ go/kbfs/libhttpserver/server.go | 85 +--- 3 files changed, 725 insertions(+), 70 deletions(-) create mode 100644 go/kbfs/libhttpserver/appstate.go create mode 100644 go/kbfs/libhttpserver/appstate_test.go diff --git a/go/kbfs/libhttpserver/appstate.go b/go/kbfs/libhttpserver/appstate.go new file mode 100644 index 000000000000..9d93b5b2eb88 --- /dev/null +++ b/go/kbfs/libhttpserver/appstate.go @@ -0,0 +1,184 @@ +// 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 libhttpserver + +import ( + "errors" + "net/http" + "sync" + + "github.com/keybase/client/go/kbfs/env" + "github.com/keybase/client/go/kbhttp" + "github.com/keybase/client/go/logger" + "github.com/keybase/client/go/protocol/keybase1" +) + +// appStateServer runs an HTTP server that is up in every app state except +// BACKGROUND. Moving between up states (for example an INACTIVE blip from +// Control Center) leaves a running server alone, so in-flight requests +// survive, and restarts one that is not serving. +type appStateServer struct { + appStateUpdater env.AppStateUpdater + logger logger.Logger + newSource func() kbhttp.ListenerSource + register func(mux *http.ServeMux) + + // mu guards everything below and serializes starts and stops. + mu sync.Mutex + server *kbhttp.Srv + shutdown bool + // changes counts the app-state changes the monitor has acted on, and + // exitRestart is one past its value at the last restart after an + // unexpected exit, so a listener that keeps dying restarts at most once + // per change. + changes uint64 + exitRestart uint64 + // exits counts handled unexpected exits, for tests. + exits int + // beforeExitRestart, if set, runs in serverExited between reading the + // app state and acting on it. Tests only. + beforeExitRestart func() + // monitorState is the state the monitor last acted on, and monitorWait + // the change channel it waits on for that state; tests use them to wait + // until the monitor has caught up. + monitorState keybase1.MobileAppState + monitorWait <-chan struct{} + + shutdownCh chan struct{} + monitorDone chan struct{} +} + +func newAppStateServer( + appStateUpdater env.AppStateUpdater, log logger.Logger, + newSource func() kbhttp.ListenerSource, register func(mux *http.ServeMux), +) *appStateServer { + s := &appStateServer{ + appStateUpdater: appStateUpdater, + logger: log, + newSource: newSource, + register: register, + shutdownCh: make(chan struct{}), + monitorDone: make(chan struct{}), + } + s.server = s.newServer() + return s +} + +// start starts serving unless the app is in BACKGROUND, and follows app-state +// changes until Shutdown. An error starting the server is returned, and +// nothing is left running. +func (s *appStateServer) start() error { + s.mu.Lock() + state := s.appStateUpdater.AppState() + var err error + if wantUp(state) { + err = s.startLocked() + } + s.mu.Unlock() + if err != nil { + close(s.monitorDone) + return err + } + go s.monitorAppState(state) + return nil +} + +func wantUp(state keybase1.MobileAppState) bool { + return state != keybase1.MobileAppState_BACKGROUND +} + +func (s *appStateServer) newServer() *kbhttp.Srv { + server := kbhttp.NewSrv(s.logger, s.newSource()) + server.OnUnexpectedExit(s.serverExited) + return server +} + +// startLocked starts the server unless it is serving or shut down. Handlers +// are registered before it accepts connections, so a restart never answers +// 404. +func (s *appStateServer) startLocked() error { + if s.shutdown || s.server.Active() { + return nil + } + err := s.server.StartWithHandlers(s.register) + if errors.Is(err, kbhttp.ErrPinnedPortInUse) { + // Pick a new port like we never had a server before. + s.server = s.newServer() + err = s.server.StartWithHandlers(s.register) + } + return err +} + +func (s *appStateServer) reconcileLocked(state keybase1.MobileAppState) { + if !wantUp(state) { + <-s.server.Stop() + return + } + if err := s.startLocked(); err != nil { + s.logger.Error("Starting server in %v failed: %v", state, err) + } +} + +func (s *appStateServer) monitorAppState(state keybase1.MobileAppState) { + defer close(s.monitorDone) + for { + next := s.appStateUpdater.NextAppStateUpdate(state) + s.mu.Lock() + s.monitorState, s.monitorWait = state, next + s.mu.Unlock() + select { + case <-next: + case <-s.shutdownCh: + return + } + s.mu.Lock() + // Read the state under mu, so an unexpected exit deciding concurrently + // sees either the state before this change or its outcome. + state = s.appStateUpdater.AppState() + s.changes++ + s.reconcileLocked(state) + s.mu.Unlock() + } +} + +// serverExited restarts a server whose listener died without a Stop, for +// example one the OS reclaimed while the app was suspended. +func (s *appStateServer) serverExited() { + s.mu.Lock() + defer s.mu.Unlock() + state := s.appStateUpdater.AppState() + if s.beforeExitRestart != nil { + s.beforeExitRestart() + } + s.exits++ + if !wantUp(state) || s.exitRestart == s.changes+1 { + s.logger.Debug("Not restarting server after it exited in %v", state) + return + } + s.exitRestart = s.changes + 1 + if err := s.startLocked(); err != nil { + s.logger.Error("Restarting server after it exited failed: %v", err) + } +} + +// Addr returns the address the server is listening on, if it is running. +func (s *appStateServer) Addr() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.server.Addr() +} + +// Shutdown stops the server for good and waits for it and the monitor to +// exit. +func (s *appStateServer) Shutdown() { + s.mu.Lock() + if !s.shutdown { + s.shutdown = true + close(s.shutdownCh) + <-s.server.Stop() + } + s.mu.Unlock() + <-s.monitorDone +} diff --git a/go/kbfs/libhttpserver/appstate_test.go b/go/kbfs/libhttpserver/appstate_test.go new file mode 100644 index 000000000000..9295cc98b34b --- /dev/null +++ b/go/kbfs/libhttpserver/appstate_test.go @@ -0,0 +1,526 @@ +// 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 libhttpserver + +import ( + "errors" + "fmt" + "io" + "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/logger" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// mobileAppState adapts libkb's app state to env.AppStateUpdater, as +// env.KBFSContext does. +type mobileAppState struct { + *libkb.MobileAppState +} + +func (m mobileAppState) NextAppStateUpdate(last keybase1.MobileAppState) <-chan struct{} { + return m.NextUpdate(last) +} + +func (m mobileAppState) AppState() keybase1.MobileAppState { return m.State() } + +func (m mobileAppState) NextNetworkStateUpdate(keybase1.MobileNetworkState) <-chan struct{} { + return nil +} + +func (m mobileAppState) NetworkState() keybase1.MobileNetworkState { + return keybase1.MobileNetworkState_NONE +} + +// listeners hands out pinned random-port listener sources and remembers the +// last listener, so a test can kill it underneath the server. +type listeners struct { + sync.Mutex + calls int + last net.Listener + // failing makes new listeners fail on their first Accept, so Serve + // returns right away. + failing atomic.Bool + // onListen, if set, runs with the address of each new listener before + // the server gets it. + onListen func(address string) +} + +type failingListener struct { + net.Listener +} + +func (failingListener) Accept() (net.Conn, error) { + return nil, errors.New("listener failed") +} + +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() + s.l.calls++ + onListen := s.l.onListen + if err == nil { + s.l.last = listener + if s.l.failing.Load() { + listener = failingListener{listener} + } + } + s.l.Unlock() + if err == nil && onListen != nil { + onListen(address) + } + 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: 10 * time.Second, + Transport: &http.Transport{DisableKeepAlives: true}, +} + +type testServer struct { + *appStateServer + l *listeners + appState *libkb.MobileAppState + // hold, while set, blocks requests to /files/hold until it closes; + // entered receives a value when such a request arrives. + hold chan struct{} + entered chan struct{} + // slowRegister delays handler registration. + slowRegister atomic.Bool +} + +func setupServer(t *testing.T, state keybase1.MobileAppState) *testServer { + tc := libkb.SetupTest(t, "libhttpserver", 2) + t.Cleanup(tc.Cleanup) + tc.G.MobileAppState.Update(state) + return startServer(t, tc.G.MobileAppState) +} + +func startServer(t *testing.T, appState *libkb.MobileAppState) *testServer { + ts := &testServer{ + l: &listeners{}, + appState: appState, + hold: make(chan struct{}), + entered: make(chan struct{}, 10), + } + register := func(mux *http.ServeMux) { + if ts.slowRegister.Load() { + time.Sleep(100 * time.Millisecond) + } + mux.HandleFunc(requestPathRoot, func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == requestPathRoot+"hold" { + ts.entered <- struct{}{} + <-ts.hold + } + fmt.Fprint(w, "ok") + }) + } + ts.appStateServer = newAppStateServer( + mobileAppState{appState}, logger.NewTestLogger(t), ts.l.source, register) + require.NoError(t, ts.start()) + t.Cleanup(ts.Shutdown) + return ts +} + +func fetchAddr(addr, path string) (int, error) { + resp, err := client.Get(fmt.Sprintf("http://%s%s%s", addr, requestPathRoot, path)) + if err != nil { + return 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, 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 +} + +// waitMonitor waits until the monitor has acted on the current state and is +// waiting for the next change. +func (ts *testServer) waitMonitor(t *testing.T) { + t.Helper() + require.Eventually(t, func() bool { + ts.mu.Lock() + state, wait := ts.monitorState, ts.monitorWait + ts.mu.Unlock() + if wait == nil || wait != ts.appState.NextUpdate(state) { + return false + } + select { + case <-wait: + return false + default: + return true + } + }, 10*time.Second, time.Millisecond, "monitor did not catch up") +} + +func (ts *testServer) update(t *testing.T, state keybase1.MobileAppState) { + t.Helper() + ts.appState.Update(state) + ts.waitMonitor(t) +} + +func (ts *testServer) exitCount() int { + ts.mu.Lock() + defer ts.mu.Unlock() + return ts.exits +} + +func (ts *testServer) waitExits(t *testing.T, n int) { + t.Helper() + require.Eventually(t, func() bool { return ts.exitCount() >= n }, 10*time.Second, + time.Millisecond, "unexpected exit %d was not handled", n) + require.Equal(t, n, ts.exitCount()) +} + +func (ts *testServer) active() bool { + _, err := ts.Addr() + return err == nil +} + +// killUntilDown kills the listener until an unexpected exit is not +// restarted, because this app-state change already had its restart. +func (ts *testServer) killUntilDown(t *testing.T) { + t.Helper() + for range 2 { + n := ts.exitCount() + ts.l.kill(t) + ts.waitExits(t, n+1) + if !ts.active() { + return + } + } + t.Fatal("server kept restarting after unexpected exits") +} + +func (ts *testServer) requireServing(t *testing.T) string { + t.Helper() + addr, err := ts.Addr() + require.NoError(t, err, "server not running") + _, err = fetchAddr(addr, "x") + require.NoError(t, err) + return addr +} + +func (ts *testServer) requireStopped(t *testing.T) { + t.Helper() + _, err := ts.Addr() + require.Error(t, err, "server still running") +} + +var allStates = []keybase1.MobileAppState{ + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_BACKGROUND, +} + +func TestAppStateServerUpUnlessBackground(t *testing.T) { + for _, initial := range allStates { + t.Run(initial.String(), func(t *testing.T) { + ts := setupServer(t, initial) + ts.waitMonitor(t) + check := func() { + t.Helper() + if wantUp(ts.appState.State()) { + ts.requireServing(t) + } else { + ts.requireStopped(t) + } + } + check() + for range 2 { + for _, next := range allStates { + ts.update(t, next) + check() + } + } + }) + } + ts := setupServer(t, keybase1.MobileAppState_BACKGROUND) + require.Zero(t, ts.l.Calls(), "server started during a background launch") +} + +// An INACTIVE blip (Control Center, a system alert) neither restarts the +// server nor breaks a request in flight. +func TestAppStateServerInactiveBlipKeepsRequests(t *testing.T) { + for _, blip := range [][]keybase1.MobileAppState{ + {keybase1.MobileAppState_INACTIVE, keybase1.MobileAppState_FOREGROUND}, + {keybase1.MobileAppState_BACKGROUNDACTIVE, keybase1.MobileAppState_FOREGROUND}, + } { + t.Run(fmt.Sprint(blip), func(t *testing.T) { + ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) + ts.waitMonitor(t) + addr := ts.requireServing(t) + + res := make(chan error, 1) + go func() { + _, err := fetchAddr(addr, "hold") + res <- err + }() + select { + case <-ts.entered: + case <-time.After(10 * time.Second): + t.Fatal("request did not arrive") + } + for _, state := range blip { + ts.update(t, state) + } + close(ts.hold) + require.NoError(t, <-res, "in-flight request broke across %v", blip) + require.Equal(t, addr, ts.requireServing(t)) + require.Equal(t, 1, ts.l.Calls(), "server restarted across %v", blip) + }) + } +} + +func TestAppStateServerRestartsDeadServer(t *testing.T) { + ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) + ts.waitMonitor(t) + ts.requireServing(t) + + // Without a transition, a dead server restarts once. + ts.l.kill(t) + ts.waitExits(t, 1) + ts.requireServing(t) + require.Equal(t, 2, ts.l.Calls()) + + // A listener that keeps failing does not restart in a loop. + ts.l.failing.Store(true) + ts.l.kill(t) + ts.waitExits(t, 2) + require.Equal(t, 2, ts.l.Calls(), "restart loop on a failing listener") + ts.requireStopped(t) + + // Every up state brings a dead server back. + ts.l.failing.Store(false) + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } { + ts.update(t, next) + ts.requireServing(t) + ts.killUntilDown(t) + } + ts.update(t, keybase1.MobileAppState_FOREGROUND) + ts.requireServing(t) +} + +// A BACKGROUND applied while an unexpected exit is deciding whether to +// restart must not leave the server up. +func TestAppStateServerExitRacingBackground(t *testing.T) { + ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) + ts.waitMonitor(t) + ts.requireServing(t) + + ts.mu.Lock() + ts.beforeExitRestart = func() { + // serverExited has read FOREGROUND. The monitor is idle, so mu is + // held here only if serverExited holds it; otherwise let the monitor + // fully apply BACKGROUND before serverExited acts on its stale read. + holdsMu := !ts.mu.TryLock() + if !holdsMu { + ts.mu.Unlock() + } + ts.appState.Update(keybase1.MobileAppState_BACKGROUND) + if !holdsMu { + ts.waitMonitor(t) + } + } + ts.mu.Unlock() + + ts.l.kill(t) + ts.waitExits(t, 1) + ts.waitMonitor(t) + ts.requireStopped(t) +} + +// A request that reaches a restarting server is answered by its handler, +// never with a 404 from a server that has not registered it yet. +func TestAppStateServerNo404DuringRestart(t *testing.T) { + ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) + ts.waitMonitor(t) + ts.requireServing(t) + ts.slowRegister.Store(true) + + restarts := map[string]func(){ + "exit": func() { + n := ts.exitCount() + ts.l.kill(t) + ts.waitExits(t, n+1) + }, + "foreground": func() { + ts.update(t, keybase1.MobileAppState_BACKGROUND) + ts.update(t, keybase1.MobileAppState_FOREGROUND) + }, + } + for name, restart := range restarts { + // The request connects as soon as the new listener exists and is + // served once the server accepts it. + res := make(chan error, 1) + ts.l.Lock() + ts.l.onListen = func(address string) { + go func() { + _, err := fetchAddr(address, "x") + res <- err + }() + } + ts.l.Unlock() + restart() + require.NoError(t, <-res, "request during a restart by %s", name) + ts.l.Lock() + ts.l.onListen = nil + ts.l.Unlock() + ts.requireServing(t) + } +} + +func TestAppStateServerScenarioReplay(t *testing.T) { + for _, sc := range lifecycletest.Scenarios { + t.Run(sc.Name, func(t *testing.T) { + tc := libkb.SetupTest(t, "libhttpserver", 2) + defer tc.Cleanup() + tc.G.MobileAppState.Update(sc.Platform.InitialState()) + ts := startServer(t, tc.G.MobileAppState) + lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + ts.waitMonitor(t) + if !wantUp(step.Want) { + if ts.active() { + t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) + } + return + } + addr, err := ts.Addr() + if err != nil { + t.Fatalf("step %d %v: server down in %v", i, step.Do, step.Want) + } + if _, err := fetchAddr(addr, "x"); err != nil { + t.Fatalf("step %d %v: %v", i, step.Do, err) + } + // Leave the server dead before a step that moves to another + // up state, which must bring it back. + if i+1 < len(sc.Steps) { + next := sc.Steps[i+1].Want + if next != step.Want && wantUp(next) { + ts.killUntilDown(t) + } + } + }) + }) + } +} + +// Transitions, deaths and requests racing each other leave a working server +// and no goroutines after Shutdown. +func TestAppStateServerStress(t *testing.T) { + tc := libkb.SetupTest(t, "libhttpserver", 2) + defer tc.Cleanup() + baseline := runtime.NumGoroutine() + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + ts := startServer(t, tc.G.MobileAppState) + + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + tc.G.MobileAppState.Update(allStates[i%len(allStates)]) + } + }() + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + if addr, err := ts.Addr(); err == nil { + if status, err := fetchAddr(addr, "x"); err != nil && status != 0 && status != http.StatusOK { + t.Errorf("request: %v", err) + } + } + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + case <-time.After(5 * time.Millisecond): + } + ts.l.Lock() + if ts.l.last != nil { + _ = ts.l.last.Close() + } + ts.l.Unlock() + } + }() + time.Sleep(time.Second) + close(stop) + wg.Wait() + + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + ts.waitMonitor(t) + ts.update(t, keybase1.MobileAppState_FOREGROUND) + ts.requireServing(t) + + ts.Shutdown() + ts.requireStopped(t) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + ts.serverExited() + require.False(t, ts.active(), "server started after Shutdown") + require.Eventually(t, func() bool { + return runtime.NumGoroutine() <= baseline+5 + }, 10*time.Second, 10*time.Millisecond, "goroutines outlived Shutdown") +} diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index fb711fb8233a..c6109ff82e4d 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -8,7 +8,6 @@ import ( "context" "crypto/rand" "encoding/base64" - "errors" "io" "net/http" "path" @@ -33,11 +32,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 +42,7 @@ type Server struct { fs *lru.Cache - serverLock sync.RWMutex - server *kbhttp.Srv + server *appStateServer } const ( @@ -221,55 +217,9 @@ 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, +func (s *Server) registerHandlers(mux *http.ServeMux) { + mux.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. @@ -278,10 +228,9 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( ) { 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 +240,26 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( if err != nil { return nil, err } - if err = s.restart(); err != nil { + s.server = newAppStateServer(appStateUpdater, logger, + func() kbhttp.ListenerSource { + return kbhttp.NewRandomPortRangeListenerSource(portStart, portEnd) + }, s.registerHandlers) + if err = s.server.start(); err != nil { return nil, err } - ctx, cancel := context.WithCancel(context.Background()) - go s.monitorAppState(ctx) - s.cancel = cancel 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() } From cc5c5c515490faf340a65056a4a69a64d1b2952b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 15:57:39 -0400 Subject: [PATCH 022/127] fix(kbfs): keep the local HTTP server up in every app state on Android Android still reports BACKGROUND when an activity pauses for a picker, share sheet or permission prompt, so stopping there broke in-flight previews and GUI file context lookups. As with the kbhttp manager, the server now stops in BACKGROUND only on iOS; Android still restarts a dead server on transitions and after an unexpected exit. --- go/kbfs/libhttpserver/appstate.go | 29 +++++++++------ go/kbfs/libhttpserver/appstate_test.go | 51 +++++++++++++++++++++++--- go/kbfs/libhttpserver/server.go | 3 +- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/go/kbfs/libhttpserver/appstate.go b/go/kbfs/libhttpserver/appstate.go index 9d93b5b2eb88..c85ce48aa7c0 100644 --- a/go/kbfs/libhttpserver/appstate.go +++ b/go/kbfs/libhttpserver/appstate.go @@ -16,7 +16,7 @@ import ( ) // appStateServer runs an HTTP server that is up in every app state except -// BACKGROUND. Moving between up states (for example an INACTIVE blip from +// BACKGROUND, or in every state when it does not stop in the background. Moving between up states (for example an INACTIVE blip from // Control Center) leaves a running server alone, so in-flight requests // survive, and restarts one that is not serving. type appStateServer struct { @@ -24,6 +24,9 @@ type appStateServer struct { logger logger.Logger newSource func() kbhttp.ListenerSource register func(mux *http.ServeMux) + // stopInBackground is false on Android, where the server stays up in + // every state. + stopInBackground bool // mu guards everything below and serializes starts and stops. mu sync.Mutex @@ -53,14 +56,16 @@ type appStateServer struct { func newAppStateServer( appStateUpdater env.AppStateUpdater, log logger.Logger, newSource func() kbhttp.ListenerSource, register func(mux *http.ServeMux), + stopInBackground bool, ) *appStateServer { s := &appStateServer{ - appStateUpdater: appStateUpdater, - logger: log, - newSource: newSource, - register: register, - shutdownCh: make(chan struct{}), - monitorDone: make(chan struct{}), + appStateUpdater: appStateUpdater, + logger: log, + newSource: newSource, + register: register, + stopInBackground: stopInBackground, + shutdownCh: make(chan struct{}), + monitorDone: make(chan struct{}), } s.server = s.newServer() return s @@ -73,7 +78,7 @@ func (s *appStateServer) start() error { s.mu.Lock() state := s.appStateUpdater.AppState() var err error - if wantUp(state) { + if s.wantUp(state) { err = s.startLocked() } s.mu.Unlock() @@ -85,8 +90,8 @@ func (s *appStateServer) start() error { return nil } -func wantUp(state keybase1.MobileAppState) bool { - return state != keybase1.MobileAppState_BACKGROUND +func (s *appStateServer) wantUp(state keybase1.MobileAppState) bool { + return !s.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } func (s *appStateServer) newServer() *kbhttp.Srv { @@ -112,7 +117,7 @@ func (s *appStateServer) startLocked() error { } func (s *appStateServer) reconcileLocked(state keybase1.MobileAppState) { - if !wantUp(state) { + if !s.wantUp(state) { <-s.server.Stop() return } @@ -153,7 +158,7 @@ func (s *appStateServer) serverExited() { s.beforeExitRestart() } s.exits++ - if !wantUp(state) || s.exitRestart == s.changes+1 { + if !s.wantUp(state) || s.exitRestart == s.changes+1 { s.logger.Debug("Not restarting server after it exited in %v", state) return } diff --git a/go/kbfs/libhttpserver/appstate_test.go b/go/kbfs/libhttpserver/appstate_test.go index 9295cc98b34b..91e482f68c44 100644 --- a/go/kbfs/libhttpserver/appstate_test.go +++ b/go/kbfs/libhttpserver/appstate_test.go @@ -126,10 +126,10 @@ func setupServer(t *testing.T, state keybase1.MobileAppState) *testServer { tc := libkb.SetupTest(t, "libhttpserver", 2) t.Cleanup(tc.Cleanup) tc.G.MobileAppState.Update(state) - return startServer(t, tc.G.MobileAppState) + return startServer(t, tc.G.MobileAppState, true) } -func startServer(t *testing.T, appState *libkb.MobileAppState) *testServer { +func startServer(t *testing.T, appState *libkb.MobileAppState, stopInBackground bool) *testServer { ts := &testServer{ l: &listeners{}, appState: appState, @@ -149,7 +149,8 @@ func startServer(t *testing.T, appState *libkb.MobileAppState) *testServer { }) } ts.appStateServer = newAppStateServer( - mobileAppState{appState}, logger.NewTestLogger(t), ts.l.source, register) + mobileAppState{appState}, logger.NewTestLogger(t), ts.l.source, register, + stopInBackground) require.NoError(t, ts.start()) t.Cleanup(ts.Shutdown) return ts @@ -259,7 +260,7 @@ func TestAppStateServerUpUnlessBackground(t *testing.T) { ts.waitMonitor(t) check := func() { t.Helper() - if wantUp(ts.appState.State()) { + if ts.appState.State() != keybase1.MobileAppState_BACKGROUND { ts.requireServing(t) } else { ts.requireStopped(t) @@ -413,13 +414,51 @@ func TestAppStateServerNo404DuringRestart(t *testing.T) { } } +// Without stopping in the background (Android), the server serves in every +// state, and a dead one comes back on any transition or once after it exits. +func TestAppStateServerNotStoppingInBackgroundStaysUp(t *testing.T) { + tc := libkb.SetupTest(t, "libhttpserver", 2) + defer tc.Cleanup() + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + ts := startServer(t, tc.G.MobileAppState, false) + ts.waitMonitor(t) + ts.requireServing(t) + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_FOREGROUND, + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUND, + } { + ts.update(t, next) + ts.requireServing(t) + } + + n := ts.exitCount() + ts.l.kill(t) + ts.waitExits(t, n+1) + ts.requireServing(t) + + ts.killUntilDown(t) + ts.update(t, keybase1.MobileAppState_BACKGROUNDACTIVE) + ts.requireServing(t) + ts.killUntilDown(t) + ts.update(t, keybase1.MobileAppState_BACKGROUND) + ts.requireServing(t) +} + func TestAppStateServerScenarioReplay(t *testing.T) { for _, sc := range lifecycletest.Scenarios { t.Run(sc.Name, func(t *testing.T) { tc := libkb.SetupTest(t, "libhttpserver", 2) defer tc.Cleanup() tc.G.MobileAppState.Update(sc.Platform.InitialState()) - ts := startServer(t, tc.G.MobileAppState) + // Android keeps the server up in every state. + stopInBackground := sc.Platform == lifecycletest.IOS + wantUp := func(state keybase1.MobileAppState) bool { + return !stopInBackground || state != keybase1.MobileAppState_BACKGROUND + } + ts := startServer(t, tc.G.MobileAppState, stopInBackground) lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { ts.waitMonitor(t) if !wantUp(step.Want) { @@ -455,7 +494,7 @@ func TestAppStateServerStress(t *testing.T) { defer tc.Cleanup() baseline := runtime.NumGoroutine() tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - ts := startServer(t, tc.G.MobileAppState) + ts := startServer(t, tc.G.MobileAppState, true) stop := make(chan struct{}) var wg sync.WaitGroup diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index c6109ff82e4d..d8e6c142939a 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "path" + "runtime" "strings" "sync" "time" @@ -243,7 +244,7 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( s.server = newAppStateServer(appStateUpdater, logger, func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(portStart, portEnd) - }, s.registerHandlers) + }, s.registerHandlers, runtime.GOOS != "android") if err = s.server.start(); err != nil { return nil, err } From 0ecf32efec147ab18bc78675e24d540ad78b91b9 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 16:07:32 -0400 Subject: [PATCH 023/127] fix(ios): forward lifecycle events to Go off the main thread and deliver each push to JS exactly once Swift reports scene/app events through the Go lifecycle entry points on one serial queue and no longer reports state from didFinishLaunching. Each background entry owns its own UIKit background task, so a late end of an older task can no longer strand Go in BACKGROUNDACTIVE. The expiration handler and willTerminate bound their wait on Go. Pushes are emitted only once JS has registered its listener; before that a tap is kept for getInitialNotification and anything else is queued, so background launches without React Native lose nothing. Cold-start taps are taken from the scene connection options too, deduplicated against didReceive. The scene delegate clears the window and privacy cover on disconnect. --- rnmodules/react-native-kb/ios/Kb.h | 10 +- rnmodules/react-native-kb/ios/Kb.mm | 87 +++++----- shared/ios/Keybase/AppDelegate.swift | 231 ++++++++++++++++--------- shared/ios/Keybase/SceneDelegate.swift | 13 +- 4 files changed, 205 insertions(+), 136 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.h b/rnmodules/react-native-kb/ios/Kb.h index 929434082d22..f313e088131f 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -22,8 +22,8 @@ // 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); +// Emits to JS when its push listener is ready. Otherwise a tap +// (userInteraction) is kept for getInitialNotification and anything else is +// queued until JS is ready, so a push that arrives while React Native isn't +// running (a background launch never starts it) is not lost. +FOUNDATION_EXPORT void KbDeliverPushNotification(NSDictionary *notification); diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 48ad75ca8097..6994abddd7c9 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -49,7 +49,13 @@ + (id)sharedFsPathsHolder { static std::mutex kbSharedInstanceMutex; static BOOL kbPasteImageEnabled = NO; static NSString *kbStoredDeviceToken = nil; +// Push notifications that arrive before JS can take them. A tap waits in +// kbInitialNotification for getInitialNotification (the startup path); anything +// else waits in kbPendingNotifications and is emitted once JS is ready. +static std::mutex kbNotificationMutex; static NSDictionary *kbInitialNotification = nil; +static NSMutableArray *kbPendingNotifications = nil; +static const NSUInteger kbMaxPendingNotifications = 50; // 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 @@ -237,6 +243,12 @@ @implementation Kb { // lock) keeps this ivar and kbCurrentBridge consistent with each other // without ever nesting the two critical sections. std::shared_ptr myBridge_; + // Guarded by kbNotificationMutex. Set once this instance's JS has asked for + // the initial notification: JS registers its onPushNotification listener + // before that call, so an emit from here on has a listener. canEmit alone is + // not enough, since the emitter callback exists as soon as JS creates the + // module, well before the listener. + BOOL pushListenerReady_; } RCT_EXPORT_MODULE() @@ -799,13 +811,22 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime } RCT_EXPORT_METHOD(getInitialNotification: (RCTPromiseResolveBlock)resolve reject: (RCTPromiseRejectBlock)reject) { - if (kbInitialNotification) { - NSDictionary *notification = kbInitialNotification; + NSDictionary *notification = nil; + { + std::lock_guard lock(kbNotificationMutex); + notification = kbInitialNotification; kbInitialNotification = nil; - resolve(notification); - } else { - resolve([NSNull null]); + pushListenerReady_ = YES; + // Emitted under the lock so a push delivered concurrently can't overtake + // the ones queued before it. + if ([self canEmit]) { + for (NSDictionary *pending in kbPendingNotifications) { + [self emitOnPushNotification:pending]; + } + } + kbPendingNotifications = nil; } + resolve(notification ?: [NSNull null]); } RCT_EXPORT_METHOD(removeAllPendingNotificationRequests) { @@ -892,18 +913,26 @@ + (void)setDeviceToken:(NSString *)token { }); } -+ (void)setInitialNotification:(NSDictionary *)notification { - kbInitialNotification = notification; -} - -+ (void)emitPushNotification:(NSDictionary *)notification { ++ (void)deliverPushNotification:(NSDictionary *)notification { + std::lock_guard lock(kbNotificationMutex); Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { + if (instance && instance->pushListenerReady_ && [instance canEmit]) { [instance emitOnPushNotification:notification]; - NSLog(@"Kb.emitPushNotification: sent event 'onPushNotification' to JS"); - } else { - NSLog(@"Kb.emitPushNotification: WARNING - module not ready, event not sent"); + return; + } + if ([notification[@"userInteraction"] boolValue]) { + kbInitialNotification = notification; + NSLog(@"Kb.deliverPushNotification: JS not ready, stored tap for getInitialNotification"); + return; + } + if (!kbPendingNotifications) { + kbPendingNotifications = [NSMutableArray array]; } + if (kbPendingNotifications.count >= kbMaxPendingNotifications) { + [kbPendingNotifications removeObjectAtIndex:0]; + NSLog(@"Kb.deliverPushNotification: pending queue full, dropped the oldest"); + } + [kbPendingNotifications addObject:notification]; } - (void)handleHardwareKeyPressed:(NSNotification *)notification { @@ -952,32 +981,6 @@ 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; +void KbDeliverPushNotification(NSDictionary *notification) { + [Kb deliverPushNotification:notification]; } diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 04648fc15bdd..f6e56654e4f7 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -21,7 +21,8 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi var resignImageView: UIImageView? var fsPaths: [String: String] = [:] - var shutdownTask: UIBackgroundTaskIdentifier = .invalid + private let lifecycle = AppLifecycleForwarder(events: KeybaseLifecycleEvents()) + private var lastNotificationResponseKey: String? var iph: ItemProviderHelper? private var startupLogFileHandle: FileHandle? private let logQueue = DispatchQueue(label: "kb.startup.log", qos: .utility) @@ -37,17 +38,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi self.didLaunchSetupBefore() - // Tell Go the real app state right after init. Go defaults to foreground, - // so a background launch (silent push, background fetch) would otherwise - // look foregrounded until didLaunchSetupAfter runs — long enough to join - // a coin flip it can't finish. - self.notifyAppState(application) - - if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { - let notificationDict = Dictionary(uniqueKeysWithValues: remoteNotification.map { (String(describing: $0.key), $0.value) }) - KbSetInitialNotification(notificationDict) - } - 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 @@ -74,7 +64,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // Start FPS monitoring if launched with -PERF_FPS_MONITOR PerfFPSMonitor.startIfEnabled() - self.didLaunchSetupAfter(application: application) + self.didLaunchSetupAfter() return true } @@ -202,17 +192,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi self.writeStartupTimingLog("After Go init") } - func notifyAppState(_ application: UIApplication) { - let state = application.applicationState - log.info("notifyAppState: notifying service with new appState: \(state.rawValue)") - switch state { - case .active: Keybasego.KeybaseSetAppStateForeground() - case .background: Keybasego.KeybaseSetAppStateBackground() - case .inactive: Keybasego.KeybaseSetAppStateInactive() - default: Keybasego.KeybaseSetAppStateForeground() - } - } - func didLaunchSetupBefore() { setupGo() try? AVAudioSession.sharedInstance().setCategory(.ambient) @@ -221,9 +200,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // BGTaskScheduler.register must run before didFinishLaunching returns, so this // can't wait for the scene to connect. - func didLaunchSetupAfter(application: UIApplication) { - notifyAppState(application) - + func didLaunchSetupAfter() { BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in self.handleAppRefresh(task: task as! BGAppRefreshTask) } @@ -244,6 +221,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi dim = screenBounds.height } let square = CGRect(origin: screenBounds.origin, size: CGSize(width: dim, height: dim)) + self.resignImageView?.removeFromSuperview() self.resignImageView = UIImageView(frame: square) self.resignImageView?.contentMode = .center self.resignImageView?.alpha = 0 @@ -252,6 +230,14 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi if let view = self.resignImageView { window.addSubview(view) } } + // Called by SceneDelegate when the scene goes away; didStartReactNative + // rebuilds both if a new scene connects. + func didDisconnectScene() { + self.window = nil + self.resignImageView?.removeFromSuperview() + self.resignImageView = nil + } + func addDrop(_ rootView: UIView) { let dropInteraction = UIDropInteraction(delegate: self) dropInteraction.allowsSimultaneousDropSessions = true @@ -353,38 +339,41 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("Remote notification handle finished...") } } else { - var notificationDict = Dictionary(uniqueKeysWithValues: notification.map { (String(describing: $0.key), $0.value) }) - notificationDict["userInteraction"] = false - KbEmitPushNotification(notificationDict) + KbDeliverPushNotification(Self.pushPayload(notification, userInteraction: false)) completionHandler(.newData) } } - 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 + private static func pushPayload(_ userInfo: [AnyHashable: Any], userInteraction: Bool) -> [String: Any] { + var payload = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) + payload["userInteraction"] = userInteraction + return payload + } - // 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) + // A tap that cold-starts the app can arrive both here (via the scene's + // connection options) and through userNotificationCenter(_:didReceive:), so + // deliver each response once. + func handleNotificationResponse(_ response: UNNotificationResponse) { + let notification = response.notification + let key = "\(notification.request.identifier)|\(notification.date.timeIntervalSince1970)" + guard key != lastNotificationResponseKey else { return } + lastNotificationResponseKey = key + KbDeliverPushNotification(Self.pushPayload(notification.request.content.userInfo, userInteraction: true)) + } - // Also emit immediately in case React Native is ready - KbEmitPushNotification(notificationDict) + public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + handleNotificationResponse(response) 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) + KbDeliverPushNotification(Self.pushPayload(notification.request.content.userInfo, userInteraction: false)) completionHandler([]) } override func applicationWillTerminate(_ application: UIApplication) { self.window?.rootViewController?.view.isHidden = true - Keybasego.KeybaseAppWillExit(PushNotifier()) + lifecycle.willTerminate() } func hideCover() { @@ -403,7 +392,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } completion: { finished in log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") } - Keybasego.KeybaseSetAppStateInactive() + lifecycle.willResignActive() } override func applicationDidEnterBackground(_ application: UIApplication) { @@ -414,58 +403,20 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationDidEnterBackground: setting keyz screen alpha to 1.") self.resignImageView?.alpha = 1 - log.info("applicationDidEnterBackground: notifying go.") - let requestTime = Keybasego.KeybaseAppDidEnterBackground() - log.info("applicationDidEnterBackground: after notifying go.") - - if requestTime && (self.shutdownTask == UIBackgroundTaskIdentifier.invalid) { - self.shutdownTask = UIApplication.shared.beginBackgroundTask { - // Expiration handler runs on the main thread. - log.info("applicationDidEnterBackground: shutdown task run.") - Keybasego.KeybaseAppWillExit(PushNotifier()) - self.endShutdownTask() - } - - DispatchQueue.global(qos: .default).async { - Keybasego.KeybaseAppBeginBackgroundTask(PushNotifier()) - DispatchQueue.main.async { - self.endShutdownTask() - } - } - } - } - - // Main thread only: serializes the expiration handler and the background - // work both trying to end the same task. - private func endShutdownTask() { - let task = self.shutdownTask - guard task != .invalid else { return } - self.shutdownTask = .invalid - UIApplication.shared.endBackgroundTask(task) + lifecycle.didEnterBackground(application) } override func applicationDidBecomeActive(_ application: UIApplication) { log.info("applicationDidBecomeActive: hiding keyz screen.") hideCover() - log.info("applicationDidBecomeActive: notifying service.") - // Forwarded from sceneDidBecomeActive, where applicationState still reads - // .inactive; notifyAppState would stop the http server. - Keybasego.KeybaseSetAppStateForeground() - - // 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.willEnterForeground() } func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { @@ -474,6 +425,114 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } +// The Go lifecycle events, one bind call each. Go decides what state each event +// means (go/libkb/lifecycle); nothing here may derive state, and +// UIApplication.applicationState lags inside the scene-forwarded callbacks +// anyway. +protocol AppLifecycleEvents { + func willEnterForeground() + func didBecomeActive() + func willResignActive() + // True when Go wants to keep running; runBackgroundTask then does that work. + func didEnterBackground() -> Bool + func runBackgroundTask() + func backgroundTaskExpired() + func willTerminate() +} + +struct KeybaseLifecycleEvents: AppLifecycleEvents { + func willEnterForeground() { Keybasego.KeybaseAppWillEnterForeground() } + func didBecomeActive() { Keybasego.KeybaseAppDidBecomeActive() } + func willResignActive() { Keybasego.KeybaseAppWillResignActive() } + func didEnterBackground() -> Bool { Keybasego.KeybaseAppDidEnterBackground() } + func runBackgroundTask() { Keybasego.KeybaseAppBeginBackgroundTask(PushNotifier()) } + func backgroundTaskExpired() { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } + func willTerminate() { Keybasego.KeybaseAppWillExit(PushNotifier()) } +} + +// Hands lifecycle events to Go on one serial queue, so Go sees them in callback +// order without the main thread waiting on Go (didEnterBackground queries the +// chat outbox). Also owns the UIKit background task that keeps the app alive +// while Go decides and does its background work. Main thread only. +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 + + private let events: AppLifecycleEvents + private let queue = DispatchQueue(label: "com.keybase.app.lifecycle", qos: .userInitiated) + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + init(events: AppLifecycleEvents) { + self.events = events + } + + func willEnterForeground() { queue.async { self.events.willEnterForeground() } } + func didBecomeActive() { queue.async { self.events.didBecomeActive() } } + func willResignActive() { queue.async { self.events.willResignActive() } } + + func willTerminate() { + runBounded { $0.willTerminate() } + } + + // Every background entry starts its own task before asking Go, so the app + // can't suspend mid-query, and takes over from a task an earlier entry left + // running: that task's pending end or expiration then finds it no longer + // current and does nothing. + func didEnterBackground(_ application: UIApplication) { + let owner = BackgroundTaskOwner() + let task = application.beginBackgroundTask(withName: "kb.didEnterBackground") { [weak self] in + self?.backgroundTaskExpired(owner.task) + } + owner.task = task + let previous = backgroundTask + backgroundTask = task + if previous != .invalid { + application.endBackgroundTask(previous) + } + queue.async { + guard self.events.didEnterBackground() else { + DispatchQueue.main.async { self.endBackgroundTask(task) } + return + } + DispatchQueue.global(qos: .default).async { + self.events.runBackgroundTask() + DispatchQueue.main.async { self.endBackgroundTask(task) } + } + } + } + + private func backgroundTaskExpired(_ task: UIBackgroundTaskIdentifier) { + guard task != .invalid, task == backgroundTask else { return } + log.info("background task expired") + runBounded { $0.backgroundTaskExpired() } + endBackgroundTask(task) + } + + private func endBackgroundTask(_ task: UIBackgroundTaskIdentifier) { + guard task != .invalid, task == backgroundTask else { return } + backgroundTask = .invalid + UIApplication.shared.endBackgroundTask(task) + } + + // Queued behind earlier events to keep the order; the wait only bounds how + // long the app stays alive for it. + private func runBounded(_ work: @escaping (AppLifecycleEvents) -> Void) { + let done = DispatchSemaphore(value: 0) + queue.async { + work(self.events) + done.signal() + } + _ = done.wait(timeout: .now() + Self.exitWorkTimeout) + } +} + +// The expiration handler is created before beginBackgroundTask returns the id +// it needs. +private final class BackgroundTaskOwner { + var task: UIBackgroundTaskIdentifier = .invalid +} + class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { // Extension point for config-plugins diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift index 17be345c2006..fec33c493d06 100644 --- a/shared/ios/Keybase/SceneDelegate.swift +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -10,9 +10,16 @@ class SceneDelegate: ExpoAppSceneDelegate { ) { super.scene(scene, willConnectTo: session, options: connectionOptions) - guard let window = self.window, - let appDelegate = UIApplication.shared.delegate as? AppDelegate - else { return } + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } + if let response = connectionOptions.notificationResponse { + appDelegate.handleNotificationResponse(response) + } + guard let window = self.window else { return } appDelegate.didStartReactNative(in: window) } + + override func sceneDidDisconnect(_ scene: UIScene) { + super.sceneDidDisconnect(scene) + (UIApplication.shared.delegate as? AppDelegate)?.didDisconnectScene() + } } From f7e5d354a491768c3986fee2c2c34b5802b7811b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 16:18:53 -0400 Subject: [PATCH 024/127] fix(appstate): apply termination before the pending-message warning and gate queued iOS pushes WillTerminate now forces BACKGROUND and kicks the flush before the slow pending-message notification, so native's short wait can't cut the state change. SetAppStateInactive had no callers and is removed. react-native-kb gains pushListenerRegistered as the explicit JS readiness signal (getInitialNotification remains a fallback). Queued non-tap pushes older than ten minutes are dropped when flushed, navigation-only pushes are never queued without a tap, and a queue that can't be emitted yet is kept. --- go/bind/keybase.go | 8 -- go/libkb/lifecycle/controller_test.go | 34 ++++++++ go/libkb/lifecycle/lifecycle.go | 7 +- .../main/java/com/reactnativekb/KbModule.kt | 5 ++ rnmodules/react-native-kb/ios/Kb.mm | 87 ++++++++++++++++--- rnmodules/react-native-kb/src/NativeKb.ts | 1 + rnmodules/react-native-kb/src/index.tsx | 5 ++ 7 files changed, 123 insertions(+), 24 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index bcf6eac9a5e3..3410c7dbf2cf 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -891,14 +891,6 @@ func SetAppStateBackground() { kbCtx.MobileLifecycle.DidEnterBackground(func() bool { return false }) } -func SetAppStateInactive() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateInactive", nil)() - kbCtx.MobileLifecycle.WillResignActive() -} - func SetAppStateBackgroundActive() { if !isInited() { return diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 6487cc3d0b65..60208db851ba 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -142,6 +142,40 @@ func TestPushWindowEndStaleTokenSkipsStayRunning(t *testing.T) { require.Equal(t, foreground, 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.DidBecomeActive() }, + do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, + }, + "backgroundTaskExpired": { + prepare: func(c *lifecycle.Controller) { require.True(t, c.DidEnterBackground(stay)) }, + 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++ }}) + 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) + }) + } +} + func TestEventString(t *testing.T) { require.Equal(t, "willEnterForeground", lifecycle.EventWillEnterForeground.String()) require.Equal(t, "liveLocationRelease", lifecycle.EventLiveLocationRelease.String()) diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index f270bc204809..7a8651644e67 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -218,11 +218,14 @@ func (c *Controller) DidEnterBackground(stayRunning func() bool) bool { } // WillTerminate forces BACKGROUND regardless of owners: the process is about -// to die. notifyPending warns about messages that won't send. +// to die. notifyPending warns about messages that won't send. It runs last: +// it can take seconds (an outbox query and a local notification), and native +// only waits briefly before the process exits, so the state change and the +// flush must not wait behind it. func (c *Controller) WillTerminate(notifyPending func()) { - notifyPending() c.taskGen.Store(0) c.update(keybase1.MobileAppState_BACKGROUND) + notifyPending() c.debug(EventWillTerminate, "applied") } 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..0b4c894fdde7 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 @@ -187,6 +187,11 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } + // Only iOS queues pushes until JS listens. + @ReactMethod + override fun pushListenerRegistered() { + } + // Sharing @ReactMethod override fun androidShare(uriPath: String, mimeType: String, promise: Promise) { diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 6994abddd7c9..4142df2fc71d 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -56,6 +56,25 @@ + (id)sharedFsPathsHolder { static NSDictionary *kbInitialNotification = nil; static NSMutableArray *kbPendingNotifications = nil; static const NSUInteger kbMaxPendingNotifications = 50; +// A background-launched process can sit suspended for hours before the user +// opens the app. The only thing a queued non-tap push still does in JS is +// badge upkeep (chat.readmessage), and JS reloads badge state from the service +// at startup, so anything older than this is superseded rather than useful. +static const uint64_t kbMaxPendingNotificationAgeNs = 10 * 60 * NSEC_PER_SEC; +static NSString *const kbPendingPayloadKey = @"payload"; +static NSString *const kbPendingQueuedAtKey = @"queuedAt"; + +// Continues while the device sleeps, unlike mach_absolute_time, so a push +// queued before a long sleep reads as old. +static uint64_t kbMonotonicNowNs(void) { + return clock_gettime_nsec_np(CLOCK_MONOTONIC); +} + +// Pushes whose only effect in JS is navigation. Without a tap they must not +// navigate, so they are never queued for a later JS. +static BOOL kbIsNavigationOnlyPush(NSDictionary *notification) { + return [notification[@"type"] isEqual:@"chat.extension"]; +} // 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 @@ -243,14 +262,43 @@ @implementation Kb { // lock) keeps this ivar and kbCurrentBridge consistent with each other // without ever nesting the two critical sections. std::shared_ptr myBridge_; - // Guarded by kbNotificationMutex. Set once this instance's JS has asked for - // the initial notification: JS registers its onPushNotification listener - // before that call, so an emit from here on has a listener. canEmit alone is - // not enough, since the emitter callback exists as soon as JS creates the - // module, well before the listener. + // Guarded by kbNotificationMutex. Set once this instance's JS has registered + // its onPushNotification listener (pushListenerRegistered, or the + // getInitialNotification fallback). canEmit alone is not enough, since the + // emitter callback exists as soon as JS creates the module, well before the + // listener. BOOL pushListenerReady_; } +// REQUIRES kbNotificationMutex. Marks this instance's JS as listening and emits +// the queued pushes that are still fresh. Emitting under the lock keeps a push +// delivered concurrently from overtaking the ones queued before it. +- (void)pushListenerReadyLocked { + pushListenerReady_ = YES; + if (kbPendingNotifications.count == 0) { + return; + } + if (![self canEmit]) { + NSLog(@"Kb.pushListenerReady: emitter not ready, keeping %lu queued pushes", + (unsigned long)kbPendingNotifications.count); + return; + } + uint64_t now = kbMonotonicNowNs(); + NSUInteger stale = 0; + for (NSDictionary *pending in kbPendingNotifications) { + uint64_t queuedAt = [pending[kbPendingQueuedAtKey] unsignedLongLongValue]; + if (now - queuedAt > kbMaxPendingNotificationAgeNs) { + stale++; + continue; + } + [self emitOnPushNotification:pending[kbPendingPayloadKey]]; + } + if (stale > 0) { + NSLog(@"Kb.pushListenerReady: dropped %lu stale queued pushes", (unsigned long)stale); + } + kbPendingNotifications = nil; +} + RCT_EXPORT_MODULE() + (BOOL)requiresMainQueueSetup { @@ -508,6 +556,11 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime RCT_EXPORT_METHOD(shareListenersRegistered) { } +RCT_EXPORT_METHOD(pushListenerRegistered) { + std::lock_guard lock(kbNotificationMutex); + [self pushListenerReadyLocked]; +} + // No current caller (kept for future use). RCT_EXPORT_METHOD(engineReset) { NSError *error = nil; @@ -816,15 +869,11 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime std::lock_guard lock(kbNotificationMutex); notification = kbInitialNotification; kbInitialNotification = nil; - pushListenerReady_ = YES; - // Emitted under the lock so a push delivered concurrently can't overtake - // the ones queued before it. - if ([self canEmit]) { - for (NSDictionary *pending in kbPendingNotifications) { - [self emitOnPushNotification:pending]; - } + // Fallback until JS calls pushListenerRegistered: today JS registers its + // listener before it asks for the initial notification. + if (!pushListenerReady_) { + [self pushListenerReadyLocked]; } - kbPendingNotifications = nil; } resolve(notification ?: [NSNull null]); } @@ -917,6 +966,9 @@ + (void)deliverPushNotification:(NSDictionary *)notification { std::lock_guard lock(kbNotificationMutex); Kb *instance = kbSharedInstance; if (instance && instance->pushListenerReady_ && [instance canEmit]) { + if (kbPendingNotifications.count > 0) { + [instance pushListenerReadyLocked]; + } [instance emitOnPushNotification:notification]; return; } @@ -925,6 +977,10 @@ + (void)deliverPushNotification:(NSDictionary *)notification { NSLog(@"Kb.deliverPushNotification: JS not ready, stored tap for getInitialNotification"); return; } + if (kbIsNavigationOnlyPush(notification)) { + NSLog(@"Kb.deliverPushNotification: JS not ready, dropped a navigation-only push without a tap"); + return; + } if (!kbPendingNotifications) { kbPendingNotifications = [NSMutableArray array]; } @@ -932,7 +988,10 @@ + (void)deliverPushNotification:(NSDictionary *)notification { [kbPendingNotifications removeObjectAtIndex:0]; NSLog(@"Kb.deliverPushNotification: pending queue full, dropped the oldest"); } - [kbPendingNotifications addObject:notification]; + [kbPendingNotifications addObject:@{ + kbPendingPayloadKey : notification, + kbPendingQueuedAtKey : @(kbMonotonicNowNs()), + }]; } - (void)handleHardwareKeyPressed:(NSNotification *)notification { diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index 86133a5402e1..2c4698b81144 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -66,6 +66,7 @@ export interface Spec extends TurboModule { engineReset(): void notifyJSReady(): void shareListenersRegistered(): void + pushListenerRegistered(): void setEnablePasteImage(enabled: boolean): void clearLocalLogs(): Promise } diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index aeccfb245834..48b398cf5b19 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -167,6 +167,11 @@ export const notifyJSReady = (): void => { export const shareListenersRegistered = (): void => { return Kb.shareListenersRegistered() } +// iOS: call once onPushNotification is subscribed; pushes queued while JS +// wasn't listening are emitted then. +export const pushListenerRegistered = (): void => { + return Kb.pushListenerRegistered() +} export const clearLocalLogs = (): Promise => { return Kb.clearLocalLogs() From c2f21d928f9f1437c6b6573fff1b31d2d5a9539e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 16:38:10 -0400 Subject: [PATCH 025/127] fix(mobile): keep JS app state, http server address and push navigation consistent with native The shell store's mobileAppState is seeded from AppState when subscribing, and while it reads inactive JS re-asks native, since under iOS scenes RN's AppState can start at (or report) inactive during didBecomeActive and never send active. Image heal is no longer disabled for a whole first session. The http server address is ordered by when each value was observed: a bootstrap read loses to a notification that arrived after the read started, and to a newer read. It is applied from the bootstrap load itself, re-read once the service subscription is in place, and kept across logout. A localhost image retry points at the current address and token. Merged messages and reaction updates keep attachment, preview and emoji URLs that the service returns empty while its http server is down. Pushes navigate only when tapped, at startup and live (chat.extension and settings.contacts included). The startup read of the tapped notification no longer races a 10ms timer that could drop it, and JS tells native once its push listener is registered. Native platform listeners unsubscribe on re-init. --- shared/app/index.native.tsx | 35 ++- shared/app/watch-app-state.test.ts | 102 +++++++ shared/app/watch-app-state.tsx | 73 +++++ .../chat/conversation/local-server-urls.tsx | 40 +++ .../thread-message-state.test.tsx | 114 ++++++++ .../conversation/thread-message-state.tsx | 23 +- shared/common-adapters/image.tsx | 21 +- shared/common-adapters/localhost-src.test.ts | 31 ++ shared/common-adapters/localhost-src.tsx | 19 ++ shared/constants/init/index.tsx | 42 +-- shared/constants/init/platform.desktop.tsx | 2 +- .../init/push-listener.native.test.ts | 234 ++++++++++++++++ .../constants/init/push-listener.native.tsx | 264 +++++++++++------- shared/constants/init/shared.test.ts | 52 +++- shared/constants/init/shared.tsx | 21 +- shared/constants/types/push.tsx | 3 + shared/stores/config.tsx | 20 +- shared/stores/daemon.tsx | 8 + shared/stores/push.tsx | 4 +- shared/stores/tests/daemon.test.ts | 88 ++++++ 20 files changed, 1036 insertions(+), 160 deletions(-) create mode 100644 shared/app/watch-app-state.test.ts create mode 100644 shared/app/watch-app-state.tsx create mode 100644 shared/chat/conversation/local-server-urls.tsx create mode 100644 shared/common-adapters/localhost-src.test.ts create mode 100644 shared/common-adapters/localhost-src.tsx create mode 100644 shared/constants/init/push-listener.native.test.ts diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index dfc15a3d789a..e4c27ffbbebe 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, AppState, Appearance, Platform, TurboModuleRegistry, type TurboModule} from 'react-native' import {PortalProvider} from '@/common-adapters/portal.native' import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context' import {makeEngine} from '../engine' @@ -20,6 +20,7 @@ import * as DarkMode from '@/stores/darkmode' import {colors, darkColors} from '@/styles/colors' import {initPlatformListener, onEngineConnected, onEngineDisconnected, onEngineIncoming} from '@/constants/init/index' import logger from '@/logger' +import {watchAppState, type QueryNativeAppState} from './watch-app-state' logger.info('INIT App index module load') @@ -56,21 +57,35 @@ const initDarkMode = () => { } catch {} } +type NativeAppStateSpec = { + getCurrentAppState: (onSuccess: (s: {app_state: string}) => void, onError: (e: unknown) => void) => void +} +const nativeAppState = TurboModuleRegistry.get('AppState') +const queryNativeAppState: QueryNativeAppState | undefined = nativeAppState + ? onState => { + nativeAppState.getCurrentAppState( + s => onState(s.app_state), + () => onState('unknown') + ) + } + : undefined + const useDarkHookup = () => { 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') { + const stopWatchingAppState = watchAppState({ + appState: AppState, + onState: nextAppState => { + appStateRef.current = nextAppState setMobileAppState(nextAppState) - } - - if (nextAppState === 'active') { - setSystemDarkMode(Appearance.getColorScheme() === 'dark') - } + if (nextAppState === 'active') { + setSystemDarkMode(Appearance.getColorScheme() === 'dark') + } + }, + queryNativeAppState, }) // only watch dark changes if in foreground due to ios calling this to take snapshots @@ -81,7 +96,7 @@ const useDarkHookup = () => { }) return () => { - appStateChangeSub.remove() + stopWatchingAppState() darkSub.remove() } }, [setSystemDarkMode, setMobileAppState]) diff --git a/shared/app/watch-app-state.test.ts b/shared/app/watch-app-state.test.ts new file mode 100644 index 000000000000..3c224da510d5 --- /dev/null +++ b/shared/app/watch-app-state.test.ts @@ -0,0 +1,102 @@ +/// +import {inactiveRecheckMs, watchAppState, type MobileAppState} from './watch-app-state' + +const makeAppState = (currentState: string | null) => { + let listener: ((state: string) => void) | undefined + const remove = jest.fn(() => { + listener = undefined + }) + return { + appState: { + addEventListener: (_type: 'change', l: (state: string) => void) => { + listener = l + return {remove} + }, + currentState, + }, + emit: (state: string) => listener?.(state), + remove, + } +} + +beforeEach(() => { + jest.useFakeTimers() +}) +afterEach(() => { + jest.useRealTimers() +}) + +test('seeds from the current state when subscribing', () => { + const {appState} = makeAppState('active') + const states = new Array() + const stop = watchAppState({appState, onState: s => states.push(s)}) + expect(states).toEqual(['active']) + stop() +}) + +test('ignores states that are not app states', () => { + const {appState, emit} = makeAppState('unknown') + const states = new Array() + const stop = watchAppState({appState, onState: s => states.push(s)}) + emit('extension') + emit('background') + expect(states).toEqual(['background']) + stop() +}) + +test('a stale inactive seed converges to active once native reports it', () => { + const {appState} = makeAppState('inactive') + let nativeState = 'inactive' + const queryNativeAppState = jest.fn((onState: (s: string) => void) => onState(nativeState)) + const states = new Array() + const stop = watchAppState({appState, onState: s => states.push(s), queryNativeAppState}) + expect(states).toEqual(['inactive']) + + jest.advanceTimersByTime(inactiveRecheckMs) + expect(queryNativeAppState).toHaveBeenCalledTimes(1) + expect(states).toEqual(['inactive']) + + nativeState = 'active' + jest.advanceTimersByTime(inactiveRecheckMs) + expect(states).toEqual(['inactive', 'active']) + + jest.advanceTimersByTime(inactiveRecheckMs * 10) + expect(queryNativeAppState).toHaveBeenCalledTimes(2) + stop() +}) + +test('a stale inactive change event converges too', () => { + const {appState, emit} = makeAppState('background') + const queryNativeAppState = jest.fn((onState: (s: string) => void) => onState('active')) + const states = new Array() + const stop = watchAppState({appState, onState: s => states.push(s), queryNativeAppState}) + emit('inactive') + jest.advanceTimersByTime(inactiveRecheckMs) + expect(states).toEqual(['background', 'inactive', 'active']) + stop() +}) + +test('a real change wins over a recheck that answers late', () => { + const {appState, emit} = makeAppState('inactive') + let answer: ((s: string) => void) | undefined + const queryNativeAppState = (onState: (s: string) => void) => { + answer = onState + } + const states = new Array() + const stop = watchAppState({appState, onState: s => states.push(s), queryNativeAppState}) + jest.advanceTimersByTime(inactiveRecheckMs) + emit('background') + answer?.('active') + expect(states).toEqual(['inactive', 'background']) + stop() +}) + +test('stopping removes the listener and pending rechecks', () => { + const {appState, remove} = makeAppState('inactive') + const queryNativeAppState = jest.fn() + const stop = watchAppState({appState, onState: () => {}, queryNativeAppState}) + stop() + jest.advanceTimersByTime(inactiveRecheckMs * 4) + expect(queryNativeAppState).not.toHaveBeenCalled() + expect(remove).toHaveBeenCalled() +}) diff --git a/shared/app/watch-app-state.tsx b/shared/app/watch-app-state.tsx new file mode 100644 index 000000000000..7669f2b52019 --- /dev/null +++ b/shared/app/watch-app-state.tsx @@ -0,0 +1,73 @@ +export type MobileAppState = 'active' | 'background' | 'inactive' + +type AppStateLike = { + currentState: string | null | undefined + addEventListener: (type: 'change', listener: (state: string) => void) => {remove: () => void} +} + +// RN's NativeAppState.getCurrentAppState: reads UIApplication.applicationState when called +export type QueryNativeAppState = (onState: (state: string) => void) => void + +// Under iOS scenes UIApplication.applicationState still reads inactive while didBecomeActive is +// posted, so RN's AppState can report (and start with) 'inactive' and then never send 'active' +// because it only emits on a change of what it read. While we believe we're inactive, keep asking +// native directly until it says otherwise. +export const inactiveRecheckMs = 500 + +const asMobileAppState = (state: string | null | undefined): MobileAppState | undefined => + state === 'active' || state === 'background' || state === 'inactive' ? state : undefined + +export const watchAppState = (p: { + appState: AppStateLike + queryNativeAppState?: QueryNativeAppState + onState: (state: MobileAppState) => void +}) => { + const {appState, queryNativeAppState, onState} = p + let current: MobileAppState | undefined + let timer: ReturnType | undefined + let stopped = false + + const scheduleRecheck = () => { + if (!queryNativeAppState) return + clearTimeout(timer) + timer = setTimeout(() => { + queryNativeAppState(state => { + if (stopped || current !== 'inactive') return + const next = asMobileAppState(state) + if (next && next !== 'inactive') { + apply(next) + } else { + scheduleRecheck() + } + }) + }, inactiveRecheckMs) + } + + const apply = (state: MobileAppState) => { + current = state + onState(state) + if (state === 'inactive') { + scheduleRecheck() + } else { + clearTimeout(timer) + } + } + + const sub = appState.addEventListener('change', state => { + const next = asMobileAppState(state) + if (next) { + apply(next) + } + }) + + const seeded = asMobileAppState(appState.currentState) + if (seeded) { + apply(seeded) + } + + return () => { + stopped = true + clearTimeout(timer) + sub.remove() + } +} diff --git a/shared/chat/conversation/local-server-urls.tsx b/shared/chat/conversation/local-server-urls.tsx new file mode 100644 index 000000000000..24c66581fb95 --- /dev/null +++ b/shared/chat/conversation/local-server-urls.tsx @@ -0,0 +1,40 @@ +import * as T from '@/constants/types' +import {parseServiceDecoration} from '@/common-adapters/markdown/service-decoration-parser' + +// The service hands out '' for URLs on its local http server while that server is down (iOS +// background). A message refreshed then must not lose the URLs we already have: they work again +// once the server is back, and nothing else would refill them. + +export const localServerURLKeys: ReadonlySet = new Set(['fileURL', 'previewURL']) + +const decorationRegex = /\$>kb\$(.*?)\$ void): T.RPCChat.EmojiLoadSource => { + if (source.typ !== T.RPCChat.EmojiLoadSourceTyp.httpsrv) return source + if (!source.httpsrv) onEmpty() + return {...source, httpsrv: ''} +} + +const withoutEmojiURLs = (decorated: string) => { + let hasEmpty = false + const onEmpty = () => { + hasEmpty = true + } + const blanked = decorated.replace(decorationRegex, (match, json: string) => { + const d = parseServiceDecoration(json) + if (d?.typ !== T.RPCChat.UITextDecorationTyp.emoji) return match + const noAnimSource = blankSource(d.emoji.noAnimSource, onEmpty) + const source = blankSource(d.emoji.source, onEmpty) + return JSON.stringify({...d, emoji: {...d.emoji, noAnimSource, source}}) + }) + return {blanked, hasEmpty} +} + +// true when incoming decorated text is existing with emoji URLs gone empty, so existing should stay +export const shouldKeepEmojiURLs = (existing: string, incoming: string) => { + if (existing === incoming || !incoming.includes('$>kb$')) return false + const next = withoutEmojiURLs(incoming) + if (!next.hasEmpty) return false + const cur = withoutEmojiURLs(existing) + return !cur.hasEmpty && cur.blanked === next.blanked +} diff --git a/shared/chat/conversation/thread-message-state.test.tsx b/shared/chat/conversation/thread-message-state.test.tsx index 8ba2bfb62629..ed7a2a52b539 100644 --- a/shared/chat/conversation/thread-message-state.test.tsx +++ b/shared/chat/conversation/thread-message-state.test.tsx @@ -673,3 +673,117 @@ describe('addMessagesToThreadState', () => { expect(merged?.type === 'text' && merged.text.stringValue()).toBe('edited') }) }) + +describe('local server urls that went empty', () => { + const textAt = (ord: number, override?: Omit, 'text'>) => + makeTextMessage({ + id: T.Chat.numberToMessageID(ord), + ordinal: T.Chat.numberToOrdinal(ord), + outboxID: undefined, + ...override, + }) + const emojiDecoration = (url: string) => { + const decoration: T.RPCChat.UITextDecoration = { + emoji: { + alias: 'party', + isAlias: false, + isBig: false, + isCrossTeam: false, + isReacji: false, + noAnimSource: {httpsrv: url && `${url}&noanim=true`, typ: T.RPCChat.EmojiLoadSourceTyp.httpsrv}, + remoteSource: { + message: {convID: new Uint8Array([1]), isAlias: false, msgID: 5}, + typ: T.RPCChat.EmojiRemoteSourceTyp.message, + }, + source: {httpsrv: url, typ: T.RPCChat.EmojiLoadSourceTyp.httpsrv}, + }, + typ: T.RPCChat.UITextDecorationTyp.emoji, + } + return `$>kb$${Buffer.from(JSON.stringify(decoration)).toString('base64')}$ { + 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('http://127.0.0.1:5000/f') + expect(m.previewURL).toBe('http://127.0.0.1:5000/p') + expect(m.title).toBe('renamed') + }) + + test('a new non-empty url still 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('decorated text keeps its emoji urls when only they went empty', () => { + const state = makeThreadState([]) + const good = `hi ${emojiDecoration(emojiURL)}` + addMessagesToThreadState(state, [textAt(10, {decoratedText: new HiddenString(good)})], {}) + addMessagesToThreadState( + state, + [textAt(10, {decoratedText: new HiddenString(`hi ${emojiDecoration('')}`)})], + {} + ) + expect( + (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).decoratedText?.stringValue() + ).toBe(good) + }) + + test('decorated text that changed otherwise is taken even with empty emoji urls', () => { + const state = makeThreadState([]) + addMessagesToThreadState( + state, + [textAt(10, {decoratedText: new HiddenString(`hi ${emojiDecoration(emojiURL)}`)})], + {} + ) + const edited = `bye ${emojiDecoration('')}` + addMessagesToThreadState(state, [textAt(10, {decoratedText: new HiddenString(edited)})], {}) + expect( + (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).decoratedText?.stringValue() + ).toBe(edited) + }) + + test('reactions keep their emoji urls on a merge and on a reaction update', () => { + const good = emojiDecoration(emojiURL) + 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(good, ['testuser'])]])})], {}) + addMessagesToThreadState( + state, + [textAt(10, {reactions: new Map([[':party:', reaction(emojiDecoration(''), ['testuser', 'testuser-mac'])]])})], + {} + ) + const merged = (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).reactions?.get(':party:') + expect(merged?.decorated).toBe(good) + expect(merged?.users.map(u => u.username)).toEqual(['testuser', 'testuser-mac']) + + updateReactionsInThreadState(state, [ + { + reactions: new Map([[':party:', reaction(emojiDecoration(''), ['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(good) + 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..7f53f64c6688 100644 --- a/shared/chat/conversation/thread-message-state.tsx +++ b/shared/chat/conversation/thread-message-state.tsx @@ -1,6 +1,7 @@ import * as Message from '@/constants/chat/message' import * as T from '@/constants/types' import HiddenString from '@/util/hidden-string' +import {localServerURLKeys, shouldKeepEmojiURLs} from './local-server-urls' import type {WritableDraft} from '@/util/zustand' type MessageLookup = Pick @@ -143,6 +144,11 @@ const maybeGetOrdinalByMessageID = ( ) => getOrdinalForMessageID(state.messageMap, state.pendingOutboxToOrdinal, messageID, state.messageIDToOrdinal) +const reactionKeepingEmojiURLs = (existing: T.Chat.ReactionDesc | undefined, incoming: T.Chat.ReactionDesc) => + existing && shouldKeepEmojiURLs(existing.decorated, incoming.decorated) + ? {...incoming, decorated: existing.decorated} + : incoming + const mergeMessage = ( existing: WritableDraft, incoming: WritableDraft @@ -154,6 +160,9 @@ const mergeMessage = ( const val = incomingRecord[key] const cur = existingRecord[key] if (val instanceof HiddenString) { + if (cur instanceof HiddenString && shouldKeepEmojiURLs(cur.stringValue(), val.stringValue())) { + continue + } if (!(cur instanceof HiddenString) || !val.equals(cur)) { existingRecord[key] = val } @@ -165,11 +174,18 @@ const mergeMessage = ( } } for (const [k, v] of val as Map) { - ;(cur as Map).set(k, v) + if (key === 'reactions') { + const old = (cur as Map).get(k) + ;(cur as Map).set(k, reactionKeepingEmojiURLs(old, v as T.Chat.ReactionDesc)) + } else { + ;(cur as Map).set(k, v) + } } } else { existingRecord[key] = val } + } else if (localServerURLKeys.has(key) && val === '' && typeof cur === 'string' && cur) { + continue } else if (cur !== val) { existingRecord[key] = val } @@ -573,8 +589,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, reactionKeepingEmojiURLs(m.reactions.get(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..67180e6801ff --- /dev/null +++ b/shared/common-adapters/localhost-src.test.ts @@ -0,0 +1,31 @@ +/// +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 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..9604ac61b41f --- /dev/null +++ b/shared/common-adapters/localhost-src.tsx @@ -0,0 +1,19 @@ +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. 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/init/index.tsx b/shared/constants/init/index.tsx index 51c81055da2d..63111c3c2df7 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -343,7 +343,11 @@ 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) { @@ -364,7 +368,7 @@ const _initNativePlatformListener = () => { // Native KeybaseSetAppState* is the only writer of Go MobileAppState. logger.info(`app focus changed: ${s.mobileAppState}`) s.dispatch.changedFocus(appFocused) - }) + })) const configureAndroidCacheDir = () => { const {fsCacheDir, fsDownloadDir} = _getNativeSync() @@ -387,7 +391,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 +403,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 +417,27 @@ const _initNativePlatformListener = () => { } } ignorePromise(f()) - }) + })) - useShellState.subscribe((s, old) => { + _platformUnsubs.push(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 +448,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 +465,20 @@ const _initNativePlatformListener = () => { } } ignorePromise(f()) - }) + })) } // Start this immediately instead of waiting so we can do more things in parallel ignorePromise(loadStartupDetails()) - initPushListener() + _platformUnsubs.push(...initPushListener()) 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)) 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.test.ts b/shared/constants/init/push-listener.native.test.ts new file mode 100644 index 000000000000..0af8d76a1c0e --- /dev/null +++ b/shared/constants/init/push-listener.native.test.ts @@ -0,0 +1,234 @@ +/// +import type * as PushListener from './push-listener.native' +import type * as PushStore from '@/stores/push' +import type * as ConfigStore from '@/stores/config' +import type * as CurrentUserStore from '@/stores/current-user' +import type * as T from '@/constants/types' + +// push-listener and the push store pick their mobile behavior when they load, so each test loads +// them fresh with the mobile globals set and the native module mocked. + +type Loaded = { + configStore: typeof ConfigStore + currentUserStore: typeof CurrentUserStore + pushListener: typeof PushListener + pushStore: typeof PushStore +} + +const calls = new Array() +const emitDeepLink = jest.fn() +const switchTab = jest.fn() +const navUpToScreen = jest.fn() +let onNotification: ((n: object) => void) | undefined +const pushSubRemove = jest.fn() +let getInitialNotification: () => Promise = async () => Promise.resolve(null) + +const currentUid = 'uid-testuser' +const otherUid = 'uid-testuser-mac' +const convID = 'aabbccdd' + +const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} + +const load = (): Loaded => { + global.isMobile = true + global.isIOS = true + global.isAndroid = false + jest.resetModules() + jest.doMock('react-native-kb', () => ({ + checkPushPermissions: async () => Promise.resolve(true), + getInitialNotification: async () => getInitialNotification(), + getRegistrationToken: async () => Promise.resolve(''), + iosGetHasShownPushPrompt: async () => Promise.resolve(true), + onPushNotification: (cb: (n: object) => void) => { + calls.push('onPushNotification') + onNotification = cb + return {remove: pushSubRemove} + }, + onPushToken: () => ({remove: () => {}}), + onShareData: () => ({remove: () => {}}), + pushListenerRegistered: () => { + calls.push('pushListenerRegistered') + }, + removeAllPendingNotificationRequests: () => {}, + requestPushPermissions: async () => Promise.resolve(true), + setApplicationIconBadgeNumber: () => {}, + })) + jest.doMock('@/router-v2/deep-link-emitter', () => ({ + emitDeepLink, + normalizeUrl: (url: string) => url, + setInitialURLOnce: (url: string) => url, + })) + jest.doMock('@/constants/router', () => ({ + ...jest.requireActual('@/constants/router'), + getRootState: () => undefined, + navUpToScreen, + switchTab, + })) + const loaded = { + configStore: require('@/stores/config') as typeof ConfigStore, + currentUserStore: require('@/stores/current-user') as typeof CurrentUserStore, + pushListener: require('./push-listener.native') as typeof PushListener, + pushStore: require('@/stores/push') as typeof PushStore, + } + const T_ = require('@/constants/types') as typeof T + jest.spyOn(T_.RPCGen, 'configGuiGetValueRpcPromise').mockResolvedValue({b: true, isNull: false}) + loaded.currentUserStore.useCurrentUserState.setState({uid: currentUid, username: 'testuser'}) + loaded.configStore.useConfigState.setState({ + configuredAccounts: [{hasStoredSecret: true, uid: currentUid, username: 'testuser'}], + loggedIn: true, + }) + return loaded +} + +const flush = async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + } +} + +afterEach(() => { + calls.length = 0 + onNotification = undefined + getInitialNotification = async () => Promise.resolve(null) + jest.useRealTimers() + jest.restoreAllMocks() + jest.clearAllMocks() + jest.dontMock('react-native-kb') + jest.dontMock('@/router-v2/deep-link-emitter') + jest.dontMock('@/constants/router') + jest.resetModules() + global.isMobile = originalGlobals.isMobile + global.isIOS = originalGlobals.isIOS + global.isAndroid = originalGlobals.isAndroid +}) + +// every raw push type whose handling can navigate +const navigatingPushes = (userInteraction: boolean) => ({ + 'chat.extension': {convID, type: 'chat.extension', userInteraction}, + 'chat.newmessage': {convID, m: '', t: 2, type: 'chat.newmessage', userInteraction}, + 'chat.newmessage for another account': { + convID, + m: '', + t: 2, + type: 'chat.newmessage', + uid: otherUid, + userInteraction, + }, + 'device.new': {type: 'device.new', uid: currentUid, userInteraction}, + 'device.revoked': {type: 'device.revoked', uid: currentUid, userInteraction}, + follow: {type: 'follow', username: 'testuser-mac', userInteraction}, + 'settings.contacts': {message: 'Your contact testuser-mac joined Keybase', userInteraction}, +}) + +describe('live pushes', () => { + test('the native readiness signal comes after the push listener is registered', () => { + const {pushListener} = load() + const unsubs = pushListener.initPushListener() + expect(calls).toEqual(['onPushNotification', 'pushListenerRegistered']) + + for (const unsub of unsubs) unsub() + expect(pushSubRemove).toHaveBeenCalled() + }) + + test.each(Object.entries(navigatingPushes(false)))('%s without a tap never navigates', async (_, raw) => { + const {pushListener, pushStore} = load() + pushListener.initPushListener() + onNotification?.(raw) + await flush() + + expect(emitDeepLink).not.toHaveBeenCalled() + expect(switchTab).not.toHaveBeenCalled() + expect(navUpToScreen).not.toHaveBeenCalled() + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + }) + + test.each(Object.entries(navigatingPushes(true)))('%s with a tap navigates', async (name, raw) => { + const {pushListener, pushStore} = load() + pushListener.initPushListener() + onNotification?.(raw) + await flush() + + if (name === 'chat.newmessage for another account') { + // not a configured account yet: kept until the account list catches up + expect(pushStore.usePushState.getState().pendingPushNotification?.type).toBe('chat.newmessage') + } else if (name.startsWith('device.')) { + expect(switchTab).toHaveBeenCalled() + } else { + expect(emitDeepLink).toHaveBeenCalledTimes(1) + } + }) +}) + +describe('startup push', () => { + const tapped = { + 'chat.newmessage': {convID, m: 'payload', t: 2, type: 'chat.newmessage'}, + 'chat.newmessageSilent_2': {c: convID, m: 'payload', t: 2, type: 'chat.newmessageSilent_2'}, + follow: {type: 'follow', username: 'testuser-mac'}, + } + + // the read races a timer; fake timers keep it from outliving the test + beforeEach(() => { + jest.useFakeTimers() + }) + + test.each(Object.entries(tapped))('%s without a tap does not pick the startup screen', async (_, raw) => { + const {pushListener, pushStore} = load() + getInitialNotification = async () => Promise.resolve({...raw, userInteraction: false}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + }) + + test('chat.newmessage for another account without a tap is not kept pending', async () => { + const {pushListener, pushStore} = load() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: false}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + }) + + test('tapped pushes pick the startup screen', async () => { + const {pushListener} = load() + getInitialNotification = async () => Promise.resolve({...tapped['chat.newmessage'], userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ + startupConversation: convID, + startupPushPayload: 'payload', + }) + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessageSilent_2'], userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ + startupConversation: convID, + startupPushPayload: 'payload', + }) + getInitialNotification = async () => Promise.resolve({...tapped.follow, userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ + startupFollowUser: 'testuser-mac', + }) + }) + + test('a tap that native takes a while to hand over is not lost', async () => { + const {pushListener} = load() + getInitialNotification = async () => + new Promise(resolve => { + setTimeout(() => resolve({...tapped['chat.newmessage'], userInteraction: true}), 50) + }) + const details = pushListener.getStartupDetailsFromInitialPush() + await jest.advanceTimersByTimeAsync(50) + await expect(details).resolves.toEqual({startupConversation: convID, startupPushPayload: 'payload'}) + }) + + test('startup does not wait forever on native, and a tap that lands later still navigates', async () => { + const {pushListener} = load() + getInitialNotification = async () => + new Promise(resolve => { + setTimeout(() => resolve({...tapped['chat.newmessage'], userInteraction: true}), 60_000) + }) + const details = pushListener.getStartupDetailsFromInitialPush() + await jest.advanceTimersByTimeAsync(10_000) + await expect(details).resolves.toBeUndefined() + expect(emitDeepLink).not.toHaveBeenCalled() + + await jest.advanceTimersByTimeAsync(50_000) + await flush() + expect(emitDeepLink).toHaveBeenCalledWith(`keybase://convid/${convID}`, {targetUid: undefined}) + }) +}) diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index 5d1d83bd6346..f796015edc44 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -9,6 +9,7 @@ import { onPushToken, onShareData, getInitialNotification, + pushListenerRegistered, removeAllPendingNotificationRequests, } from 'react-native-kb' import {useConfigState} from '@/stores/config' @@ -126,6 +127,7 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { membersType, type: 'chat.newmessageSilent_2', unboxPayload: data.m || '', + userInteraction, } } } @@ -169,14 +171,16 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { conversationIDKey: T.Chat.stringToConversationIDKey(data.convID), forUid, type: 'chat.extension', + userInteraction, } : undefined default: { const unk = data as any - if (typeof unk.message === 'string' && unk.message.startsWith('Your contact') && userInteraction) { + if (typeof unk.message === 'string' && unk.message.startsWith('Your contact')) { return { type: 'settings.contacts', + userInteraction, } } } @@ -193,9 +197,35 @@ const getInitialPush = async () => { const n = await getInitialNotification() return n ? normalizePush(n) : undefined } + +const isTap = (notification: T.Push.PushNotification) => + 'userInteraction' in notification && notification.userInteraction + +// Native clears the initial notification when it is read, so a read that loses a race is a lost +// tap. Both platforms resolve it right away; the timeout only keeps a misbehaving native module +// from holding startup, and a tap that still shows up after it is handled like a live one. +const initialPushTimeoutMs = 3000 + const getStartupDetailsFromInitialPush = async () => { - const notification = await Promise.race([getInitialPush(), timeoutPromise(10)]) - if (!notification) { + const initialPush = getInitialPush() + const timedOut = 'timedOut' as const + const notification = await Promise.race([ + initialPush, + timeoutPromise(initialPushTimeoutMs).then(() => timedOut), + ]) + if (notification === timedOut) { + logger.warn('[Push] initial notification read timed out') + initialPush + .then(n => { + if (n) { + usePushState.getState().dispatch.handlePush(n) + } + }) + .catch(() => {}) + return + } + // only a tap on a visible notification may pick where the app opens + if (!notification || !isTap(notification)) { return } @@ -224,131 +254,149 @@ const getStartupDetailsFromInitialPush = async () => { } 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 - }) + 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 + }) + ) // 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( + 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) - }) + unsubs.push( + 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.loggedIn === old.loggedIn) return - if (!s.loggedIn && !s.userSwitching) { - usePushState.getState().dispatch.clearPendingPushNotification() - } - }) + unsubs.push( + 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) + }) + ) - 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 + unsubs.push( + useConfigState.subscribe((s, old) => { + if (s.loggedIn === old.loggedIn) return + if (!s.loggedIn && !s.userSwitching) { + usePushState.getState().dispatch.clearPendingPushNotification() } - usePushState.getState().dispatch.handlePush(notification) + }) + ) + + // 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 { - // 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) + 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 + const pushSub = onPushNotification(onNotification) + unsubs.push(() => pushSub.remove()) + // iOS holds pushes that arrive before this; they are emitted once it's called + pushListenerRegistered() - if (isIOS) { - onPushToken(token => { - logger.debug('[PushToken] received token via onPushToken event: ', token) - usePushState.getState().dispatch.setPushToken(token) - }) - } + if (isIOS) { + const tokenSub = onPushToken(token => { + logger.debug('[PushToken] received token via onPushToken event: ', token) + usePushState.getState().dispatch.setPushToken(token) + }) + unsubs.push(() => tokenSub.remove()) + } - 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: the init/index.tsx + // router subscriber controls when native flushes pending share intents. } + } 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 +406,9 @@ export const initPushListener = () => { // Token will be retrieved later when permissions are checked } } - ignorePromise(listenNative()) + ignorePromise(fetchToken()) + + return unsubs } export {getStartupDetailsFromInitialPush} diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index e09a104c6ff6..03a23010c8fd 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -3,7 +3,7 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' -import {loadAccountsStep} from './shared' +import {loadAccountsStep, onEngineConnected} from './shared' describe('loadAccountsStep', () => { const originalDispatch = useConfigState.getState().dispatch @@ -65,3 +65,53 @@ 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() + }) + + test('reads the http server address again once the service subscription is in place', async () => { + useConfigState.setState(s => { + s.httpSrv = {address: '127.0.0.1:1000', token: 'token'} + s.dispatch = {...originalConfigDispatch, onEngineConnected: () => {}} + }) + useDaemonState.setState({dispatch: {...originalDaemonDispatch, startHandshake: () => {}}}) + for (const rpc of [ + 'delegateUiCtlRegisterChatUIRpcPromise', + 'delegateUiCtlRegisterLogUIRpcPromise', + 'delegateUiCtlRegisterHomeUIRpcPromise', + 'delegateUiCtlRegisterSecretUIRpcPromise', + 'delegateUiCtlRegisterIdentify3UIRpcPromise', + 'delegateUiCtlRegisterRekeyUIRpcPromise', + ] as const) { + jest.spyOn(T.RPCGen, rpc).mockResolvedValue(undefined) + } + let subscribed!: () => void + jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockReturnValue( + new Promise(resolve => { + subscribed = resolve + }) + ) + const bootstrap = jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, + loggedIn: true, + } as T.RPCGen.BootstrapStatus) + + onEngineConnected() + await Promise.resolve() + expect(bootstrap).not.toHaveBeenCalled() + + subscribed() + await new Promise(resolve => setImmediate(resolve)) + + expect(bootstrap).toHaveBeenCalledTimes(1) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') + }) +}) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 2517164b6470..9be531530873 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -216,10 +216,6 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => return } configDispatch.setLoggedIn(loggedIn) - - if (bootstrap.httpSrvInfo) { - configDispatch.setHTTPSrvInfo(bootstrap.httpSrvInfo.address, bootstrap.httpSrvInfo.token) - } } const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { @@ -246,6 +242,21 @@ const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavStat onChatRouteChanged(prev, next) } +// An HTTPSrvInfoUpdate sent before the service subscription took effect never reached us, and +// the handshake's bootstrap read may have started before it too, so read the address once more. +const refreshHTTPSrvInfo = async () => { + const {dispatch} = useConfigState.getState() + const readStartedAt = dispatch.startHTTPSrvInfoRead() + try { + const {httpSrvInfo} = await T.RPCGen.configGetBootstrapStatusRpcPromise() + if (httpSrvInfo) { + dispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token, readStartedAt) + } + } catch (error) { + logger.warn('[HTTPSrv] refresh failed: ', error) + } +} + export const onEngineConnected = () => { { const registerUIs = async () => { @@ -287,7 +298,9 @@ export const onEngineConnected = () => { if (error) { logger.warn('error in toggling notifications: ', error) } + return } + await refreshHTTPSrvInfo() } ignorePromise(notifyCtl()) } diff --git a/shared/constants/types/push.tsx b/shared/constants/types/push.tsx index 244b1556dfbf..7b8b702090b6 100644 --- a/shared/constants/types/push.tsx +++ b/shared/constants/types/push.tsx @@ -12,6 +12,7 @@ export type PushNotification = membersType: RPCChatTypes.ConversationMembersType type: 'chat.newmessageSilent_2' unboxPayload: string + userInteraction: boolean } | { conversationIDKey: ChatTypes.ConversationIDKey @@ -46,7 +47,9 @@ export type PushNotification = conversationIDKey: ChatTypes.ConversationIDKey forUid?: string type: 'chat.extension' + userInteraction: boolean } | { type: 'settings.contacts' + userInteraction: boolean } diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index f0ab46999359..525bd0239cf7 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -115,13 +115,15 @@ export type State = Store & { setDefaultUsername: (u: string) => void setGlobalError: (e?: unknown) => void setGregorReachable: (r: Store['gregorReachable']) => void - setHTTPSrvInfo: (address: string, token: string) => void + // readStartedAt: from startHTTPSrvInfoRead, for a value read through an RPC; omit for a live notification + setHTTPSrvInfo: (address: string, token: string, readStartedAt?: number) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void setOutOfDate: (outOfDate: T.Config.OutOfDate) => void setUpdating: () => void setUserSwitching: (sw: boolean) => void + startHTTPSrvInfoRead: () => number toggleRuntimeStats: () => void updateGregorCategory: (category: string, body: string, dtime?: {offset: number; time: number}) => void } @@ -129,6 +131,12 @@ export type State = Store & { export const useConfigState = Z.createZustand('config', (set, get) => { let inflightRefreshAccounts: Promise | undefined + // The http server can move (new port) at any time and says so with HTTPSrvInfoUpdate, while + // a bootstrap status read can take seconds. Every source is stamped with when its value was + // observed (a notification when it arrives, an RPC read when it starts) and only a stamp newer + // than the applied one wins, so a read that started before a notification can't undo it. + let httpSrvClock = 0 + let httpSrvAppliedAt = 0 const _checkForUpdate = async () => { try { @@ -461,6 +469,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, })) @@ -528,7 +538,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { setGregorReachable: r => { setGregorReachable(r) }, - setHTTPSrvInfo: (address, token) => { + setHTTPSrvInfo: (address, token, readStartedAt = ++httpSrvClock) => { + if (readStartedAt <= httpSrvAppliedAt) { + logger.info(`[HTTPSrv] ignoring ${address}: read before a newer value`) + return + } + httpSrvAppliedAt = readStartedAt set(s => { s.httpSrv.address = address s.httpSrv.token = token @@ -584,6 +599,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.userSwitching = sw }) }, + startHTTPSrvInfoRead: () => ++httpSrvClock, toggleRuntimeStats: () => { const f = async () => { await T.RPCGen.configToggleRuntimeStatsRpcPromise() diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index 31c4371d35ff..f2114df55f55 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -4,6 +4,7 @@ import {ignorePromise, timeoutPromise} from '@/constants/utils' import * as T from '@/constants/types' import * as Z from '@/util/zustand' import {maxHandshakeTries} from '@/constants/values' +import {useConfigState} from '@/stores/config' // A bootstrap step gates the handshake: the app stays on the splash screen until every step // resolves. Throwing fails the whole attempt (FatalHandshakeError skips the remaining retries). @@ -61,10 +62,17 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { } const gen = generation const f = async () => { + const configDispatch = useConfigState.getState().dispatch + const httpSrvReadStartedAt = configDispatch.startHTTPSrvInfoRead() const bs = await T.RPCGen.configGetBootstrapStatusRpcPromise() logger.info( `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'}` ) + // applied here rather than from bootstrapStatus: the address has its own ordering, and a + // status that is skipped below or later edited in place must not skip or replay it + if (bs.httpSrvInfo) { + configDispatch.setHTTPSrvInfo(bs.httpSrvInfo.address, bs.httpSrvInfo.token, httpSrvReadStartedAt) + } // a newer handshake owns the store now; don't write a potentially older status over its load if (gen !== generation || isEqual(bs, get().bootstrapStatus)) { return diff --git a/shared/stores/push.tsx b/shared/stores/push.tsx index fc0b539289b6..7ef0f8fe2d50 100644 --- a/shared/stores/push.tsx +++ b/shared/stores/push.tsx @@ -264,13 +264,13 @@ export const usePushState = Z.createZustand('push', (set, get) => { case 'autoreset': break case 'chat.extension': - { + if (notification.userInteraction) { const {conversationIDKey} = notification emitDeepLink(`keybase://convid/${conversationIDKey}`, navigationIntentOptions) } break case 'settings.contacts': - if (useConfigState.getState().loggedIn) { + if (notification.userInteraction && useConfigState.getState().loggedIn) { emitDeepLink('keybase://people', navigationIntentOptions) } break diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index dbb1ad951b0d..2a48a9956009 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -3,6 +3,7 @@ import * as T from '@/constants/types' import {ignorePromise} from '@/constants/utils' import {maxHandshakeTries} from '@/constants/values' import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '../config' import {FatalHandshakeError, useDaemonState} from '../daemon' const bootstrapStatus = { @@ -147,3 +148,90 @@ describe('daemon store', () => { expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries) }) }) + +describe('httpSrvInfo ordering', () => { + const withHTTP = (address: string): T.RPCGen.BootstrapStatus => ({ + ...bootstrapStatus, + httpSrvInfo: {address, token: 'token'}, + }) + const notify = (address: string) => + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: {info: {address, token: 'token'}}}, + type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', + } as any) + const deferredBootstrap = () => { + let resolve!: (bs: T.RPCGen.BootstrapStatus) => void + const promise = new Promise(_resolve => { + resolve = _resolve + }) + return {promise, resolve} + } + + beforeEach(() => { + jest.useFakeTimers() + useConfigState.setState(s => { + s.httpSrv = {address: '', token: ''} + }) + }) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() + }) + + test('a bootstrap read that started before a notification does not overwrite it', async () => { + const read = deferredBootstrap() + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockReturnValue(read.promise) + + const load = useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + notify('127.0.0.1:2000') + read.resolve(withHTTP('127.0.0.1:1000')) + await load + + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') + }) + + test('a bootstrap read that started after a notification is applied', async () => { + notify('127.0.0.1:2000') + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:3000')) + + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3000') + }) + + test('an older bootstrap read landing after a newer one does not overwrite it', async () => { + const older = deferredBootstrap() + jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockReturnValueOnce(older.promise) + .mockResolvedValueOnce(withHTTP('127.0.0.1:3000')) + const {dispatch} = useDaemonState.getState() + + const olderLoad = dispatch.loadDaemonBootstrapStatus() + // a new handshake starts its own load instead of reusing the in-flight one + dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3000') + + older.resolve(withHTTP('127.0.0.1:1000')) + await olderLoad + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3000') + }) + + test('a status equal to the stored one still applies its newer address', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1000')) + const {dispatch} = useDaemonState.getState() + await dispatch.loadDaemonBootstrapStatus() + notify('127.0.0.1:2000') + await dispatch.loadDaemonBootstrapStatus() + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1000') + }) + + test('logging out keeps the http server address', () => { + notify('127.0.0.1:2000') + useConfigState.getState().dispatch.resetState() + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') + }) +}) From f3f6d30bfaa6f5f63a5c2649f96dff68abc69abc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 16:53:40 -0400 Subject: [PATCH 026/127] fix(mobile): take the iOS app state from scene notifications and keep unfurl URLs react-native-kb observes the UIScene activation notifications from launch and emits the aggregate app state (onAppStateChange, with a getAppState getter), so JS no longer depends on UIApplication.applicationState, which lags under scenes, and no longer polls. Android keeps RN's AppState. Merged messages keep unfurl image, favicon and video URLs that the service returns empty while its http server is down. The startup path no longer has a branch for silent pushes, which are never shown and so never tapped. --- .../main/java/com/reactnativekb/KbModule.kt | 4 + rnmodules/react-native-kb/ios/Kb.mm | 74 ++++++++++ rnmodules/react-native-kb/src/NativeKb.ts | 4 + rnmodules/react-native-kb/src/index.tsx | 10 ++ shared/app/index.native.tsx | 49 +++---- shared/app/watch-app-state.test.ts | 133 ++++++++---------- shared/app/watch-app-state.tsx | 69 ++------- .../chat/conversation/local-server-urls.tsx | 29 ++++ .../thread-message-state.test.tsx | 69 +++++++++ .../conversation/thread-message-state.tsx | 8 +- .../init/push-listener.native.test.ts | 17 ++- .../constants/init/push-listener.native.tsx | 5 +- shared/constants/types/push.tsx | 1 - 13 files changed, 306 insertions(+), 166 deletions(-) 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 0b4c894fdde7..860c0a36e15e 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 @@ -192,6 +192,10 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T override fun pushListenerRegistered() { } + // Only iOS needs a scene-based app state; JS uses RN's AppState on Android. + @ReactMethod(isBlockingSynchronousMethod = true) + override fun getAppState(): String = "" + // Sharing @ReactMethod override fun androidShare(uriPath: String, mimeType: String, promise: Promise) { diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 4142df2fc71d..5f5575a9c2dd 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -301,6 +301,80 @@ - (void)pushListenerReadyLocked { RCT_EXPORT_MODULE() +// UIApplication.applicationState lags under scenes (it still reads inactive in +// didBecomeActive), and RN's AppState reads it, so JS takes the app state from +// here instead: derived from the scene activation notifications, active if any +// scene is active, inactive if any is in the foreground, background otherwise. +// Observed from a load-time constructor (RCT_EXPORT_MODULE owns +load) so +// every transition since launch is seen before any module (or JS) exists. kbSceneStates is main-thread only; kbAppState is what +// getAppState reads from the JS thread. +static NSMapTable *kbSceneStates = nil; +static std::mutex kbAppStateMutex; +static NSString *kbAppState = @"background"; + +__attribute__((constructor)) static void kbObserveSceneStates(void) { + kbSceneStates = [NSMapTable weakToStrongObjectsMapTable]; + NSDictionary *states = @{ + UISceneWillEnterForegroundNotification : @"inactive", + UISceneDidActivateNotification : @"active", + UISceneWillDeactivateNotification : @"inactive", + UISceneDidEnterBackgroundNotification : @"background", + }; + NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; + for (NSNotificationName name in states) { + NSString *state = states[name]; + [center addObserverForName:name + object:nil + queue:nil + usingBlock:^(NSNotification *note) { + [Kb scene:note.object changedToState:state]; + }]; + } + [center addObserverForName:UISceneDidDisconnectNotification + object:nil + queue:nil + usingBlock:^(NSNotification *note) { + [Kb scene:note.object changedToState:nil]; + }]; +} + ++ (void)scene:(UIScene *)scene changedToState:(NSString *)state { + if (![scene isKindOfClass:[UIScene class]]) { + return; + } + if (state) { + [kbSceneStates setObject:state forKey:scene]; + } else { + [kbSceneStates removeObjectForKey:scene]; + } + NSString *next = @"background"; + for (NSString *sceneState in kbSceneStates.objectEnumerator) { + if ([sceneState isEqualToString:@"active"]) { + next = sceneState; + break; + } + if ([sceneState isEqualToString:@"inactive"]) { + next = sceneState; + } + } + { + std::lock_guard lock(kbAppStateMutex); + if ([next isEqualToString:kbAppState]) { + return; + } + kbAppState = next; + } + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnAppStateChange:next]; + } +} + +- (NSString *)getAppState { + std::lock_guard lock(kbAppStateMutex); + return kbAppState; +} + + (BOOL)requiresMainQueueSetup { return YES; } diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index 2c4698b81144..be557d67baaf 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -8,6 +8,8 @@ export interface Spec extends TurboModule { readonly onPushNotification: EventEmitter readonly onPushToken: EventEmitter readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> + // iOS only: 'active' | 'inactive' | 'background', from the scene activation notifications + readonly onAppStateChange: EventEmitter getTypedConstants(): { androidIsDeviceSecure: boolean androidIsTestDevice: boolean @@ -67,6 +69,8 @@ export interface Spec extends TurboModule { notifyJSReady(): void shareListenersRegistered(): void pushListenerRegistered(): void + // iOS only: the current value onAppStateChange reports; '' on Android + getAppState(): string setEnablePasteImage(enabled: boolean): void clearLocalLogs(): Promise } diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index 48b398cf5b19..4a0499e638d9 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -167,6 +167,16 @@ export const notifyJSReady = (): void => { export const shareListenersRegistered = (): void => { return Kb.shareListenersRegistered() } +// iOS only. UIApplication.applicationState (and so RN's AppState) lags under scenes, reading +// inactive while the scene is already active; these follow the scene activation notifications. +export const iosOnAppStateChange = (callback: (state: string) => void): EventSubscription => { + return Kb.onAppStateChange(callback) +} + +export const iosGetAppState = (): string => { + return Kb.getAppState() +} + // iOS: call once onPushNotification is subscribed; pushes queued while JS // wasn't listening are emitted then. export const pushListenerRegistered = (): void => { diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index e4c27ffbbebe..9e13a223ab1a 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, TurboModuleRegistry, type TurboModule} from 'react-native' +import {AppRegistry, AppState, 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' @@ -15,12 +15,12 @@ import {Image as ExpoImage} from 'expo-image' import {setServiceDecoration} from '@/common-adapters/markdown/react' import ServiceDecoration from '@/common-adapters/markdown/service-decoration' import {useUnmountAll} from '@/util/debug-react' -import {darkModeSupported, guiConfig} from 'react-native-kb' +import {darkModeSupported, guiConfig, iosGetAppState, iosOnAppStateChange} from 'react-native-kb' import * as DarkMode from '@/stores/darkmode' import {colors, darkColors} from '@/styles/colors' import {initPlatformListener, onEngineConnected, onEngineDisconnected, onEngineIncoming} from '@/constants/init/index' import logger from '@/logger' -import {watchAppState, type QueryNativeAppState} from './watch-app-state' +import {watchAppState, type AppStateSource} from './watch-app-state' logger.info('INIT App index module load') @@ -57,18 +57,23 @@ const initDarkMode = () => { } catch {} } -type NativeAppStateSpec = { - getCurrentAppState: (onSuccess: (s: {app_state: string}) => void, onError: (e: unknown) => void) => void -} -const nativeAppState = TurboModuleRegistry.get('AppState') -const queryNativeAppState: QueryNativeAppState | undefined = nativeAppState - ? onState => { - nativeAppState.getCurrentAppState( - s => onState(s.app_state), - () => onState('unknown') - ) +// UIApplication.applicationState lags under iOS scenes, so RN's AppState can sit at inactive while +// the app is active; iOS reports the scene state itself. Android has no such lag. +const appStateSource: AppStateSource = isIOS + ? { + current: iosGetAppState, + subscribe: listener => { + const sub = iosOnAppStateChange(listener) + return () => sub.remove() + }, + } + : { + current: () => AppState.currentState, + subscribe: listener => { + const sub = AppState.addEventListener('change', listener) + return () => sub.remove() + }, } - : undefined const useDarkHookup = () => { const appStateRef = React.useRef('active') @@ -76,16 +81,12 @@ const useDarkHookup = () => { const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState) React.useEffect(() => { - const stopWatchingAppState = watchAppState({ - appState: AppState, - onState: nextAppState => { - appStateRef.current = nextAppState - setMobileAppState(nextAppState) - if (nextAppState === 'active') { - setSystemDarkMode(Appearance.getColorScheme() === 'dark') - } - }, - queryNativeAppState, + const stopWatchingAppState = watchAppState(appStateSource, nextAppState => { + appStateRef.current = nextAppState + setMobileAppState(nextAppState) + if (nextAppState === 'active') { + setSystemDarkMode(Appearance.getColorScheme() === 'dark') + } }) // only watch dark changes if in foreground due to ios calling this to take snapshots diff --git a/shared/app/watch-app-state.test.ts b/shared/app/watch-app-state.test.ts index 3c224da510d5..6f018fe9df86 100644 --- a/shared/app/watch-app-state.test.ts +++ b/shared/app/watch-app-state.test.ts @@ -1,102 +1,93 @@ /// -import {inactiveRecheckMs, watchAppState, type MobileAppState} from './watch-app-state' +import {resetAllStores} from '@/util/zustand' +import {useShellState} from '@/stores/shell' +import {watchAppState} from './watch-app-state' -const makeAppState = (currentState: string | null) => { +const makeSource = (initial: string) => { + let state = initial let listener: ((state: string) => void) | undefined - const remove = jest.fn(() => { + const unsubscribe = jest.fn(() => { listener = undefined }) return { - appState: { - addEventListener: (_type: 'change', l: (state: string) => void) => { + emit: (next: string) => { + state = next + listener?.(next) + }, + source: { + current: () => state, + subscribe: (l: (state: string) => void) => { listener = l - return {remove} + return unsubscribe }, - currentState, }, - emit: (state: string) => listener?.(state), - remove, + unsubscribe, } } -beforeEach(() => { - jest.useFakeTimers() -}) -afterEach(() => { - jest.useRealTimers() -}) +const watchIntoStore = (source: Parameters[0]) => + watchAppState(source, useShellState.getState().dispatch.setMobileAppState) -test('seeds from the current state when subscribing', () => { - const {appState} = makeAppState('active') - const states = new Array() - const stop = watchAppState({appState, onState: s => states.push(s)}) - expect(states).toEqual(['active']) - stop() +afterEach(() => { + resetAllStores() + useShellState.setState({mobileAppState: 'unknown'}) }) -test('ignores states that are not app states', () => { - const {appState, emit} = makeAppState('unknown') - const states = new Array() - const stop = watchAppState({appState, onState: s => states.push(s)}) - emit('extension') - emit('background') - expect(states).toEqual(['background']) +test('the store is seeded from the current native state', () => { + const {source} = makeSource('active') + const stop = watchIntoStore(source) + expect(useShellState.getState().mobileAppState).toBe('active') stop() }) -test('a stale inactive seed converges to active once native reports it', () => { - const {appState} = makeAppState('inactive') - let nativeState = 'inactive' - const queryNativeAppState = jest.fn((onState: (s: string) => void) => onState(nativeState)) - const states = new Array() - const stop = watchAppState({appState, onState: s => states.push(s), queryNativeAppState}) - expect(states).toEqual(['inactive']) +test('the store follows every native transition, including a return to a state it already had', () => { + const {emit, source} = makeSource('active') + const stop = watchIntoStore(source) + const seen = new Array() + const unsub = useShellState.subscribe(s => seen.push(s.mobileAppState)) - jest.advanceTimersByTime(inactiveRecheckMs) - expect(queryNativeAppState).toHaveBeenCalledTimes(1) - expect(states).toEqual(['inactive']) - - nativeState = 'active' - jest.advanceTimersByTime(inactiveRecheckMs) - expect(states).toEqual(['inactive', 'active']) + emit('inactive') + emit('active') + emit('inactive') + emit('background') + emit('inactive') + emit('active') - jest.advanceTimersByTime(inactiveRecheckMs * 10) - expect(queryNativeAppState).toHaveBeenCalledTimes(2) + expect(seen).toEqual(['inactive', 'active', 'inactive', 'background', 'inactive', 'active']) + unsub() stop() }) -test('a stale inactive change event converges too', () => { - const {appState, emit} = makeAppState('background') - const queryNativeAppState = jest.fn((onState: (s: string) => void) => onState('active')) - const states = new Array() - const stop = watchAppState({appState, onState: s => states.push(s), queryNativeAppState}) - emit('inactive') - jest.advanceTimersByTime(inactiveRecheckMs) - expect(states).toEqual(['background', 'inactive', 'active']) +test('a change between subscribing and seeding is not lost', () => { + const {emit, source} = makeSource('inactive') + const stop = watchAppState( + { + current: source.current, + // native changes while the listener is being registered, and the event misses it + subscribe: l => { + emit('active') + return source.subscribe(l) + }, + }, + useShellState.getState().dispatch.setMobileAppState + ) + expect(useShellState.getState().mobileAppState).toBe('active') stop() }) -test('a real change wins over a recheck that answers late', () => { - const {appState, emit} = makeAppState('inactive') - let answer: ((s: string) => void) | undefined - const queryNativeAppState = (onState: (s: string) => void) => { - answer = onState - } - const states = new Array() - const stop = watchAppState({appState, onState: s => states.push(s), queryNativeAppState}) - jest.advanceTimersByTime(inactiveRecheckMs) +test('states that are not app states are ignored', () => { + const {emit, source} = makeSource('unknown') + const stop = watchIntoStore(source) + expect(useShellState.getState().mobileAppState).toBe('unknown') + emit('extension') + expect(useShellState.getState().mobileAppState).toBe('unknown') emit('background') - answer?.('active') - expect(states).toEqual(['inactive', 'background']) + expect(useShellState.getState().mobileAppState).toBe('background') stop() }) -test('stopping removes the listener and pending rechecks', () => { - const {appState, remove} = makeAppState('inactive') - const queryNativeAppState = jest.fn() - const stop = watchAppState({appState, onState: () => {}, queryNativeAppState}) - stop() - jest.advanceTimersByTime(inactiveRecheckMs * 4) - expect(queryNativeAppState).not.toHaveBeenCalled() - expect(remove).toHaveBeenCalled() +test('stopping unsubscribes from native', () => { + const {source, unsubscribe} = makeSource('active') + watchIntoStore(source)() + expect(unsubscribe).toHaveBeenCalled() }) diff --git a/shared/app/watch-app-state.tsx b/shared/app/watch-app-state.tsx index 7669f2b52019..2b9f6fd2495d 100644 --- a/shared/app/watch-app-state.tsx +++ b/shared/app/watch-app-state.tsx @@ -1,73 +1,24 @@ export type MobileAppState = 'active' | 'background' | 'inactive' -type AppStateLike = { - currentState: string | null | undefined - addEventListener: (type: 'change', listener: (state: string) => void) => {remove: () => void} +export type AppStateSource = { + current: () => string | null | undefined + subscribe: (listener: (state: string) => void) => () => void } -// RN's NativeAppState.getCurrentAppState: reads UIApplication.applicationState when called -export type QueryNativeAppState = (onState: (state: string) => void) => void - -// Under iOS scenes UIApplication.applicationState still reads inactive while didBecomeActive is -// posted, so RN's AppState can report (and start with) 'inactive' and then never send 'active' -// because it only emits on a change of what it read. While we believe we're inactive, keep asking -// native directly until it says otherwise. -export const inactiveRecheckMs = 500 - const asMobileAppState = (state: string | null | undefined): MobileAppState | undefined => state === 'active' || state === 'background' || state === 'inactive' ? state : undefined -export const watchAppState = (p: { - appState: AppStateLike - queryNativeAppState?: QueryNativeAppState - onState: (state: MobileAppState) => void -}) => { - const {appState, queryNativeAppState, onState} = p - let current: MobileAppState | undefined - let timer: ReturnType | undefined - let stopped = false - - const scheduleRecheck = () => { - if (!queryNativeAppState) return - clearTimeout(timer) - timer = setTimeout(() => { - queryNativeAppState(state => { - if (stopped || current !== 'inactive') return - const next = asMobileAppState(state) - if (next && next !== 'inactive') { - apply(next) - } else { - scheduleRecheck() - } - }) - }, inactiveRecheckMs) - } - - const apply = (state: MobileAppState) => { - current = state - onState(state) - if (state === 'inactive') { - scheduleRecheck() - } else { - clearTimeout(timer) - } - } - - const sub = appState.addEventListener('change', state => { +// Subscribes before reading the current state, so a change in between is never missed. +export const watchAppState = (source: AppStateSource, onState: (state: MobileAppState) => void) => { + const unsubscribe = source.subscribe(state => { const next = asMobileAppState(state) if (next) { - apply(next) + onState(next) } }) - - const seeded = asMobileAppState(appState.currentState) + const seeded = asMobileAppState(source.current()) if (seeded) { - apply(seeded) - } - - return () => { - stopped = true - clearTimeout(timer) - sub.remove() + onState(seeded) } + return unsubscribe } diff --git a/shared/chat/conversation/local-server-urls.tsx b/shared/chat/conversation/local-server-urls.tsx index 24c66581fb95..8a0e76bef11a 100644 --- a/shared/chat/conversation/local-server-urls.tsx +++ b/shared/chat/conversation/local-server-urls.tsx @@ -38,3 +38,32 @@ export const shouldKeepEmojiURLs = (existing: string, incoming: string) => { const cur = withoutEmojiURLs(existing) return !cur.hasEmpty && cur.blanked === next.blanked } + +type ImageDisplay = T.RPCChat.UnfurlImageDisplay | null | undefined + +const imageKeepingURL = (existing: ImageDisplay, incoming: ImageDisplay) => + incoming && !incoming.url && existing?.url ? {...incoming, url: existing.url} : incoming + +export const unfurlKeepingLocalServerURLs = ( + existing: T.RPCChat.UIMessageUnfurlInfo | undefined, + incoming: T.RPCChat.UIMessageUnfurlInfo +): T.RPCChat.UIMessageUnfurlInfo => { + const cur = existing?.unfurl + const next = incoming.unfurl + if (cur?.unfurlType === T.RPCChat.UnfurlType.generic && next.unfurlType === T.RPCChat.UnfurlType.generic) { + const favicon = imageKeepingURL(cur.generic.favicon, next.generic.favicon) + const media = imageKeepingURL(cur.generic.media, next.generic.media) + if (favicon === next.generic.favicon && media === next.generic.media) return incoming + return {...incoming, unfurl: {...next, generic: {...next.generic, favicon, media}}} + } + if (cur?.unfurlType === T.RPCChat.UnfurlType.giphy && next.unfurlType === T.RPCChat.UnfurlType.giphy) { + const favicon = imageKeepingURL(cur.giphy.favicon, next.giphy.favicon) + const image = imageKeepingURL(cur.giphy.image, next.giphy.image) + const video = imageKeepingURL(cur.giphy.video, next.giphy.video) + if (favicon === next.giphy.favicon && image === next.giphy.image && video === next.giphy.video) { + return incoming + } + return {...incoming, unfurl: {...next, giphy: {...next.giphy, favicon, image, video}}} + } + return incoming +} diff --git a/shared/chat/conversation/thread-message-state.test.tsx b/shared/chat/conversation/thread-message-state.test.tsx index ed7a2a52b539..11a9e720a9c6 100644 --- a/shared/chat/conversation/thread-message-state.test.tsx +++ b/shared/chat/conversation/thread-message-state.test.tsx @@ -759,6 +759,75 @@ describe('local server urls that went empty', () => { ).toBe(edited) }) + test('unfurls keep their image, favicon and video urls when an update carries empty ones', () => { + const image = (url: string): T.RPCChat.UnfurlImageDisplay => ({height: 10, isVideo: false, url, width: 10}) + const generic = (url: string, title: string): T.RPCChat.UIMessageUnfurlInfo => ({ + isCollapsed: false, + unfurl: { + generic: { + favicon: image(url && `${url}/favicon`), + media: image(url && `${url}/media`), + siteName: 'site', + title, + url: 'https://keybase.io', + }, + unfurlType: T.RPCChat.UnfurlType.generic, + }, + unfurlMessageID: T.Chat.numberToMessageID(11), + url: 'https://keybase.io', + }) + const giphy = (url: string): T.RPCChat.UIMessageUnfurlInfo => ({ + isCollapsed: false, + unfurl: { + giphy: { + favicon: image(url && `${url}/favicon`), + image: image(url && `${url}/image`), + video: {...image(url && `${url}/video`), isVideo: true}, + }, + unfurlType: T.RPCChat.UnfurlType.giphy, + }, + unfurlMessageID: T.Chat.numberToMessageID(12), + url: 'https://giphy.com/x', + }) + const local = 'http://127.0.0.1:5000' + const state = makeThreadState([]) + addMessagesToThreadState( + state, + [ + textAt(10, { + unfurls: new Map([ + ['https://keybase.io', generic(local, 'first')], + ['https://giphy.com/x', giphy(local)], + ]), + }), + ], + {} + ) + addMessagesToThreadState( + state, + [ + textAt(10, { + unfurls: new Map([ + ['https://keybase.io', generic('', 'second')], + ['https://giphy.com/x', giphy('')], + ]), + }), + ], + {} + ) + const unfurls = (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).unfurls + const g = unfurls?.get('https://keybase.io')?.unfurl + expect(g?.unfurlType === T.RPCChat.UnfurlType.generic && g.generic.title).toBe('second') + expect(g?.unfurlType === T.RPCChat.UnfurlType.generic && [g.generic.favicon?.url, g.generic.media?.url]).toEqual([ + `${local}/favicon`, + `${local}/media`, + ]) + const gi = unfurls?.get('https://giphy.com/x')?.unfurl + expect( + gi?.unfurlType === T.RPCChat.UnfurlType.giphy && [gi.giphy.favicon?.url, gi.giphy.image?.url, gi.giphy.video?.url] + ).toEqual([`${local}/favicon`, `${local}/image`, `${local}/video`]) + }) + test('reactions keep their emoji urls on a merge and on a reaction update', () => { const good = emojiDecoration(emojiURL) const reaction = (decorated: string, users: Array): T.Chat.ReactionDesc => ({ diff --git a/shared/chat/conversation/thread-message-state.tsx b/shared/chat/conversation/thread-message-state.tsx index 7f53f64c6688..e3c6c951611e 100644 --- a/shared/chat/conversation/thread-message-state.tsx +++ b/shared/chat/conversation/thread-message-state.tsx @@ -1,7 +1,7 @@ import * as Message from '@/constants/chat/message' import * as T from '@/constants/types' import HiddenString from '@/util/hidden-string' -import {localServerURLKeys, shouldKeepEmojiURLs} from './local-server-urls' +import {localServerURLKeys, shouldKeepEmojiURLs, unfurlKeepingLocalServerURLs} from './local-server-urls' import type {WritableDraft} from '@/util/zustand' type MessageLookup = Pick @@ -177,6 +177,12 @@ const mergeMessage = ( if (key === 'reactions') { const old = (cur as Map).get(k) ;(cur as Map).set(k, reactionKeepingEmojiURLs(old, v as T.Chat.ReactionDesc)) + } else if (key === 'unfurls') { + const old = (cur as Map).get(k) + ;(cur as Map).set( + k, + unfurlKeepingLocalServerURLs(old, v as T.RPCChat.UIMessageUnfurlInfo) + ) } else { ;(cur as Map).set(k, v) } diff --git a/shared/constants/init/push-listener.native.test.ts b/shared/constants/init/push-listener.native.test.ts index 0af8d76a1c0e..b3410538879f 100644 --- a/shared/constants/init/push-listener.native.test.ts +++ b/shared/constants/init/push-listener.native.test.ts @@ -162,7 +162,6 @@ describe('live pushes', () => { describe('startup push', () => { const tapped = { 'chat.newmessage': {convID, m: 'payload', t: 2, type: 'chat.newmessage'}, - 'chat.newmessageSilent_2': {c: convID, m: 'payload', t: 2, type: 'chat.newmessageSilent_2'}, follow: {type: 'follow', username: 'testuser-mac'}, } @@ -186,6 +185,16 @@ describe('startup push', () => { expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() }) + test('a tapped chat.newmessage for another account is kept pending for the account switch', async () => { + const {pushListener, pushStore} = load() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() + const pending = pushStore.usePushState.getState().pendingPushNotification + expect(pending?.type).toBe('chat.newmessage') + expect(pending && 'forUid' in pending && pending.forUid).toBe(otherUid) + }) + test('tapped pushes pick the startup screen', async () => { const {pushListener} = load() getInitialNotification = async () => Promise.resolve({...tapped['chat.newmessage'], userInteraction: true}) @@ -193,12 +202,6 @@ describe('startup push', () => { startupConversation: convID, startupPushPayload: 'payload', }) - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessageSilent_2'], userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ - startupConversation: convID, - startupPushPayload: 'payload', - }) getInitialNotification = async () => Promise.resolve({...tapped.follow, userInteraction: true}) await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ startupFollowUser: 'testuser-mac', diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index f796015edc44..d84733949eea 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -127,7 +127,6 @@ const normalizePush = (_n?: object): T.Push.PushNotification | undefined => { membersType, type: 'chat.newmessageSilent_2', unboxPayload: data.m || '', - userInteraction, } } } @@ -233,13 +232,13 @@ const getStartupDetailsFromInitialPush = async () => { if (notification.username) { return {startupFollowUser: notification.username} } - } else if (notification.type === 'chat.newmessage' || notification.type === 'chat.newmessageSilent_2') { + } else if (notification.type === 'chat.newmessage') { 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) { + if (notification.forUid) { usePushState.getState().dispatch.setPendingPushNotification(notification) return } diff --git a/shared/constants/types/push.tsx b/shared/constants/types/push.tsx index 7b8b702090b6..fc4129c4473a 100644 --- a/shared/constants/types/push.tsx +++ b/shared/constants/types/push.tsx @@ -12,7 +12,6 @@ export type PushNotification = membersType: RPCChatTypes.ConversationMembersType type: 'chat.newmessageSilent_2' unboxPayload: string - userInteraction: boolean } | { conversationIDKey: ChatTypes.ConversationIDKey From d69d76f1d2abfed00f4bb81c7fff363017a4edbe Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 17:10:13 -0400 Subject: [PATCH 027/127] feat(ios): run live location natively so it works without JS Go's live location tracker drives a native CLLocationManager on iOS through a watcher passed to KeybaseInit, reference-counted across trackers, and fixes come back through a LocationUpdate bind entry point. Trackers restored after a location relaunch start watching with no UI. JS on iOS only requests the permission and no longer runs the expo-location task; Android is unchanged. Also reads the tracker's last coordinate under its lock. --- go/bind/keybase.go | 28 ++- go/bind/location_test.go | 74 ++++++ go/chat/globals/globals.go | 1 + go/chat/maps/livelocation.go | 77 ++++++- go/chat/maps/livelocation_watch_test.go | 218 ++++++++++++++++++ go/chat/types/interfaces.go | 8 + .../java/io/keybase/ossifrage/MainActivity.kt | 2 +- shared/constants/init/index.tsx | 4 + shared/constants/init/location-watch.test.ts | 103 +++++++++ shared/ios/Keybase.xcodeproj/project.pbxproj | 4 + shared/ios/Keybase/AppDelegate.swift | 5 +- shared/ios/Keybase/LocationWatcher.swift | 106 +++++++++ 12 files changed, 614 insertions(+), 16 deletions(-) create mode 100644 go/bind/location_test.go create mode 100644 go/chat/maps/livelocation_watch_test.go create mode 100644 shared/constants/init/location-watch.test.ts create mode 100644 shared/ios/Keybase/LocationWatcher.swift diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 3410c7dbf2cf..ceffe676168f 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -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. @@ -460,6 +469,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 @@ -927,6 +937,18 @@ func AppWillResignActive() { kbCtx.MobileLifecycle.WillResignActive() } +// LocationUpdate reports a location fix from the native location service. +func LocationUpdate(lat, lon float64, accuracy int) { + if !isInited() || !kbCtx.ActiveDevice.HaveKeys() { + return + } + locationUpdate(kbChatCtx.LiveLocationTracker, lat, lon, accuracy) +} + +func locationUpdate(tracker types.LiveLocationTracker, lat, lon float64, accuracy int) { + tracker.LocationUpdate(context.Background(), chat1.Coordinate{Lat: lat, Lon: lon, Accuracy: float64(accuracy)}) +} + func waitForInit(maxDur time.Duration) error { if isInited() { return nil diff --git a/go/bind/location_test.go b/go/bind/location_test.go new file mode 100644 index 000000000000..79379ece3684 --- /dev/null +++ b/go/bind/location_test.go @@ -0,0 +1,74 @@ +package keybase + +import ( + "context" + "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/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) + tracker.TestingCoordsAddedCh = make(chan struct{}, 10) + ctx := context.Background() + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + + tracker.StartTracking(ctx, chat1.ConversationID("conv"), 1, clock.Now().Add(time.Hour)) + select { + case <-watcher.starts: + case <-time.After(10 * time.Second): + require.Fail(t, "native watch never started") + } + + locationUpdate(tracker, 40.5, -73.25, 12) + select { + case <-tracker.TestingCoordsAddedCh: + case <-time.After(10 * time.Second): + require.Fail(t, "coordinate never reached the tracker") + } + require.Equal(t, []chat1.Coordinate{{Lat: 40.5, Lon: -73.25, Accuracy: 12}}, + tracker.GetCoordinates(ctx, "not a tracker")) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, tc.G.MobileAppState.State()) + + 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()) +} 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 a207c383d7fd..998cc0987552 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -33,6 +33,9 @@ type LiveLocationTracker struct { lastCoord chat1.Coordinate maxCoords int + nativeWatchMu sync.Mutex + nativeWatchRefs int + // testing only TestingCoordsAddedCh chan struct{} } @@ -110,6 +113,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() { @@ -123,6 +130,12 @@ func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { } } +func (l *LiveLocationTracker) getLastCoord() chat1.Coordinate { + l.Lock() + defer l.Unlock() + return l.lastCoord +} + func (l *LiveLocationTracker) getChatUI(ctx context.Context) libkb.ChatUI { ui, err := l.G().UIRouter.GetChatUI() if err != nil || ui == nil { @@ -193,8 +206,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") } @@ -243,13 +256,58 @@ 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 { + 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 } @@ -274,25 +332,22 @@ func (l *LiveLocationTracker) tracker(t *locationTrack) error { } // start up the OS watch routine - watchID, err := l.startWatch(ctx, t) + watchID, stopWatch, err := l.startWatch(ctx, t) if err != nil { 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) - } + stopWatch() l.Lock() defer l.Unlock() l.removeTrackerLocked(ctx, t) }() // 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 diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go new file mode 100644 index 000000000000..9191b4efd078 --- /dev/null +++ b/go/chat/maps/livelocation_watch_test.go @@ -0,0 +1,218 @@ +package maps + +import ( + "context" + "sync" + "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/protocol/chat1" + "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()) +} diff --git a/go/chat/types/interfaces.go b/go/chat/types/interfaces.go index 5212f879fab5..5239ba655b1f 100644 --- a/go/chat/types/interfaces.go +++ b/go/chat/types/interfaces.go @@ -469,6 +469,14 @@ 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.LocationUpdate. 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 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..1cd23e10e613 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 @@ -465,7 +465,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/constants/init/index.tsx b/shared/constants/init/index.tsx index 63111c3c2df7..a3e5d036574b 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -92,6 +92,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 +115,7 @@ const onChatWatchPosition = async ( } const onChatClearWatch = async () => { + if (isIOS) return const {ExpoLocation, ExpoTaskManager} = _getNative() locationRefs-- if (locationRefs <= 0) { diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts new file mode 100644 index 000000000000..84ee593d132d --- /dev/null +++ b/shared/constants/init/location-watch.test.ts @@ -0,0 +1,103 @@ +/// +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') + }, + }, + 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', + ]) +}) diff --git a/shared/ios/Keybase.xcodeproj/project.pbxproj b/shared/ios/Keybase.xcodeproj/project.pbxproj index 04efe02c9f9d..5530d8122d57 100644 --- a/shared/ios/Keybase.xcodeproj/project.pbxproj +++ b/shared/ios/Keybase.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 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 */ @@ -120,6 +121,7 @@ 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; }; @@ -281,6 +283,7 @@ DBD252972DF32D5C008A43FF /* Fs.swift */, DBDF89F52DF7779900EA18C2 /* Pusher.swift */, DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */, + DBLOCWATCH00270000000001 /* LocationWatcher.swift */, DBPERF022600000001 /* PerfFPSMonitor.swift */, 832341AE1AAA6A7D00B99B32 /* Libraries */, DAA243C38335068656BC36B1 /* PrivacyInfo.xcprivacy */, @@ -599,6 +602,7 @@ 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 f6e56654e4f7..9226c54913b8 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -22,6 +22,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi var resignImageView: UIImageView? var fsPaths: [String: String] = [:] private let lifecycle = AppLifecycleForwarder(events: KeybaseLifecycleEvents()) + private var locationWatcher: LocationWatcher? private var lastNotificationResponseKey: String? var iph: ItemProviderHelper? private var startupLogFileHandle: FileHandle? @@ -179,7 +180,9 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi 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)") diff --git a/shared/ios/Keybase/LocationWatcher.swift b/shared/ios/Keybase/LocationWatcher.swift new file mode 100644 index 000000000000..78f8aa9a803f --- /dev/null +++ b/shared/ios/Keybase/LocationWatcher.swift @@ -0,0 +1,106 @@ +import CoreLocation +import Keybasego +import UIKit +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; each fix goes back to Go. Created in +// didFinishLaunching, before Go restores its trackers, so an app relaunched by +// significant-change monitoring starts watching again. Its options match the +// expo-location background task it replaced. +final class LocationWatcher: NSObject, Keybasego.KeybaseNativeLocationWatcherProtocol, CLLocationManagerDelegate { + // In the background a fix is only reported once the device has moved this far + // since the last one reported. + private static let deferredUpdatesDistance: CLLocationDistance = 65 + + // Everything below is main thread only. + private let manager = CLLocationManager() + private var wanted = false + private var running = false + private var lastReported: CLLocation? + private var pending: CLLocation? + private var pendingDistance: CLLocationDistance = 0 + + 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 } + for location in locations where location.horizontalAccuracy >= 0 { + if let previous = pending ?? lastReported { + pendingDistance += location.distance(from: previous) + } + pending = location + } + guard let location = pending, + UIApplication.shared.applicationState == .active || pendingDistance >= Self.deferredUpdatesDistance + else { return } + lastReported = location + pending = nil + pendingDistance = 0 + let coordinate = location.coordinate + let accuracy = Int(location.horizontalAccuracy) + goQueue.async { + Keybasego.KeybaseLocationUpdate(coordinate.latitude, coordinate.longitude, 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() + lastReported = nil + pending = nil + pendingDistance = 0 + } + } +} From cebf667d8b3706763633cb16eec9a590f018a5c4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 17:17:52 -0400 Subject: [PATCH 028/127] fix(ios): report the first live location fix at once and test the LocationUpdate guards --- go/bind/location_test.go | 56 ++++++++++++++++++++++++ shared/ios/Keybase/LocationWatcher.swift | 10 +++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/go/bind/location_test.go b/go/bind/location_test.go index 79379ece3684..54a6988e362e 100644 --- a/go/bind/location_test.go +++ b/go/bind/location_test.go @@ -2,6 +2,7 @@ package keybase import ( "context" + "sync" "testing" "time" @@ -72,3 +73,58 @@ func TestLocationUpdateReachesTrackers(t *testing.T) { } require.Equal(t, keybase1.MobileAppState_BACKGROUND, tc.G.MobileAppState.State()) } + +type recordingLiveLocationTracker struct { + types.LiveLocationTracker + sync.Mutex + coords []chat1.Coordinate +} + +func (r *recordingLiveLocationTracker) LocationUpdate(_ 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/shared/ios/Keybase/LocationWatcher.swift b/shared/ios/Keybase/LocationWatcher.swift index 78f8aa9a803f..a47ab61a1e19 100644 --- a/shared/ios/Keybase/LocationWatcher.swift +++ b/shared/ios/Keybase/LocationWatcher.swift @@ -8,11 +8,12 @@ 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; each fix goes back to Go. Created in // didFinishLaunching, before Go restores its trackers, so an app relaunched by -// significant-change monitoring starts watching again. Its options match the -// expo-location background task it replaced. +// 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 { // In the background a fix is only reported once the device has moved this far - // since the last one reported. + // since the last one reported. The first fix after starting is reported right + // away, so the move that relaunched the app gets posted. private static let deferredUpdatesDistance: CLLocationDistance = 65 // Everything below is main thread only. @@ -60,7 +61,8 @@ final class LocationWatcher: NSObject, Keybasego.KeybaseNativeLocationWatcherPro pending = location } guard let location = pending, - UIApplication.shared.applicationState == .active || pendingDistance >= Self.deferredUpdatesDistance + lastReported == nil || UIApplication.shared.applicationState == .active + || pendingDistance >= Self.deferredUpdatesDistance else { return } lastReported = location pending = nil From 553a0ef26b49747fc3d647a78944cda99ab33f68 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 17:41:33 -0400 Subject: [PATCH 029/127] fix(chat): give up on the chat UI location watch after its retry limit --- go/chat/maps/livelocation.go | 4 +-- go/chat/maps/livelocation_watch_test.go | 46 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 998cc0987552..5968b58674bf 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -314,8 +314,8 @@ func (l *LiveLocationTracker) startChatUIWatch(ctx context.Context, t *locationT } else { break } - maxWatchAttempts++ - time.Sleep(time.Second) + watchAttempts++ + l.clock.Sleep(time.Second) } return watchID, nil } diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go index 9191b4efd078..fe12c3b38f8d 100644 --- a/go/chat/maps/livelocation_watch_test.go +++ b/go/chat/maps/livelocation_watch_test.go @@ -2,7 +2,9 @@ package maps import ( "context" + "errors" "sync" + "sync/atomic" "testing" "time" @@ -216,3 +218,47 @@ func TestLiveLocationTrackerNativeWatchStopsWhenTrackerEnds(t *testing.T) { 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()) +} From 9eee1d508d4261b590133eb4a69b25680aa89f97 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 17:41:33 -0400 Subject: [PATCH 030/127] fix(android): report the process lifecycle and push windows to Go as lifecycle events --- go/bind/keybase.go | 41 +- go/libkb/lifecycle/lifecycle.go | 7 + go/libkb/lifecycle/lifecycletest/harness.go | 9 +- go/libkb/lifecycle/lifecycletest/scenarios.go | 76 +++- shared/android/app/build.gradle | 2 + .../keybase/ossifrage/AppLifecycleReporter.kt | 193 ++++++++++ .../ossifrage/ChatBroadcastReceiver.kt | 10 +- .../keybase/ossifrage/KeybaseLifecycleBind.kt | 27 ++ .../KeybasePushNotificationListenerService.kt | 88 ++--- .../java/io/keybase/ossifrage/MainActivity.kt | 23 +- .../io/keybase/ossifrage/MainApplication.kt | 6 + .../ossifrage/AppLifecycleReporterTest.kt | 359 ++++++++++++++++++ 12 files changed, 722 insertions(+), 119 deletions(-) create mode 100644 shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt create mode 100644 shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt create mode 100644 shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt diff --git a/go/bind/keybase.go b/go/bind/keybase.go index ceffe676168f..dfb2049ac71c 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -885,31 +885,8 @@ func FlushLogs() { logger.FlushLogFile() } -func SetAppStateForeground() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateForeground", nil)() - kbCtx.MobileLifecycle.DidBecomeActive() -} - -func SetAppStateBackground() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateBackground", nil)() - kbCtx.MobileLifecycle.DidEnterBackground(func() bool { return false }) -} - -func SetAppStateBackgroundActive() { - if !isInited() { - return - } - defer kbCtx.Trace("SetAppStateBackgroundActive", nil)() - kbCtx.MobileLifecycle.WillEnterForeground() -} - -// AppWillEnterForeground reports iOS applicationWillEnterForeground. +// AppWillEnterForeground reports iOS applicationWillEnterForeground, or +// Android's process start. func AppWillEnterForeground() { if !isInited() { return @@ -919,7 +896,7 @@ func AppWillEnterForeground() { } // AppDidBecomeActive reports iOS applicationDidBecomeActive, or Android's -// process start. +// process resume. func AppDidBecomeActive() { if !isInited() { return @@ -1081,6 +1058,18 @@ func AppPushWindowEnd(token int64) bool { return kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground) } +// AppPushWindowClose closes the window opened by AppPushWindowBegin, only if +// nothing has updated the app state since, without handing it over to a +// background task. Use it instead of AppPushWindowEnd when no background task +// can be started. +func AppPushWindowClose(token int64) { + if !isInited() { + return + } + defer kbCtx.Trace("AppPushWindowClose", nil)() + kbCtx.MobileLifecycle.PushWindowClose(token) +} + func AppBeginBackgroundTaskNonblock(pusher PushNotifier) { if !isInited() { return diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 7a8651644e67..e957e7d1f224 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -284,6 +284,13 @@ func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) bool { return false } +// PushWindowClose is PushWindowEnd for a caller that can't run a background +// task: it never hands the window over, so nothing is left in +// BACKGROUNDACTIVE waiting on a task that won't start. +func (c *Controller) PushWindowClose(token int64) { + c.PushWindowEnd(token, func() bool { return false }) +} + // BackgroundSync moves BACKGROUND to BACKGROUNDACTIVE for the sync window, // then undoes that transition unless someone else updated the state meanwhile. // It returns a status for native logs. diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index b04279c83a8d..59c7571089ad 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -42,7 +42,8 @@ type Action int const ( // Nothing reports no event, as when a silent push launches the app - // without a scene or an Android dialog pauses the activity. + // without a scene, or an Android dialog, permission prompt or picker + // pauses the activity. Nothing Action = iota + 1 // Native lifecycle events. @@ -54,6 +55,9 @@ const ( BackgroundTaskExpired PushWindowBegin PushWindowEnd + // PushWindowClose ends the push window when native can't start a + // background task. + PushWindowClose LiveLocationClaim LiveLocationRelease @@ -98,6 +102,7 @@ var actionNames = map[Action]string{ BackgroundTaskExpired: "BackgroundTaskExpired", PushWindowBegin: "PushWindowBegin", PushWindowEnd: "PushWindowEnd", + PushWindowClose: "PushWindowClose", LiveLocationClaim: "LiveLocationClaim", LiveLocationRelease: "LiveLocationRelease", BackgroundSyncStart: "BackgroundSyncStart", @@ -315,6 +320,8 @@ func (h *Harness) perform(step Step) bool { return h.tokens[step.Slot] > 0 case PushWindowEnd: return c.PushWindowEnd(h.tokens[step.Slot], h.stayRunning) + case PushWindowClose: + c.PushWindowClose(h.tokens[step.Slot]) case LiveLocationClaim: c.LiveLocationClaim() case LiveLocationRelease: diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index ddc22530f0b3..7d77cdaa5a7a 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -66,10 +66,16 @@ var iosToBackgroundTask = []Step{ step(BackgroundTaskStart, bga, 0).returns(true), } -var androidLaunch = []Step{ +// androidStart is the process lifecycle's start and resume, from any state. +// The observed states assume it starts from BACKGROUND. +var androidStart = []Step{ + step(WillEnterForeground, bga, 1), step(DidBecomeActive, fg, 1), } +// androidLaunch starts the UI in a fresh process (BACKGROUNDACTIVE). +var androidLaunch = androidStart + // Scenarios replays whole native event sequences. Consumers of the app state // can play them with their own checks (see Play). var Scenarios = []Scenario{ @@ -341,23 +347,27 @@ var Scenarios = []Scenario{ Platform: Android, Steps: steps(androidLaunch, []Step{ step(DidEnterBackground, bg, 1).flush().returns(false), - step(DidBecomeActive, fg, 1), + }, androidStart, []Step{ step(WorkStarts, fg, 0), step(DidEnterBackground, bga, 1).flush().returns(true), step(BackgroundTaskStart, bga, 0).returns(true), + // The same value, so the task keeps waiting, but the window is no + // longer its own. + step(WillEnterForeground, bga, 1), step(DidBecomeActive, fg, 1), step(BackgroundTaskWait, fg, 0), }), - Observed: states(bga, fg, bg, fg, bga, fg), + Observed: states(bga, fg, bg, bga, fg, bga, fg), }, { - Name: "android dialog or picker pause keeps the foreground", + Name: "android dialog, permission prompt or picker pause keeps the foreground", Platform: Android, Steps: steps(androidLaunch, []Step{ step(Nothing, fg, 0), step(PushWindowBegin, fg, 0).returns(false), step(PushWindowEnd, fg, 0).returns(false), - step(Nothing, fg, 0), + // Back from the prompt: the process resumes without a start. + step(DidBecomeActive, fg, 1), }), Observed: states(bga, fg), }, @@ -382,13 +392,16 @@ var Scenarios = []Scenario{ Observed: states(bga, fg, bg, bga, bg), }, { + // A process started without UI reports the background before the push + // window opens. Name: "android push at cold start", Platform: Android, Steps: []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), step(PushWindowBegin, bga, 1).returns(true), step(PushWindowEnd, bg, 1).flush().returns(false), }, - Observed: states(bga, bg), + Observed: states(bga, bg, bga, bg), }, { Name: "android push window racing process start", @@ -396,14 +409,18 @@ var Scenarios = []Scenario{ Steps: steps(androidLaunch, []Step{ step(DidEnterBackground, bg, 1).flush().returns(false), step(PushWindowBegin, bga, 1).returns(true), + // The process start's first half matches the window's value, but + // still supersedes it. + step(WillEnterForeground, bga, 1), + step(PushWindowEnd, bga, 0).returns(false), step(DidBecomeActive, fg, 1), + step(PushWindowBegin, fg, 0).returns(false), step(PushWindowEnd, fg, 0).returns(false), // Foreground and back to the background while the push is // handled: the value matches, but the window isn't the push's. - step(PushWindowBegin, fg, 0).returns(false), step(DidEnterBackground, bg, 1).flush().returns(false), step(PushWindowBegin, bga, 1).returns(true), - step(DidBecomeActive, fg, 1), + }, androidStart, []Step{ step(DidEnterBackground, bg, 1).flush().returns(false), step(PushWindowEnd, bg, 0).returns(false), }), @@ -422,6 +439,24 @@ var Scenarios = []Scenario{ }), Observed: states(bga, fg, bg, bga, bg), }, + { + // With work pending but no way to start a background task, the window + // closes instead of staying BACKGROUNDACTIVE with no one to end it. + Name: "android push window closes when no background task can start", + Platform: Android, + Steps: steps(androidLaunch, []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(WorkStarts, bg, 0), + step(PushWindowBegin, bga, 1).returns(true), + step(PushWindowClose, bg, 1).flush(), + step(BackgroundTaskStart, bg, 0).returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(WillEnterForeground, bga, 1), + step(PushWindowClose, bga, 0), + step(DidBecomeActive, fg, 1), + }), + Observed: states(bga, fg, bg, bga, bg, bga, fg), + }, { Name: "android overlapping push windows", Platform: Android, @@ -435,15 +470,28 @@ var Scenarios = []Scenario{ Observed: states(bga, fg, bg, bga, bg), }, { - // Current behavior, pinned until Task 9 revisits it: Android starts in - // BACKGROUNDACTIVE, so a WorkManager cold start skips the sync and - // nothing moves the state to BACKGROUND. - Name: "android WorkManager BackgroundSync at cold start skips (current behavior)", + // A process started without UI (WorkManager) reports the background + // first, so the sync gets its window and returns to BACKGROUND. + Name: "android WorkManager BackgroundSync at cold start", Platform: Android, Steps: []Step{ - step(BackgroundSyncStart, bga, 0).returns(false), + step(DidEnterBackground, bg, 1).flush().returns(false), + step(BackgroundSyncStart, bga, 1).returns(true), + step(BackgroundSyncTimerFires, bg, 1).flush(), }, - Observed: states(bga), + Observed: states(bga, bg, bga, bg), + }, + { + Name: "android UI starts during a WorkManager cold start sync", + Platform: Android, + Steps: []Step{ + step(DidEnterBackground, bg, 1).flush().returns(false), + step(BackgroundSyncStart, bga, 1).returns(true), + step(WillEnterForeground, bga, 1), + step(DidBecomeActive, fg, 1), + step(BackgroundSyncWait, fg, 0), + }, + Observed: states(bga, bg, bga, fg), }, { Name: "android WorkManager BackgroundSync racing a push window", 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/java/io/keybase/ossifrage/AppLifecycleReporter.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt new file mode 100644 index 000000000000..6c80c265adf1 --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -0,0 +1,193 @@ +package io.keybase.ossifrage + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.Executors +import java.util.concurrent.Future +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 willEnterForeground() + fun didBecomeActive() + fun didEnterBackground(): Boolean + fun willExit() + fun pushWindowBegin(): Long + fun pushWindowEnd(token: Long): Boolean + fun pushWindowClose(token: Long) + // False when a background task can't run, e.g. with no context for its + // failure notifications. + fun canBeginBackgroundTask(): Boolean + fun beginBackgroundTask() +} + +internal interface LifecycleExecutor { + fun submit(task: Runnable): Future<*> + // Returns a function that cancels the task. + fun schedule(delayMs: Long, task: Runnable): () -> Unit +} + +internal class SingleThreadLifecycleExecutor : LifecycleExecutor { + private val executor = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "kb-app-lifecycle") } + + override fun submit(task: Runnable): Future<*> = executor.submit(task) + + override fun schedule(delayMs: Long, task: Runnable): () -> Unit { + val scheduled = executor.schedule(task, delayMs, TimeUnit.MILLISECONDS) + return { scheduled.cancel(false) } + } +} + +// Reports the app's process lifecycle to Go as events; Go decides the state. +// +// Events reach Go in the order they happen, on one background thread: +// didEnterBackground queries the outbox, so it can't run on the main thread. +// +// Only the process lifecycle counts. Activity pauses (dialogs, permission +// prompts, choosers, the photo picker) report nothing, not even +// willResignActive: INACTIVE would let a push window open and end in +// BACKGROUND while the app is on screen. +internal class AppLifecycleReporter( + private val bind: LifecycleBind, + private val executor: LifecycleExecutor, + private val log: (String) -> Unit, +) : DefaultLifecycleObserver { + private enum class Reported { NOTHING, FOREGROUND, BACKGROUND } + + private var reported = Reported.NOTHING + private var started = false + private var externalActivityPending = false + private var deferredStop = 0L + private var cancelDeferredStop: (() -> Unit)? = null + + @Synchronized + override fun onStart(owner: LifecycleOwner) { + started = true + externalActivityPending = false + endDeferredStop() + if (reported != Reported.FOREGROUND) { + enqueue("willEnterForeground") { bind.willEnterForeground() } + } + } + + @Synchronized + override fun onResume(owner: LifecycleOwner) { + reported = Reported.FOREGROUND + enqueue("didBecomeActive") { bind.didBecomeActive() } + } + + @Synchronized + override fun onStop(owner: LifecycleOwner) { + started = false + if (!externalActivityPending) { + reportBackground("process stop") + return + } + // A full-screen picker, camera or document UI we started for a result + // stops the process, but the user is still using the app. + val token = ++deferredStop + log("AppLifecycleReporter: deferring the background while an activity started for a result is up") + cancelDeferredStop = executor.schedule(EXTERNAL_ACTIVITY_GRACE_MS, Runnable { deferredStopExpired(token) }) + } + + @Synchronized + fun onExternalActivityLaunched() { + externalActivityPending = true + } + + @Synchronized + fun onExternalActivityResult() { + externalActivityPending = false + } + + // Activity recreation and a task moved to the back are not an exit. + @Synchronized + fun onMainActivityDestroy(isFinishing: Boolean, isChangingConfigurations: Boolean) { + if (!isFinishing || isChangingConfigurations) { + return + } + endDeferredStop() + reported = Reported.BACKGROUND + enqueue("willExit") { bind.willExit() } + } + + // A process started without UI (a push) starts Go in BACKGROUNDACTIVE with + // nothing to end it; report the background, unless the UI got there first. + @Synchronized + fun reportHeadlessStart() { + if (reported == Reported.NOTHING && !started) { + reportBackground("started without UI") + } + } + + // Waits until every event reported so far has reached Go. + fun awaitReported(timeoutMs: Long) { + try { + executor.submit(Runnable {}).get(timeoutMs, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + log("AppLifecycleReporter: gave up waiting for events to reach Go: $e") + } + } + + @Synchronized + private fun deferredStopExpired(token: Long) { + if (token != deferredStop || cancelDeferredStop == null || started) { + return + } + cancelDeferredStop = null + reportBackground("process stop after the external activity grace period") + } + + private fun endDeferredStop() { + cancelDeferredStop?.invoke() + cancelDeferredStop = null + } + + private fun reportBackground(why: String) { + reported = Reported.BACKGROUND + enqueue("didEnterBackground: $why") { + if (bind.didEnterBackground()) { + bind.beginBackgroundTask() + } + } + } + + // Callers hold the lock, so tasks are queued in the order events happen. + private fun enqueue(event: String, call: () -> Unit) { + executor.submit(Runnable { + log("AppLifecycleReporter: $event") + try { + call() + } catch (e: Exception) { + log("AppLifecycleReporter: $event failed: $e") + } + }) + } + + companion object { + const val EXTERNAL_ACTIVITY_GRACE_MS = 2 * 60 * 1000L + } +} + +// Runs task in a push window: Go stays up in BACKGROUNDACTIVE while it runs, +// unless the app is in the foreground, where the task is skipped. +internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, task: () -> Unit) { + val token = bind.pushWindowBegin() + if (token == 0L) { + log("runPushWindow: app is in the foreground, skipping") + return + } + try { + task() + } finally { + // Negative: Go isn't initialized, so no window opened. + if (token > 0) { + if (!bind.canBeginBackgroundTask()) { + bind.pushWindowClose(token) + } else if (bind.pushWindowEnd(token)) { + bind.beginBackgroundTask() + } + } + } +} 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..b618e249f6af 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 @@ -30,12 +30,12 @@ class ChatBroadcastReceiver : BroadcastReceiver() { 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 lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter + lifecycleReporter.reportHeadlessStart() + lifecycleReporter.awaitReported(5000) + runPushWindow(KeybaseLifecycleBind(context), { NativeLogger.info(it) }) { + Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody) } - withBackgroundActive.whileActive(context) repliedNotification.setContentText("Replied") } catch (e: Exception) { repliedNotification.setContentText("Couldn't send reply") 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..09f95e6452f0 --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt @@ -0,0 +1,27 @@ +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 willEnterForeground() = Keybase.appWillEnterForeground() + + override fun didBecomeActive() = Keybase.appDidBecomeActive() + + override fun didEnterBackground(): Boolean = Keybase.appDidEnterBackground() + + override fun willExit() = Keybase.appWillExit(notifier()) + + override fun pushWindowBegin(): Long = Keybase.appPushWindowBegin() + + override fun pushWindowEnd(token: Long): Boolean = Keybase.appPushWindowEnd(token) + + override fun pushWindowClose(token: Long) = Keybase.appPushWindowClose(token) + + override fun canBeginBackgroundTask(): Boolean = context != null + + override fun beginBackgroundTask() = Keybase.appBeginBackgroundTaskNonblock(notifier()) + + private fun notifier() = 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..23cb48f9bd17 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 @@ -40,8 +40,11 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { return style } + private val lifecycleReporter get() = (application as MainApplication).lifecycleReporter + override fun onCreate() { setupKBRuntime(this, false) + lifecycleReporter.reportHeadlessStart() NativeLogger.info("KeybasePushNotificationListenerService created") createNotificationChannel(this) } @@ -116,28 +119,28 @@ 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 + // The push window must see the state after the process + // start or stop that came before this push. + lifecycleReporter.awaitReported(5000) + runPushWindow(KeybaseLifecycleBind(applicationContext), { NativeLogger.info(it) }) { + 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) } catch (ex: Exception) { if (isOtherAccountPushError(ex)) { NativeLogger.info("Skipping active-account processing for different-account push") @@ -388,50 +391,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 1cd23e10e613..190beb7e9ba4 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -93,13 +93,24 @@ class MainActivity : ReactActivity() { override fun onPause() { NativeLogger.info("Activity onPause") super.onPause() - if (Keybase.appDidEnterBackground()) { - Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(this, Bundle())) - } else { - Keybase.setAppStateBackground() + } + + @Deprecated("Deprecated in Java") + override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { + @Suppress("DEPRECATION") + super.startActivityForResult(intent, requestCode, options) + if (requestCode >= 0) { + lifecycleReporter().onExternalActivityLaunched() } } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + lifecycleReporter().onExternalActivityResult() + super.onActivityResult(requestCode, resultCode, data) + } + + private fun lifecycleReporter() = (application as MainApplication).lifecycleReporter + private fun getFileNameFromResolver(resolver: ContentResolver, uri: Uri, extension: String?): String { // Use a GUID default. var filename = String.format("%s.%s", UUID.randomUUID().toString(), extension) @@ -157,20 +168,18 @@ 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())) + lifecycleReporter().onMainActivityDestroy(isFinishing, isChangingConfigurations) } private var cachedIntent: Intent? = 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..aac9f1f8976c 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 @@ -52,9 +52,15 @@ class MainApplication : Application(), ReactApplication { } + internal val lifecycleReporter by lazy { + AppLifecycleReporter(KeybaseLifecycleBind(this), SingleThreadLifecycleExecutor()) { 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) { 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..7842d13b4c50 --- /dev/null +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -0,0 +1,359 @@ +package io.keybase.ossifrage + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import java.util.Collections +import java.util.concurrent.Future +import java.util.concurrent.FutureTask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +private class FakeBind : LifecycleBind { + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + var stayRunning = false + var token = 7L + var endHandsOver = false + var hasContext = true + var onDidEnterBackground: () -> Unit = {} + + override fun willEnterForeground() { + calls.add("willEnterForeground") + } + + override fun didBecomeActive() { + calls.add("didBecomeActive") + } + + override fun didEnterBackground(): Boolean { + onDidEnterBackground() + calls.add("didEnterBackground") + return stayRunning + } + + override fun willExit() { + calls.add("willExit") + } + + override fun pushWindowBegin(): Long { + calls.add("pushWindowBegin") + return token + } + + override fun pushWindowEnd(token: Long): Boolean { + calls.add("pushWindowEnd($token)") + return endHandsOver + } + + override fun pushWindowClose(token: Long) { + calls.add("pushWindowClose($token)") + } + + override fun canBeginBackgroundTask() = hasContext + + override fun beginBackgroundTask() { + calls.add("beginBackgroundTask") + } +} + +// Runs nothing until told to, so tests see what was queued and in what order. +private class ManualExecutor : LifecycleExecutor { + val queue = mutableListOf() + val scheduled = mutableListOf() + + class Scheduled(val delayMs: Long, val task: Runnable) { + var cancelled = false + } + + override fun submit(task: Runnable): Future<*> { + val future = FutureTask(task, Unit) + queue.add(future) + return future + } + + override fun schedule(delayMs: Long, task: Runnable): () -> Unit { + val s = Scheduled(delayMs, task) + scheduled.add(s) + return { s.cancelled = true } + } + + fun runAll() { + while (queue.isNotEmpty()) { + queue.removeAt(0).run() + } + } + + // Fires a scheduled task as the executor would, even a cancelled one + // whose cancel lost the race. + fun fire(s: Scheduled) { + queue.add(s.task) + runAll() + } +} + +private object Owner : LifecycleOwner { + override val lifecycle: Lifecycle + get() = throw UnsupportedOperationException() +} + +class AppLifecycleReporterTest { + private val bind = FakeBind() + private val executor = ManualExecutor() + private val reporter = AppLifecycleReporter(bind, executor) {} + + 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 { + executor.runAll() + return bind.calls.toList() + } + + @Test + fun processStartAndStopReportEventsInOrder() { + launch() + stop() + reporter.onStart(Owner) + reporter.onResume(Owner) + assertTrue("nothing reaches Go on the calling thread", bind.calls.isEmpty()) + assertEquals( + listOf( + "willEnterForeground", "didBecomeActive", + "didEnterBackground", + "willEnterForeground", "didBecomeActive", + ), + calls(), + ) + } + + @Test + fun processStopWithWorkStartsTheBackgroundTask() { + launch() + bind.stayRunning = true + stop() + assertEquals( + listOf("willEnterForeground", "didBecomeActive", "didEnterBackground", "beginBackgroundTask"), + calls(), + ) + } + + @Test + fun dialogOrPermissionPromptPauseNeverBackgrounds() { + launch() + reporter.onPause(Owner) + reporter.onResume(Owner) + reporter.onPause(Owner) + assertEquals(listOf("willEnterForeground", "didBecomeActive", "didBecomeActive"), calls()) + assertTrue(executor.scheduled.isEmpty()) + } + + @Test + fun fullScreenPickerStopIsDeferredUntilTheUserReturns() { + launch() + reporter.onExternalActivityLaunched() + stop() + assertEquals(1, executor.scheduled.size) + assertEquals(AppLifecycleReporter.EXTERNAL_ACTIVITY_GRACE_MS, executor.scheduled[0].delayMs) + reporter.onStart(Owner) + reporter.onExternalActivityResult() + reporter.onResume(Owner) + assertTrue(executor.scheduled[0].cancelled) + // The cancel can lose the race with the timer. + executor.fire(executor.scheduled[0]) + assertEquals(listOf("willEnterForeground", "didBecomeActive", "didBecomeActive"), calls()) + } + + @Test + fun pickerLeftOpenBackgroundsAfterTheGracePeriod() { + launch() + reporter.onExternalActivityLaunched() + stop() + executor.fire(executor.scheduled[0]) + executor.fire(executor.scheduled[0]) + reporter.onStart(Owner) + reporter.onResume(Owner) + assertEquals( + listOf( + "willEnterForeground", "didBecomeActive", + "didEnterBackground", + "willEnterForeground", "didBecomeActive", + ), + calls(), + ) + } + + @Test + fun anotherPickerAfterReturningDefersAgain() { + launch() + reporter.onExternalActivityLaunched() + stop() + val first = executor.scheduled[0] + reporter.onStart(Owner) + reporter.onResume(Owner) + reporter.onExternalActivityLaunched() + stop() + executor.fire(first) + assertFalse("a stale timer doesn't end the new deferral", calls().contains("didEnterBackground")) + executor.fire(executor.scheduled[1]) + assertEquals("didEnterBackground", calls().last()) + } + + @Test + fun stopAfterTheResultArrivedIsNotDeferred() { + launch() + reporter.onExternalActivityLaunched() + reporter.onExternalActivityResult() + stop() + assertTrue(executor.scheduled.isEmpty()) + assertEquals(listOf("willEnterForeground", "didBecomeActive", "didEnterBackground"), calls()) + } + + @Test + fun startWithoutUiReportsTheBackgroundOnce() { + reporter.onCreate(Owner) + reporter.reportHeadlessStart() + reporter.reportHeadlessStart() + assertEquals(listOf("didEnterBackground"), calls()) + reporter.onStart(Owner) + reporter.onResume(Owner) + reporter.reportHeadlessStart() + assertEquals(listOf("didEnterBackground", "willEnterForeground", "didBecomeActive"), calls()) + } + + @Test + fun startWithoutUiAfterTheUiReportsNothing() { + reporter.onStart(Owner) + reporter.reportHeadlessStart() + reporter.onResume(Owner) + stop() + reporter.reportHeadlessStart() + assertEquals(listOf("willEnterForeground", "didBecomeActive", "didEnterBackground"), calls()) + } + + @Test + fun awaitReportedWaitsForQueuedEvents() { + val executor = SingleThreadLifecycleExecutor() + val reporter = AppLifecycleReporter(bind, executor) {} + bind.onDidEnterBackground = { Thread.sleep(100) } + reporter.reportHeadlessStart() + reporter.awaitReported(5000) + assertEquals(listOf("didEnterBackground"), bind.calls.toList()) + } + + @Test + fun onlyAFinishingActivityExits() { + launch() + reporter.onMainActivityDestroy(isFinishing = false, isChangingConfigurations = false) + reporter.onMainActivityDestroy(isFinishing = true, isChangingConfigurations = true) + assertEquals(listOf("willEnterForeground", "didBecomeActive"), calls()) + reporter.onMainActivityDestroy(isFinishing = true, isChangingConfigurations = false) + stop() + reporter.onStart(Owner) + reporter.onResume(Owner) + assertEquals( + listOf( + "willEnterForeground", "didBecomeActive", + "willExit", "didEnterBackground", + "willEnterForeground", "didBecomeActive", + ), + calls(), + ) + } + + @Test + fun eventsReachGoInOrderOnOneBackgroundThread() { + val threads = Collections.synchronizedSet(mutableSetOf()) + val record = object : LifecycleBind by bind { + override fun willEnterForeground() { + threads.add(Thread.currentThread()) + bind.willEnterForeground() + } + + override fun didBecomeActive() { + threads.add(Thread.currentThread()) + bind.didBecomeActive() + } + + override fun didEnterBackground(): Boolean { + threads.add(Thread.currentThread()) + // Slow, like the outbox query, so later events queue behind it. + Thread.sleep(5) + return bind.didEnterBackground() + } + } + val ordered = AppLifecycleReporter(record, SingleThreadLifecycleExecutor()) {} + val expected = mutableListOf() + repeat(20) { + ordered.onStart(Owner) + ordered.onResume(Owner) + ordered.onStop(Owner) + expected += listOf("willEnterForeground", "didBecomeActive", "didEnterBackground") + } + ordered.awaitReported(10_000) + assertEquals(expected, bind.calls.toList()) + assertEquals(1, threads.size) + assertFalse(threads.contains(Thread.currentThread())) + } +} + +class RunPushWindowTest { + private val bind = FakeBind() + + private fun run(task: () -> Unit = { bind.calls.add("task") }) = runPushWindow(bind, {}, task) + + @Test + fun foregroundSkipsTheTask() { + bind.token = 0 + run() + assertEquals(listOf("pushWindowBegin"), bind.calls) + } + + @Test + fun notInitializedRunsTheTaskWithoutAWindow() { + bind.token = -1 + run() + assertEquals(listOf("pushWindowBegin", "task"), bind.calls) + } + + @Test + fun windowEndsAfterTheTask() { + run() + assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)"), bind.calls) + } + + @Test + fun windowHandedOverStartsTheBackgroundTask() { + bind.endHandsOver = true + run() + assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)", "beginBackgroundTask"), bind.calls) + } + + @Test + fun windowClosesWhenNoBackgroundTaskCanStart() { + bind.endHandsOver = true + bind.hasContext = false + run() + assertEquals(listOf("pushWindowBegin", "task", "pushWindowClose(7)"), bind.calls) + } + + @Test + fun windowEndsWhenTheTaskThrows() { + try { + run { throw IllegalStateException("boom") } + fail("the task's exception propagates") + } catch (e: IllegalStateException) { + assertEquals("boom", e.message) + } + assertEquals(listOf("pushWindowBegin", "pushWindowEnd(7)"), bind.calls) + } +} From 1c44ba201d852ccff477a7aee5fddd4e4507d4d3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 17:54:45 -0400 Subject: [PATCH 031/127] fix(android): background on full-screen pickers, drop the unused push window close, schedule one sync job --- go/bind/keybase.go | 12 --- go/libkb/lifecycle/lifecycle.go | 7 -- go/libkb/lifecycle/lifecycletest/harness.go | 6 -- go/libkb/lifecycle/lifecycletest/scenarios.go | 36 +++----- .../keybase/ossifrage/AppLifecycleReporter.kt | 86 +++---------------- .../keybase/ossifrage/KeybaseLifecycleBind.kt | 12 +-- .../java/io/keybase/ossifrage/MainActivity.kt | 18 +--- .../io/keybase/ossifrage/MainApplication.kt | 10 ++- .../ossifrage/AppLifecycleReporterTest.kt | 82 +----------------- 9 files changed, 38 insertions(+), 231 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index dfb2049ac71c..7c9dd8e22ad0 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -1058,18 +1058,6 @@ func AppPushWindowEnd(token int64) bool { return kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground) } -// AppPushWindowClose closes the window opened by AppPushWindowBegin, only if -// nothing has updated the app state since, without handing it over to a -// background task. Use it instead of AppPushWindowEnd when no background task -// can be started. -func AppPushWindowClose(token int64) { - if !isInited() { - return - } - defer kbCtx.Trace("AppPushWindowClose", nil)() - kbCtx.MobileLifecycle.PushWindowClose(token) -} - func AppBeginBackgroundTaskNonblock(pusher PushNotifier) { if !isInited() { return diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index e957e7d1f224..7a8651644e67 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -284,13 +284,6 @@ func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) bool { return false } -// PushWindowClose is PushWindowEnd for a caller that can't run a background -// task: it never hands the window over, so nothing is left in -// BACKGROUNDACTIVE waiting on a task that won't start. -func (c *Controller) PushWindowClose(token int64) { - c.PushWindowEnd(token, func() bool { return false }) -} - // BackgroundSync moves BACKGROUND to BACKGROUNDACTIVE for the sync window, // then undoes that transition unless someone else updated the state meanwhile. // It returns a status for native logs. diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 59c7571089ad..c5889b048e07 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -55,9 +55,6 @@ const ( BackgroundTaskExpired PushWindowBegin PushWindowEnd - // PushWindowClose ends the push window when native can't start a - // background task. - PushWindowClose LiveLocationClaim LiveLocationRelease @@ -102,7 +99,6 @@ var actionNames = map[Action]string{ BackgroundTaskExpired: "BackgroundTaskExpired", PushWindowBegin: "PushWindowBegin", PushWindowEnd: "PushWindowEnd", - PushWindowClose: "PushWindowClose", LiveLocationClaim: "LiveLocationClaim", LiveLocationRelease: "LiveLocationRelease", BackgroundSyncStart: "BackgroundSyncStart", @@ -320,8 +316,6 @@ func (h *Harness) perform(step Step) bool { return h.tokens[step.Slot] > 0 case PushWindowEnd: return c.PushWindowEnd(h.tokens[step.Slot], h.stayRunning) - case PushWindowClose: - c.PushWindowClose(h.tokens[step.Slot]) case LiveLocationClaim: c.LiveLocationClaim() case LiveLocationRelease: diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 7d77cdaa5a7a..f226b777aafe 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -439,24 +439,6 @@ var Scenarios = []Scenario{ }), Observed: states(bga, fg, bg, bga, bg), }, - { - // With work pending but no way to start a background task, the window - // closes instead of staying BACKGROUNDACTIVE with no one to end it. - Name: "android push window closes when no background task can start", - Platform: Android, - Steps: steps(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(WorkStarts, bg, 0), - step(PushWindowBegin, bga, 1).returns(true), - step(PushWindowClose, bg, 1).flush(), - step(BackgroundTaskStart, bg, 0).returns(false), - step(PushWindowBegin, bga, 1).returns(true), - step(WillEnterForeground, bga, 1), - step(PushWindowClose, bga, 0), - step(DidBecomeActive, fg, 1), - }), - Observed: states(bga, fg, bg, bga, bg, bga, fg), - }, { Name: "android overlapping push windows", Platform: Android, @@ -470,28 +452,34 @@ var Scenarios = []Scenario{ Observed: states(bga, fg, bg, bga, bg), }, { - // A process started without UI (WorkManager) reports the background - // first, so the sync gets its window and returns to BACKGROUND. - Name: "android WorkManager BackgroundSync at cold start", + // BackgroundSyncWorker doesn't init Go, so it only syncs in a process + // where something else did. After a push (or quick reply) cold start, + // that component already reported the background, so the sync gets its + // window and returns to BACKGROUND. + Name: "android WorkManager BackgroundSync after a push cold start", Platform: Android, Steps: []Step{ step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(PushWindowEnd, bg, 1).flush().returns(false), step(BackgroundSyncStart, bga, 1).returns(true), step(BackgroundSyncTimerFires, bg, 1).flush(), }, - Observed: states(bga, bg, bga, bg), + Observed: states(bga, bg, bga, bg, bga, bg), }, { - Name: "android UI starts during a WorkManager cold start sync", + Name: "android UI starts during a WorkManager sync after a push cold start", Platform: Android, Steps: []Step{ step(DidEnterBackground, bg, 1).flush().returns(false), + step(PushWindowBegin, bga, 1).returns(true), + step(PushWindowEnd, bg, 1).flush().returns(false), step(BackgroundSyncStart, bga, 1).returns(true), step(WillEnterForeground, bga, 1), step(DidBecomeActive, fg, 1), step(BackgroundSyncWait, fg, 0), }, - Observed: states(bga, bg, bga, fg), + Observed: states(bga, bg, bga, bg, bga, fg), }, { Name: "android WorkManager BackgroundSync racing a push window", 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 index 6c80c265adf1..eeed165c3261 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -15,28 +15,17 @@ internal interface LifecycleBind { fun willExit() fun pushWindowBegin(): Long fun pushWindowEnd(token: Long): Boolean - fun pushWindowClose(token: Long) - // False when a background task can't run, e.g. with no context for its - // failure notifications. - fun canBeginBackgroundTask(): Boolean fun beginBackgroundTask() } internal interface LifecycleExecutor { fun submit(task: Runnable): Future<*> - // Returns a function that cancels the task. - fun schedule(delayMs: Long, task: Runnable): () -> Unit } internal class SingleThreadLifecycleExecutor : LifecycleExecutor { - private val executor = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "kb-app-lifecycle") } + private val executor = Executors.newSingleThreadExecutor { r -> Thread(r, "kb-app-lifecycle") } override fun submit(task: Runnable): Future<*> = executor.submit(task) - - override fun schedule(delayMs: Long, task: Runnable): () -> Unit { - val scheduled = executor.schedule(task, delayMs, TimeUnit.MILLISECONDS) - return { scheduled.cancel(false) } - } } // Reports the app's process lifecycle to Go as events; Go decides the state. @@ -45,60 +34,34 @@ internal class SingleThreadLifecycleExecutor : LifecycleExecutor { // didEnterBackground queries the outbox, so it can't run on the main thread. // // Only the process lifecycle counts. Activity pauses (dialogs, permission -// prompts, choosers, the photo picker) report nothing, not even +// prompts, choosers, the photo picker sheet) report nothing, not even // willResignActive: INACTIVE would let a push window open and end in -// BACKGROUND while the app is on screen. +// BACKGROUND while the app is on screen. A full-screen picker or camera stops +// the process like any other exit. internal class AppLifecycleReporter( private val bind: LifecycleBind, private val executor: LifecycleExecutor, private val log: (String) -> Unit, ) : DefaultLifecycleObserver { - private enum class Reported { NOTHING, FOREGROUND, BACKGROUND } - - private var reported = Reported.NOTHING + private var reported = false private var started = false - private var externalActivityPending = false - private var deferredStop = 0L - private var cancelDeferredStop: (() -> Unit)? = null @Synchronized override fun onStart(owner: LifecycleOwner) { started = true - externalActivityPending = false - endDeferredStop() - if (reported != Reported.FOREGROUND) { - enqueue("willEnterForeground") { bind.willEnterForeground() } - } + reported = true + enqueue("willEnterForeground") { bind.willEnterForeground() } } @Synchronized override fun onResume(owner: LifecycleOwner) { - reported = Reported.FOREGROUND enqueue("didBecomeActive") { bind.didBecomeActive() } } @Synchronized override fun onStop(owner: LifecycleOwner) { started = false - if (!externalActivityPending) { - reportBackground("process stop") - return - } - // A full-screen picker, camera or document UI we started for a result - // stops the process, but the user is still using the app. - val token = ++deferredStop - log("AppLifecycleReporter: deferring the background while an activity started for a result is up") - cancelDeferredStop = executor.schedule(EXTERNAL_ACTIVITY_GRACE_MS, Runnable { deferredStopExpired(token) }) - } - - @Synchronized - fun onExternalActivityLaunched() { - externalActivityPending = true - } - - @Synchronized - fun onExternalActivityResult() { - externalActivityPending = false + reportBackground("process stop") } // Activity recreation and a task moved to the back are not an exit. @@ -107,8 +70,7 @@ internal class AppLifecycleReporter( if (!isFinishing || isChangingConfigurations) { return } - endDeferredStop() - reported = Reported.BACKGROUND + reported = true enqueue("willExit") { bind.willExit() } } @@ -116,7 +78,7 @@ internal class AppLifecycleReporter( // nothing to end it; report the background, unless the UI got there first. @Synchronized fun reportHeadlessStart() { - if (reported == Reported.NOTHING && !started) { + if (!reported && !started) { reportBackground("started without UI") } } @@ -130,22 +92,8 @@ internal class AppLifecycleReporter( } } - @Synchronized - private fun deferredStopExpired(token: Long) { - if (token != deferredStop || cancelDeferredStop == null || started) { - return - } - cancelDeferredStop = null - reportBackground("process stop after the external activity grace period") - } - - private fun endDeferredStop() { - cancelDeferredStop?.invoke() - cancelDeferredStop = null - } - private fun reportBackground(why: String) { - reported = Reported.BACKGROUND + reported = true enqueue("didEnterBackground: $why") { if (bind.didEnterBackground()) { bind.beginBackgroundTask() @@ -164,10 +112,6 @@ internal class AppLifecycleReporter( } }) } - - companion object { - const val EXTERNAL_ACTIVITY_GRACE_MS = 2 * 60 * 1000L - } } // Runs task in a push window: Go stays up in BACKGROUNDACTIVE while it runs, @@ -182,12 +126,8 @@ internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, task: () task() } finally { // Negative: Go isn't initialized, so no window opened. - if (token > 0) { - if (!bind.canBeginBackgroundTask()) { - bind.pushWindowClose(token) - } else if (bind.pushWindowEnd(token)) { - bind.beginBackgroundTask() - } + if (token > 0 && bind.pushWindowEnd(token)) { + bind.beginBackgroundTask() } } } 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 index 09f95e6452f0..db315136e7f3 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt @@ -4,24 +4,18 @@ import android.content.Context import android.os.Bundle import keybase.Keybase -internal class KeybaseLifecycleBind(private val context: Context?) : LifecycleBind { +internal class KeybaseLifecycleBind(private val context: Context) : LifecycleBind { override fun willEnterForeground() = Keybase.appWillEnterForeground() override fun didBecomeActive() = Keybase.appDidBecomeActive() override fun didEnterBackground(): Boolean = Keybase.appDidEnterBackground() - override fun willExit() = Keybase.appWillExit(notifier()) + override fun willExit() = Keybase.appWillExit(KBPushNotifier(context, Bundle())) override fun pushWindowBegin(): Long = Keybase.appPushWindowBegin() override fun pushWindowEnd(token: Long): Boolean = Keybase.appPushWindowEnd(token) - override fun pushWindowClose(token: Long) = Keybase.appPushWindowClose(token) - - override fun canBeginBackgroundTask(): Boolean = context != null - - override fun beginBackgroundTask() = Keybase.appBeginBackgroundTaskNonblock(notifier()) - - private fun notifier() = KBPushNotifier(context!!, Bundle()) + override fun beginBackgroundTask() = Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(context, Bundle())) } 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 190beb7e9ba4..3842796e78b5 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 @@ -95,22 +95,6 @@ class MainActivity : ReactActivity() { super.onPause() } - @Deprecated("Deprecated in Java") - override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { - @Suppress("DEPRECATION") - super.startActivityForResult(intent, requestCode, options) - if (requestCode >= 0) { - lifecycleReporter().onExternalActivityLaunched() - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - lifecycleReporter().onExternalActivityResult() - super.onActivityResult(requestCode, resultCode, data) - } - - private fun lifecycleReporter() = (application as MainApplication).lifecycleReporter - private fun getFileNameFromResolver(resolver: ContentResolver, uri: Uri, extension: String?): String { // Use a GUID default. var filename = String.format("%s.%s", UUID.randomUUID().toString(), extension) @@ -179,7 +163,7 @@ class MainActivity : ReactActivity() { override fun onDestroy() { NativeLogger.info("Activity onDestroy") super.onDestroy() - lifecycleReporter().onMainActivityDestroy(isFinishing, isChangingConfigurations) + (application as MainApplication).lifecycleReporter.onMainActivityDestroy(isFinishing, isChangingConfigurations) } private var cachedIntent: Intent? = 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 aac9f1f8976c..6599bf91f533 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,9 @@ import android.content.res.Configuration import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager -import androidx.work.WorkRequest import com.bumptech.glide.Glide import com.facebook.react.PackageList import com.facebook.react.ReactApplication @@ -79,7 +79,7 @@ class MainApplication : Application(), ReactApplication { } }.start() - val backgroundSyncRequest: WorkRequest = PeriodicWorkRequest.Builder( + val backgroundSyncRequest: PeriodicWorkRequest = PeriodicWorkRequest.Builder( BackgroundSyncWorker::class.java, 1, TimeUnit.HOURS, 15, TimeUnit.MINUTES @@ -87,7 +87,7 @@ class MainApplication : Application(), ReactApplication { .build() WorkManager .getInstance(this) - .enqueue(backgroundSyncRequest) + .enqueueUniquePeriodicWork(BACKGROUND_SYNC_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, backgroundSyncRequest) } fun onReactContextInitialized(context: ReactContext?) { @@ -105,4 +105,8 @@ class MainApplication : Application(), ReactApplication { Keybase.forceGC() super.onLowMemory() } + + companion object { + private const val BACKGROUND_SYNC_WORK_NAME = "background_sync" + } } 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 index 7842d13b4c50..0404da0509e6 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -16,7 +16,6 @@ private class FakeBind : LifecycleBind { var stayRunning = false var token = 7L var endHandsOver = false - var hasContext = true var onDidEnterBackground: () -> Unit = {} override fun willEnterForeground() { @@ -47,12 +46,6 @@ private class FakeBind : LifecycleBind { return endHandsOver } - override fun pushWindowClose(token: Long) { - calls.add("pushWindowClose($token)") - } - - override fun canBeginBackgroundTask() = hasContext - override fun beginBackgroundTask() { calls.add("beginBackgroundTask") } @@ -61,11 +54,6 @@ private class FakeBind : LifecycleBind { // Runs nothing until told to, so tests see what was queued and in what order. private class ManualExecutor : LifecycleExecutor { val queue = mutableListOf() - val scheduled = mutableListOf() - - class Scheduled(val delayMs: Long, val task: Runnable) { - var cancelled = false - } override fun submit(task: Runnable): Future<*> { val future = FutureTask(task, Unit) @@ -73,24 +61,11 @@ private class ManualExecutor : LifecycleExecutor { return future } - override fun schedule(delayMs: Long, task: Runnable): () -> Unit { - val s = Scheduled(delayMs, task) - scheduled.add(s) - return { s.cancelled = true } - } - fun runAll() { while (queue.isNotEmpty()) { queue.removeAt(0).run() } } - - // Fires a scheduled task as the executor would, even a cancelled one - // whose cancel lost the race. - fun fire(s: Scheduled) { - queue.add(s.task) - runAll() - } } private object Owner : LifecycleOwner { @@ -154,32 +129,13 @@ class AppLifecycleReporterTest { reporter.onResume(Owner) reporter.onPause(Owner) assertEquals(listOf("willEnterForeground", "didBecomeActive", "didBecomeActive"), calls()) - assertTrue(executor.scheduled.isEmpty()) } + // A full-screen picker or camera stops the process like any other exit. @Test - fun fullScreenPickerStopIsDeferredUntilTheUserReturns() { + fun fullScreenPickerBackgroundsAndReturningForegrounds() { launch() - reporter.onExternalActivityLaunched() stop() - assertEquals(1, executor.scheduled.size) - assertEquals(AppLifecycleReporter.EXTERNAL_ACTIVITY_GRACE_MS, executor.scheduled[0].delayMs) - reporter.onStart(Owner) - reporter.onExternalActivityResult() - reporter.onResume(Owner) - assertTrue(executor.scheduled[0].cancelled) - // The cancel can lose the race with the timer. - executor.fire(executor.scheduled[0]) - assertEquals(listOf("willEnterForeground", "didBecomeActive", "didBecomeActive"), calls()) - } - - @Test - fun pickerLeftOpenBackgroundsAfterTheGracePeriod() { - launch() - reporter.onExternalActivityLaunched() - stop() - executor.fire(executor.scheduled[0]) - executor.fire(executor.scheduled[0]) reporter.onStart(Owner) reporter.onResume(Owner) assertEquals( @@ -192,32 +148,6 @@ class AppLifecycleReporterTest { ) } - @Test - fun anotherPickerAfterReturningDefersAgain() { - launch() - reporter.onExternalActivityLaunched() - stop() - val first = executor.scheduled[0] - reporter.onStart(Owner) - reporter.onResume(Owner) - reporter.onExternalActivityLaunched() - stop() - executor.fire(first) - assertFalse("a stale timer doesn't end the new deferral", calls().contains("didEnterBackground")) - executor.fire(executor.scheduled[1]) - assertEquals("didEnterBackground", calls().last()) - } - - @Test - fun stopAfterTheResultArrivedIsNotDeferred() { - launch() - reporter.onExternalActivityLaunched() - reporter.onExternalActivityResult() - stop() - assertTrue(executor.scheduled.isEmpty()) - assertEquals(listOf("willEnterForeground", "didBecomeActive", "didEnterBackground"), calls()) - } - @Test fun startWithoutUiReportsTheBackgroundOnce() { reporter.onCreate(Owner) @@ -338,14 +268,6 @@ class RunPushWindowTest { assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)", "beginBackgroundTask"), bind.calls) } - @Test - fun windowClosesWhenNoBackgroundTaskCanStart() { - bind.endHandsOver = true - bind.hasContext = false - run() - assertEquals(listOf("pushWindowBegin", "task", "pushWindowClose(7)"), bind.calls) - } - @Test fun windowEndsWhenTheTaskThrows() { try { From 4c6cf81fc5cfc8c9f1b392ca0c7f26d289c92d31 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 17:58:27 -0400 Subject: [PATCH 032/127] fix(android): send quick replies in the foreground and clear duplicate background sync jobs once --- shared/android/app/build.gradle | 2 + .../keybase/ossifrage/AppLifecycleReporter.kt | 31 +++++++-- .../ossifrage/ChatBroadcastReceiver.kt | 17 ++--- .../KeybasePushNotificationListenerService.kt | 4 +- .../io/keybase/ossifrage/MainApplication.kt | 49 +++++++++++--- .../modules/BackgroundSyncSchedule.kt | 29 +++++++++ .../ossifrage/AppLifecycleReporterTest.kt | 41 ++++++++++-- .../modules/BackgroundSyncScheduleTest.kt | 64 +++++++++++++++++++ 8 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 shared/android/app/src/main/java/io/keybase/ossifrage/modules/BackgroundSyncSchedule.kt create mode 100644 shared/android/app/src/test/java/io/keybase/ossifrage/modules/BackgroundSyncScheduleTest.kt diff --git a/shared/android/app/build.gradle b/shared/android/app/build.gradle index e205353b3d61..3dc2bc0fa645 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" + // Operation.getResult() returns a ListenableFuture. Keep in sync with the guava version on the runtime classpath. + compileOnly "com.google.guava:guava:33.3.1-android" testImplementation "junit:junit:4.13.2" } 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 index eeed165c3261..5a9db839ee2a 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -114,13 +114,20 @@ internal class AppLifecycleReporter( } } -// Runs task in a push window: Go stays up in BACKGROUNDACTIVE while it runs, -// unless the app is in the foreground, where the task is skipped. -internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, task: () -> Unit) { +internal enum class InForeground { SKIP, RUN } + +// Runs task in a push window: Go stays up in BACKGROUNDACTIVE while it runs. +// When the app is in the foreground no window opens (Go is already up), and +// the task runs or is skipped per inForeground. Returns whether it ran. +internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, inForeground: InForeground, task: () -> Unit): Boolean { val token = bind.pushWindowBegin() if (token == 0L) { - log("runPushWindow: app is in the foreground, skipping") - return + if (inForeground == InForeground.SKIP) { + log("runPushWindow: app is in the foreground, skipping") + return false + } + task() + return true } try { task() @@ -130,4 +137,18 @@ internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, task: () bind.beginBackgroundTask() } } + return true } + +// Sends a notification quick reply, which must go out even with the app in +// the foreground. Returns the text for the replied notification. +internal fun sendQuickReply(bind: LifecycleBind, log: (String) -> Unit, send: () -> Unit): String = + try { + if (runPushWindow(bind, log, InForeground.RUN, send)) QUICK_REPLY_SENT else QUICK_REPLY_FAILED + } catch (e: Exception) { + log("sendQuickReply: failed to send: $e") + QUICK_REPLY_FAILED + } + +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 b618e249f6af..9916378477c5 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 @@ -29,18 +29,13 @@ class ChatBroadcastReceiver : BroadcastReceiver() { val notificationManager = NotificationManagerCompat.from(context) val messageBody = getMessageText(intent) if (messageBody != null) { - try { - val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter - lifecycleReporter.reportHeadlessStart() - lifecycleReporter.awaitReported(5000) - runPushWindow(KeybaseLifecycleBind(context), { NativeLogger.info(it) }) { - Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody) - } - repliedNotification.setContentText("Replied") - } catch (e: Exception) { - repliedNotification.setContentText("Couldn't send reply") - NativeLogger.error("Failed to send quick reply", e) + val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter + lifecycleReporter.reportHeadlessStart() + lifecycleReporter.awaitReported(5000) + val status = sendQuickReply(KeybaseLifecycleBind(context), { NativeLogger.error(it) }) { + Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody) } + repliedNotification.setContentText(status) } else { repliedNotification.setContentText("Couldn't send reply - Failed to read input.") NativeLogger.error("Message Body in quick reply was null") 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 23cb48f9bd17..5bf92b04cd1b 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 @@ -122,7 +122,9 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { // The push window must see the state after the process // start or stop that came before this push. lifecycleReporter.awaitReported(5000) - runPushWindow(KeybaseLifecycleBind(applicationContext), { NativeLogger.info(it) }) { + // In the foreground the app already has the message, and + // must not show a notification for it. + runPushWindow(KeybaseLifecycleBind(applicationContext), { NativeLogger.info(it) }, InForeground.SKIP) { try { Keybase.handleBackgroundNotification(n.convID, payload, n.serverMessageBody, n.sender, n.membersType.toLong(), n.displayPlaintext, n.messageId.toLong(), n.pushId, 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 6599bf91f533..fbb468cda717 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 @@ -22,8 +22,11 @@ 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 java.util.concurrent.TimeUnit @@ -79,15 +82,13 @@ class MainApplication : Application(), ReactApplication { } }.start() - val backgroundSyncRequest: PeriodicWorkRequest = PeriodicWorkRequest.Builder( - BackgroundSyncWorker::class.java, - 1, TimeUnit.HOURS, - 15, TimeUnit.MINUTES - ) - .build() - WorkManager - .getInstance(this) - .enqueueUniquePeriodicWork(BACKGROUND_SYNC_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, backgroundSyncRequest) + Thread { + try { + scheduleBackgroundSync(WorkManagerBackgroundSyncJobs(this), SharedPrefsCleanupFlag(this)) + } catch (e: Exception) { + NativeLogger.warn("MainApplication: error scheduling background sync", e) + } + }.start() } fun onReactContextInitialized(context: ReactContext?) { @@ -105,8 +106,36 @@ class MainApplication : Application(), ReactApplication { Keybase.forceGC() 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).result.get() + } + + override fun enqueueUnique() { + val request = PeriodicWorkRequest.Builder( + BackgroundSyncWorker::class.java, + 1, TimeUnit.HOURS, + 15, TimeUnit.MINUTES + ).build() + workManager.enqueueUniquePeriodicWork("background_sync", ExistingPeriodicWorkPolicy.KEEP, request).result.get() + } +} + +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 BACKGROUND_SYNC_WORK_NAME = "background_sync" + private const val KEY = "legacy_jobs_cancelled" } } 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 index 0404da0509e6..3947ae57e6dd 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -239,25 +239,33 @@ class AppLifecycleReporterTest { class RunPushWindowTest { private val bind = FakeBind() - private fun run(task: () -> Unit = { bind.calls.add("task") }) = runPushWindow(bind, {}, task) + private fun run(inForeground: InForeground = InForeground.SKIP, task: () -> Unit = { bind.calls.add("task") }) = + runPushWindow(bind, {}, inForeground, task) @Test fun foregroundSkipsTheTask() { bind.token = 0 - run() + assertFalse(run()) assertEquals(listOf("pushWindowBegin"), bind.calls) } + @Test + fun foregroundRunsATaskThatMustRunWithoutAWindow() { + bind.token = 0 + assertTrue(run(InForeground.RUN)) + assertEquals(listOf("pushWindowBegin", "task"), bind.calls) + } + @Test fun notInitializedRunsTheTaskWithoutAWindow() { bind.token = -1 - run() + assertTrue(run()) assertEquals(listOf("pushWindowBegin", "task"), bind.calls) } @Test fun windowEndsAfterTheTask() { - run() + assertTrue(run()) assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)"), bind.calls) } @@ -279,3 +287,28 @@ class RunPushWindowTest { assertEquals(listOf("pushWindowBegin", "pushWindowEnd(7)"), bind.calls) } } + +class SendQuickReplyTest { + private val bind = FakeBind() + + private fun send(send: () -> Unit = { bind.calls.add("send") }) = sendQuickReply(bind, {}, send) + + @Test + fun foregroundReplySends() { + bind.token = 0 + assertEquals(QUICK_REPLY_SENT, send()) + assertEquals(listOf("pushWindowBegin", "send"), bind.calls) + } + + @Test + fun backgroundReplySendsInAWindow() { + assertEquals(QUICK_REPLY_SENT, send()) + assertEquals(listOf("pushWindowBegin", "send", "pushWindowEnd(7)"), bind.calls) + } + + @Test + fun failedReplyIsNotReportedAsReplied() { + assertEquals(QUICK_REPLY_FAILED, send { throw IllegalStateException("offline") }) + assertEquals(listOf("pushWindowBegin", "pushWindowEnd(7)"), bind.calls) + } +} 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) + } +} From 6d730c4e2eaf1652f7485d25d782dd5e56d8bdc1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 18:16:31 -0400 Subject: [PATCH 033/127] fix(mobile): report quick reply send failures and run the reply off the main thread --- go/bind/notifications.go | 36 ++++--- go/bind/notifications_test.go | 100 ++++++++++++++++++ shared/android/app/build.gradle | 2 - .../keybase/ossifrage/AppLifecycleReporter.kt | 45 +++++++- .../ossifrage/ChatBroadcastReceiver.kt | 40 ++++--- .../io/keybase/ossifrage/MainApplication.kt | 23 +++- .../ossifrage/AppLifecycleReporterTest.kt | 73 ++++++++++++- 7 files changed, 277 insertions(+), 42 deletions(-) create mode 100644 go/bind/notifications_test.go diff --git a/go/bind/notifications.go b/go/bind/notifications.go index f3ca13136805..3298c263805a 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -118,34 +118,40 @@ func HandlePostTextReply(strConvID, tlfName string, intMessageID int, body strin ctx := context.Background() defer kbCtx.CTrace(ctx, "HandlePostTextReply", &err)() defer func() { err = flattenError(err) }() - outboxID, err := storage.NewOutboxID() + 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 } diff --git a/go/bind/notifications_test.go b/go/bind/notifications_test.go new file mode 100644 index 000000000000..83b5a07679e9 --- /dev/null +++ b/go/bind/notifications_test.go @@ -0,0 +1,100 @@ +package keybase + +import ( + "context" + "errors" + "testing" + + "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/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) + }) +} diff --git a/shared/android/app/build.gradle b/shared/android/app/build.gradle index 3dc2bc0fa645..e205353b3d61 100644 --- a/shared/android/app/build.gradle +++ b/shared/android/app/build.gradle @@ -171,8 +171,6 @@ 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" - // Operation.getResult() returns a ListenableFuture. Keep in sync with the guava version on the runtime classpath. - compileOnly "com.google.guava:guava:33.3.1-android" testImplementation "junit:junit:4.13.2" } 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 index 5a9db839ee2a..f128dd12d9c9 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -2,6 +2,7 @@ package io.keybase.ossifrage import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.TimeUnit @@ -142,13 +143,51 @@ internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, inForegro // Sends a notification quick reply, which must go out even with the app in // the foreground. Returns the text for the replied notification. -internal fun sendQuickReply(bind: LifecycleBind, log: (String) -> Unit, send: () -> Unit): String = +internal fun sendQuickReply( + bind: LifecycleBind, + info: (String) -> Unit, + error: (String, Throwable) -> Unit, + send: () -> Unit, +): String = try { - if (runPushWindow(bind, log, InForeground.RUN, send)) QUICK_REPLY_SENT else QUICK_REPLY_FAILED + if (runPushWindow(bind, info, InForeground.RUN, send)) QUICK_REPLY_SENT else QUICK_REPLY_FAILED } catch (e: Exception) { - log("sendQuickReply: failed to send: $e") + 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 9916378477c5..6618f078a6f6 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,32 +19,38 @@ 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) { - val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter - lifecycleReporter.reportHeadlessStart() - lifecycleReporter.awaitReported(5000) - val status = sendQuickReply(KeybaseLifecycleBind(context), { NativeLogger.error(it) }) { - 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) + val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter + lifecycleReporter.reportHeadlessStart() + lifecycleReporter.awaitReported(2000) + sendQuickReply(KeybaseLifecycleBind(context), { NativeLogger.info(it) }, { msg, e -> NativeLogger.error(msg, e) }) { + Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody) + } } - repliedNotification.setContentText(status) - } 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/MainApplication.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt index fbb468cda717..81f356a8ad7c 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 @@ -7,8 +7,10 @@ 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.await import com.bumptech.glide.Glide import com.facebook.react.PackageList import com.facebook.react.ReactApplication @@ -28,6 +30,9 @@ 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?) : @@ -113,7 +118,7 @@ private class WorkManagerBackgroundSyncJobs(context: Context) : BackgroundSyncJo // WorkManager tags every request with its worker's class name. override fun cancelAll() { - workManager.cancelAllWorkByTag(BackgroundSyncWorker::class.java.name).result.get() + workManager.cancelAllWorkByTag(BackgroundSyncWorker::class.java.name).awaitDone("cancel") } override fun enqueueUnique() { @@ -122,7 +127,21 @@ private class WorkManagerBackgroundSyncJobs(context: Context) : BackgroundSyncJo 1, TimeUnit.HOURS, 15, TimeUnit.MINUTES ).build() - workManager.enqueueUniquePeriodicWork("background_sync", ExistingPeriodicWorkPolicy.KEEP, request).result.get() + 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 } } 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 index 3947ae57e6dd..068226f5b3b8 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -3,8 +3,12 @@ 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.Future import java.util.concurrent.FutureTask +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.assertFalse import org.junit.Assert.assertTrue @@ -290,14 +294,18 @@ class RunPushWindowTest { class SendQuickReplyTest { private val bind = FakeBind() + private val infos = mutableListOf() + private val errors = mutableListOf>() - private fun send(send: () -> Unit = { bind.calls.add("send") }) = sendQuickReply(bind, {}, send) + private fun send(send: () -> Unit = { bind.calls.add("send") }) = + sendQuickReply(bind, { infos.add(it) }, { msg, e -> errors.add(msg to e) }, send) @Test fun foregroundReplySends() { bind.token = 0 assertEquals(QUICK_REPLY_SENT, send()) assertEquals(listOf("pushWindowBegin", "send"), bind.calls) + assertTrue(errors.isEmpty()) } @Test @@ -307,8 +315,67 @@ class SendQuickReplyTest { } @Test - fun failedReplyIsNotReportedAsReplied() { - assertEquals(QUICK_REPLY_FAILED, send { throw IllegalStateException("offline") }) + fun failedReplyIsNotReportedAsRepliedAndLogsTheException() { + val failure = IllegalStateException("outbox full") + assertEquals(QUICK_REPLY_FAILED, send { throw failure }) assertEquals(listOf("pushWindowBegin", "pushWindowEnd(7)"), bind.calls) + assertEquals(listOf("Failed to send quick reply" to failure), errors.toList()) + assertTrue(infos.isEmpty()) + } +} + +class RunReceiverWorkTest { + private val finishes = AtomicInteger() + private val finished = CountDownLatch(1) + private val warnings = Collections.synchronizedList(mutableListOf()) + private val errors = Collections.synchronizedList(mutableListOf()) + private val threads = Collections.synchronizedSet(mutableSetOf()) + + private fun run(budgetMs: Long, work: () -> Unit) = runReceiverWork( + budgetMs, + { r -> Thread { threads.add(Thread.currentThread()); r.run() }.start() }, + { warnings.add(it) }, + { _, e -> errors.add(e) }, + { + finishes.incrementAndGet() + finished.countDown() + }, + work, + ) + + @Test + 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 + 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 + fun finishesAtTheBudgetWhileTheWorkIsStillRunning() { + val release = CountDownLatch(1) + val workDone = CountDownLatch(1) + run(100) { + release.await() + 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()) } } From 97e83b75205883a5e85bf449dcf0e5b31c049ad1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 18:18:36 -0400 Subject: [PATCH 034/127] test(android): bound the receiver work tests so a caller-thread regression fails instead of hanging --- .../io/keybase/ossifrage/AppLifecycleReporterTest.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 index 068226f5b3b8..ad50c02fefe1 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -329,11 +329,10 @@ class RunReceiverWorkTest { private val finished = CountDownLatch(1) private val warnings = Collections.synchronizedList(mutableListOf()) private val errors = Collections.synchronizedList(mutableListOf()) - private val threads = Collections.synchronizedSet(mutableSetOf()) private fun run(budgetMs: Long, work: () -> Unit) = runReceiverWork( budgetMs, - { r -> Thread { threads.add(Thread.currentThread()); r.run() }.start() }, + { r -> Thread(r).start() }, { warnings.add(it) }, { _, e -> errors.add(e) }, { @@ -343,7 +342,7 @@ class RunReceiverWorkTest { work, ) - @Test + @Test(timeout = 10_000) fun finishesAfterTheWorkOffTheCallingThread() { val ranOn = AtomicReference() run(10_000) { ranOn.set(Thread.currentThread()) } @@ -354,7 +353,7 @@ class RunReceiverWorkTest { assertTrue(warnings.isEmpty()) } - @Test + @Test(timeout = 10_000) fun finishesAndLogsWhenTheWorkThrows() { val failure = IllegalStateException("boom") run(10_000) { throw failure } @@ -363,12 +362,12 @@ class RunReceiverWorkTest { assertEquals(1, finishes.get()) } - @Test + @Test(timeout = 10_000) fun finishesAtTheBudgetWhileTheWorkIsStillRunning() { val release = CountDownLatch(1) val workDone = CountDownLatch(1) run(100) { - release.await() + release.await(5, TimeUnit.SECONDS) workDone.countDown() } assertTrue(finished.await(5, TimeUnit.SECONDS)) From f5f6d59e3512a87ded6e42f70c5a67173c87a35a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 22:03:51 -0400 Subject: [PATCH 035/127] test(ios): add simulator lifecycle e2e flows for app state, deep links, push taps and native live location The flows validate with app state and logs only: JS state through Metro's inspector, Go transitions from the app's ios.log, JS log lines from Metro's start.log, native location logs from the simulator's unified log, and HTTP status codes fetched from the app's local server. --- shared/package.json | 1 + .../flows/lifecycle-app-state.test.ts | 171 ++++++ .../flows/lifecycle-links-push.test.ts | 179 ++++++ .../flows/lifecycle-location.test.ts | 193 +++++++ .../tests/e2e/ios-appium/helpers/lifecycle.ts | 534 ++++++++++++++++++ shared/tests/e2e/ios-appium/lifecycle.test.ts | 6 + .../e2e/ios-appium/wdio.lifecycle.conf.ts | 57 ++ shared/tests/e2e/run-ios-lifecycle.sh | 39 ++ 8 files changed, 1180 insertions(+) create mode 100644 shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts create mode 100644 shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts create mode 100644 shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts create mode 100644 shared/tests/e2e/ios-appium/helpers/lifecycle.ts create mode 100644 shared/tests/e2e/ios-appium/lifecycle.test.ts create mode 100644 shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts create mode 100644 shared/tests/e2e/run-ios-lifecycle.sh diff --git a/shared/package.json b/shared/package.json index 81d76e25e73f..b4568c4ca2ed 100644 --- a/shared/package.json +++ b/shared/package.json @@ -76,6 +76,7 @@ "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", 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..415e95c1259d --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -0,0 +1,171 @@ +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 lifecycle event, +// "MobileAppState.Update: useful update: " per Go app state change, +// "Srv: startHTTPSrv: addr:
" when the image server (re)starts. +// - Metro (JS): "app focus changed: " when the shell store's app state changes. +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: willEnterForeground: /, + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: didBecomeActive: /, + ]) + expect(goLines).toHaveLength(3) + // JS must hold the address of the server Go started, not a stale one. + const started = findLines(goLogSince(goMark), /Srv: startHTTPSrv: addr: /).at(-1) ?? '' + expect(started).toContain(`addr: ${snap.httpSrv.address} `) + + 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: willResignActive: /, + /lifecycle: didEnterBackground: /, + ]) + + 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: didEnterBackground: /, + /lifecycle: willEnterForeground: /, + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: didBecomeActive: /, + ]) + 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 { + await sender.stop() + } + }) + + 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: didEnterBackground: /).length + const actives = findLines(lines, /lifecycle: didBecomeActive: /).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.includes('app focus changed: background'))).toBe(true) + expect(focus.at(-1)).toContain('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: willResignActive: /, + ]) + // 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: didEnterBackground: /)).toEqual([]) + + await closeNotificationCenter() + await waitForAppState('active', undefined, 15000) + await waitForLinesInOrder('Go to become active again', () => goLogSince(goMark), [ + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: didBecomeActive: /, + ]) + const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) + expect(focus).toEqual([expect.stringContaining('inactive'), expect.stringContaining('active')]) + expect(findLines(goLogSince(goMark), /Srv: startHTTPSrv: addr: /)).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..1d2201daf03a --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -0,0 +1,179 @@ +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, + 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: didEnterBackground: /]) + +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; "[onNotification]: " for each push JS receives, whose payload +// carries native's "userInteraction"; "[Push] handleLoudMessage: ignore non userInteraction" +// when JS declines to navigate for an untapped push. +// - Go (ios.log): "lifecycle: didEnterBackground: " 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 metroMark0 = metroLogMark() + const convID = await openSelfConversation(user) + // Leave on People and background once, so the route the app saves and would restore on + // launch is not the conversation the link opens. + await escapeToTabs() + await navigateToPeople() + const goMark = goLogMark() + await backgroundApp() + await waitForBackground(goMark) + await waitForLinesInOrder('JS to see the background', () => metroClientLogSince(metroMark0), [/app focus changed: background/]) + await terminateApp() + 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) + }) +}) + +describe('app lifecycle: push notifications', () => { + let convID = '' + const pushFor = (body: string) => ({ + aps: {alert: {body, title: 'e2e'}, sound: 'default'}, + convID, + m: '', + t: '1', + type: 'chat.newmessage', + }) + // The payload JS logs for a push, found by its unique body. + const jsPushes = (lines: Array, body: string) => findLines(lines, /\[onNotification\]/).filter(l => l.includes(body)) + + before(async () => { + const user = requireSmokeUser() + await waitForAppState('active') + await ensureNotificationPermission() + convID = await openSelfConversation(user) + }) + + 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)) + + const [delivered] = await waitForLinesInOrder('JS to receive the push', () => jsPushes(metroClientLogSince(metroMark), body), [ + /\[onNotification\]/, + ]) + expect(delivered).toContain('"userInteraction": false') + await waitForLinesInOrder('JS to decline to navigate', () => metroClientLogSince(metroMark), [ + /\[Push\] handleLoudMessage: ignore non userInteraction/, + ]) + await browser.pause(3000) + 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(jsPushes(metroClientLogSince(metroMark), body)).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 [delivered] = jsPushes(metroClientLogSince(metroMark), body) + expect(delivered).toContain('"userInteraction": true') + }) + + it('tapping a push while the app is not running launches into its conversation', async () => { + await waitForAppState('active') + await navigateToPeople() + await terminateApp() + 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) + expect(findLines(metroClientLogSince(metroMark), /\[Push\] handleLoudMessage: ignore non userInteraction/)).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..4000aa01add0 --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -0,0 +1,193 @@ +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, + 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 native hands to Go, +// - "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: liveLocationClaim: " when a fix keeps a backgrounded app's work running. +// 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 a backgrounded watcher, which waits for real movement, reports 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: didEnterBackground: /, + ]) + // 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(goMark), /lifecycle: willEnterForeground: /)).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. + this.timeout(420000) + await terminateApp() + const goMark = goLogMark() + 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: liveLocationClaim: /).length).toBeGreaterThan(0) + expect(findLines(relaunched, /lifecycle: willEnterForeground: /)).toEqual([]) + expect(appPid()).toBe(pid) + }) + + 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/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts new file mode 100644 index 000000000000..6797dac24eba --- /dev/null +++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts @@ -0,0 +1,534 @@ +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 {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) + } +} + +// -- 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 app written after `since`. The simulator writes them to the host's +// DiagnosticReports, named after the executable. +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 [] + } + return names + .filter(n => /^Keybase[-_].*\.(ips|crash)$/.test(n)) + .map(n => path.join(dir, n)) + .filter(p => fs.statSync(p).mtimeMs >= since) +} + +// -- 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 + nativeAppState: string + httpSrv: {address: string; token: string} + screen?: {name?: string; params?: Record} +} + +// JS app state (shell store, fed by native scene notifications), the native value it +// was fed from, 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 kb = kbModule('node_modules/react-native-kb/src/index.tsx') + const screen = kbModule('constants/router.tsx').getVisibleScreen() + return { + httpSrv: config.httpSrv, + loggedIn: config.loggedIn, + mobileAppState: shell.mobileAppState, + nativeAppState: kb.iosGetAppState(), + screen: screen ? {name: screen.name, params: screen.params} : undefined, + }`, + device + ) + +// Waits for a relaunched JS runtime to be logged in and report `state` from both JS and native. +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.nativeAppState === 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 statOrUndefined = (file: string) => { + try { + return fs.statSync(file) + } catch { + return undefined + } +} + +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) + +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} + ) + 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) + if (bootedHere) { + simctl('boot', udid) + simctl('bootstatus', udid, '-b') + } + const stop = async () => { + await terminateApp(udid).catch(() => {}) + if (bootedHere) simctl('shutdown', udid) + } + 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} + ).catch(async (e: unknown) => { + await stop() + 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/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..d62d42d9fc92 --- /dev/null +++ b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs' +import * as path from 'path' +import {config as base} from './wdio.conf' +import {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'], + // Xcode 27 has no Simulator.app for the driver to open (its window is DeviceHub now), and + // the driver fails the session when it can't. The runner boots the simulator and shows it. + // 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:isHeadless': true, + '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: 'keybase.ios'})) !== foreground) { + await browser.execute('mobile: activateApp', {bundleId: 'keybase.ios'}) + } + // 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-lifecycle.sh b/shared/tests/e2e/run-ios-lifecycle.sh new file mode 100644 index 000000000000..609853a86338 --- /dev/null +++ b/shared/tests/e2e/run-ios-lifecycle.sh @@ -0,0 +1,39 @@ +#!/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-. +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]}" From a472d1f7c46d0324fcd907a2a33a76aa833bf9ca Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 22:12:35 -0400 Subject: [PATCH 036/127] test(ios): let the Appium harness start sessions under Xcode 27 Xcode 27 has no Simulator.app, and the xcuitest driver fails session creation when it cannot open it. Sessions no longer open the simulator window, and the runners fall back to DeviceHub to show it. --- shared/tests/e2e/ios-appium/helpers/app.ts | 4 ++++ shared/tests/e2e/run-ios-appium-parallel.sh | 3 ++- shared/tests/e2e/run-ios-appium.sh | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/shared/tests/e2e/ios-appium/helpers/app.ts b/shared/tests/e2e/ios-appium/helpers/app.ts index b25966673e75..4ec51e162e80 100644 --- a/shared/tests/e2e/ios-appium/helpers/app.ts +++ b/shared/tests/e2e/ios-appium/helpers/app.ts @@ -85,6 +85,10 @@ export function iosCapabilities(udid: string, opts: IosCapsOpts = {}) { 'appium:bundleId': 'keybase.ios', 'appium:noReset': true, 'appium:newCommandTimeout': 120, + // Never let the driver open the simulator window itself: Xcode 27 has no Simulator.app (its + // window is DeviceHub.app), and the driver fails session creation when it can't open it. + // The runners boot the simulator and bring its window up. + '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/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 From 5d4af14e219e077c6655fbe3ed56d616493e1dfc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 22:31:14 -0400 Subject: [PATCH 037/127] test(ios): harden the lifecycle e2e flows and type-check the e2e suite in lint:all The cold push tap test backgrounds from People before terminating and checks the push chose the startup conversation, so a restored route can't pass it. Focus log checks match exactly, the sender simulator is cleaned up on every failure, crash reports are matched by bundle id, and the runner documents what it changes on the simulators. lint ignores Gradle build output and tsc now covers the e2e tsconfig. --- shared/eslint.config.mjs | 1 + shared/package.json | 2 +- .../flows/lifecycle-app-state.test.ts | 15 +++- .../flows/lifecycle-links-push.test.ts | 34 +++++--- .../tests/e2e/ios-appium/helpers/lifecycle.ts | 78 +++++++++++-------- .../e2e/ios-appium/wdio.lifecycle.conf.ts | 9 +-- shared/tests/e2e/run-ios-lifecycle.sh | 10 +++ 7 files changed, 95 insertions(+), 54 deletions(-) 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/package.json b/shared/package.json index b4568c4ca2ed..d32996bb1ab5 100644 --- a/shared/package.json +++ b/shared/package.json @@ -82,7 +82,7 @@ "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/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts index 415e95c1259d..bd2d1eb96890 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -94,7 +94,11 @@ describe('app lifecycle: app state', () => { expect(appPid()).toBe(pid) expect(crashReportsSince(since)).toEqual([]) } finally { - await sender.stop() + // 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)}`) + }) } }) @@ -131,8 +135,8 @@ describe('app lifecycle: app state', () => { ) // JS saw the app go away and come back, ending active. const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) - expect(focus.some(l => l.includes('app focus changed: background'))).toBe(true) - expect(focus.at(-1)).toContain('app focus changed: active') + 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) @@ -164,7 +168,10 @@ describe('app lifecycle: app state', () => { /lifecycle: didBecomeActive: /, ]) const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) - expect(focus).toEqual([expect.stringContaining('inactive'), expect.stringContaining('active')]) + expect(focus).toEqual([ + expect.stringMatching(/app focus changed: inactive$/), + expect.stringMatching(/app focus changed: active$/), + ]) expect(findLines(goLogSince(goMark), /Srv: startHTTPSrv: addr: /)).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 index 1d2201daf03a..7103d2529aa2 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -41,6 +41,23 @@ const waitForScreen = async (what: string, match: (s: Awaited) => waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [/lifecycle: didEnterBackground: /]) +// 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 @@ -67,17 +84,8 @@ describe('app lifecycle: deep links', () => { it('opens a deep link that launches the app', async () => { const user = requireSmokeUser() await waitForAppState('active') - const metroMark0 = metroLogMark() const convID = await openSelfConversation(user) - // Leave on People and background once, so the route the app saves and would restore on - // launch is not the conversation the link opens. - await escapeToTabs() - await navigateToPeople() - const goMark = goLogMark() - await backgroundApp() - await waitForBackground(goMark) - await waitForLinesInOrder('JS to see the background', () => metroClientLogSince(metroMark0), [/app focus changed: background/]) - await terminateApp() + await terminateFromPeople() const metroMark = metroLogMark() const url = `keybase://convid/${convID}` openUrl(url) @@ -165,8 +173,7 @@ describe('app lifecycle: push notifications', () => { it('tapping a push while the app is not running launches into its conversation', async () => { await waitForAppState('active') - await navigateToPeople() - await terminateApp() + await terminateFromPeople() const metroMark = metroLogMark() const body = `e2e-push-cold-${Date.now()}` sendPush(pushFor(body)) @@ -174,6 +181,9 @@ describe('app lifecycle: push notifications', () => { await waitForAppState('active', undefined, 90000) await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) + // The tap reaches JS as the initial notification and picks the startup conversation. + const startup = findLines(metroClientLogSince(metroMark), /initialState: push /) + expect(startup).toEqual([expect.stringContaining(`initialState: push ${convID}`)]) expect(findLines(metroClientLogSince(metroMark), /\[Push\] handleLoudMessage: ignore non userInteraction/)).toEqual([]) }) }) diff --git a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts index 6797dac24eba..d9ad8388a57d 100644 --- a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts +++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts @@ -45,6 +45,14 @@ export const waitFor = async ( } } +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. @@ -66,8 +74,9 @@ export const terminateApp = async (udid = deviceUdid()) => { await waitFor('the app to exit', () => (appPid(udid) === undefined ? true : undefined), {timeout: 10000}) } -// Crash reports for the app written after `since`. The simulator writes them to the host's -// DiagnosticReports, named after the executable. +// 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 @@ -76,10 +85,18 @@ export const crashReportsSince = (since: number): Array => { } 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 => /^Keybase[-_].*\.(ips|crash)$/.test(n)) + .filter(n => n.endsWith('.ips')) .map(n => path.join(dir, n)) - .filter(p => fs.statSync(p).mtimeMs >= since) + .filter(p => (statOrUndefined(p)?.mtimeMs ?? 0) >= since && bundleOf(p) === BUNDLE_ID) } // -- Metro inspector ---------------------------------------------------------- @@ -211,14 +228,6 @@ export const waitForAvatar200 = async (username: string, device = deviceName(), type LogMark = {file: string; offset: number; ino: number} -const statOrUndefined = (file: string) => { - try { - return fs.statSync(file) - } catch { - return undefined - } -} - const readFrom = (file: string, offset: number) => { const size = fs.statSync(file).size if (size <= offset) return '' @@ -479,30 +488,37 @@ export const startSenderDevice = async (username: string) => { const name = senderDeviceName() const udid = udidForName(name) const bootedHere = !isBooted(udid) - if (bootedHere) { - simctl('boot', udid) - simctl('bootstatus', udid, '-b') - } const stop = async () => { await terminateApp(udid).catch(() => {}) if (bootedHere) simctl('shutdown', udid) } - 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} - ).catch(async (e: unknown) => { - await stop() + // 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`, diff --git a/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts index d62d42d9fc92..bc98e388846b 100644 --- a/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts +++ b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts @@ -1,7 +1,7 @@ import * as fs from 'fs' import * as path from 'path' import {config as base} from './wdio.conf' -import {waitForAppState} from './helpers/lifecycle' +import {BUNDLE_ID, waitForAppState} from './helpers/lifecycle' import {escapeToTabs} from './helpers/navigate' // App lifecycle flows (launch, background, deep links, push, live location). They @@ -12,13 +12,10 @@ const debugDir = process.env['KB_IOS_APPIUM_DEBUG_DIR'] ?? 'tests/results/ios-ap export const config: WebdriverIO.Config = { ...base, specs: [process.env['KB_IOS_SPEC'] ?? './lifecycle.test.ts'], - // Xcode 27 has no Simulator.app for the driver to open (its window is DeviceHub now), and - // the driver fails the session when it can't. The runner boots the simulator and shows it. // 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:isHeadless': true, 'appium:newCommandTimeout': 900, })), // A lifecycle regression is often intermittent, so a retry would hide exactly what @@ -30,8 +27,8 @@ export const config: WebdriverIO.Config = { // eslint-disable-next-line no-console console.log(`▶ ${new Date().toLocaleTimeString()} starting: ${test.title}`) const foreground = 4 - if ((await browser.execute('mobile: queryAppState', {bundleId: 'keybase.ios'})) !== foreground) { - await browser.execute('mobile: activateApp', {bundleId: 'keybase.ios'}) + 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) diff --git a/shared/tests/e2e/run-ios-lifecycle.sh b/shared/tests/e2e/run-ios-lifecycle.sh index 609853a86338..f9ba46d27f7d 100644 --- a/shared/tests/e2e/run-ios-lifecycle.sh +++ b/shared/tests/e2e/run-ios-lifecycle.sh @@ -9,6 +9,16 @@ # 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)" From df591081dbdeeae8127b94c6b221de81f36ea020 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 22:53:09 -0400 Subject: [PATCH 038/127] test(ios): use headless Appium sessions only where Simulator.app is missing In headless mode the xcuitest driver kills a running Simulator.app window and reboots the device without one. Xcode 27 has no Simulator.app (DeviceHub shows simulators), and there the driver needs headless mode to accept the booted simulator; it leaves DeviceHub running. --- shared/tests/e2e/ios-appium/helpers/app.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/shared/tests/e2e/ios-appium/helpers/app.ts b/shared/tests/e2e/ios-appium/helpers/app.ts index 4ec51e162e80..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,10 +102,7 @@ export function iosCapabilities(udid: string, opts: IosCapsOpts = {}) { 'appium:bundleId': 'keybase.ios', 'appium:noReset': true, 'appium:newCommandTimeout': 120, - // Never let the driver open the simulator window itself: Xcode 27 has no Simulator.app (its - // window is DeviceHub.app), and the driver fails session creation when it can't open it. - // The runners boot the simulator and bring its window up. - 'appium:isHeadless': true, + ...(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, From 88365d8106f174eb842a48eaee3daefcbc59453c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 09:00:59 -0400 Subject: [PATCH 039/127] fix(push): open the conversation from a cold-start tap on a push for the current account A tapped chat.newmessage that names an account was always parked as pending and only replayed when the current uid changed to it. When bootstrap had already set that uid, nothing replayed it and startup opened the inbox. Decide at read time against the startup account (the current user, or the bootstrap status the router waits on) so a tap for this account picks the startup conversation, and hand a tap for another account to handlePush so the switch runs even when the account list already loaded. --- .../init/push-listener.native.test.ts | 97 +++++++++++++++++++ .../constants/init/push-listener.native.tsx | 29 ++++-- shared/stores/push.tsx | 7 -- .../flows/lifecycle-links-push.test.ts | 12 ++- 4 files changed, 131 insertions(+), 14 deletions(-) diff --git a/shared/constants/init/push-listener.native.test.ts b/shared/constants/init/push-listener.native.test.ts index b3410538879f..5e1c50842791 100644 --- a/shared/constants/init/push-listener.native.test.ts +++ b/shared/constants/init/push-listener.native.test.ts @@ -3,6 +3,7 @@ import type * as PushListener from './push-listener.native' import type * as PushStore from '@/stores/push' import type * as ConfigStore from '@/stores/config' import type * as CurrentUserStore from '@/stores/current-user' +import type * as DaemonStore from '@/stores/daemon' import type * as T from '@/constants/types' // push-listener and the push store pick their mobile behavior when they load, so each test loads @@ -11,6 +12,7 @@ import type * as T from '@/constants/types' type Loaded = { configStore: typeof ConfigStore currentUserStore: typeof CurrentUserStore + daemonStore: typeof DaemonStore pushListener: typeof PushListener pushStore: typeof PushStore } @@ -67,6 +69,7 @@ const load = (): Loaded => { const loaded = { configStore: require('@/stores/config') as typeof ConfigStore, currentUserStore: require('@/stores/current-user') as typeof CurrentUserStore, + daemonStore: require('@/stores/daemon') as typeof DaemonStore, pushListener: require('./push-listener.native') as typeof PushListener, pushStore: require('@/stores/push') as typeof PushStore, } @@ -195,6 +198,100 @@ describe('startup push', () => { expect(pending && 'forUid' in pending && pending.forUid).toBe(otherUid) }) + test('a tapped chat.newmessage for the account already current picks the startup screen', async () => { + const {pushListener, pushStore} = load() + pushListener.initPushListener() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: currentUid, userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ + startupConversation: convID, + startupPushPayload: 'payload', + }) + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + await flush() + expect(emitDeepLink).not.toHaveBeenCalled() + }) + + test('a tapped chat.newmessage read before bootstrap names the account picks the startup screen once it does', async () => { + const {currentUserStore, daemonStore, pushListener, pushStore} = load() + currentUserStore.useCurrentUserState.setState({uid: '', username: ''}) + pushListener.initPushListener() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: currentUid, userInteraction: true}) + let settled = false + const read = pushListener.getStartupDetailsFromInitialPush().finally(() => { + settled = true + }) + await jest.advanceTimersByTimeAsync(100) + expect(settled).toBe(false) + + // the order bootstrap applies it in: the status, then the current user it names + daemonStore.useDaemonState.setState({ + bootstrapStatus: {deviceID: '', loggedIn: true, uid: currentUid, username: 'testuser'} as T.RPCGen.BootstrapStatus, + }) + currentUserStore.useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: '', + deviceName: '', + uid: currentUid, + username: 'testuser', + }) + await expect(read).resolves.toEqual({startupConversation: convID, startupPushPayload: 'payload'}) + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + await flush() + expect(emitDeepLink).not.toHaveBeenCalled() + }) + + test('a tapped chat.newmessage for another account replays once that account is current', async () => { + const {currentUserStore, pushListener, pushStore} = load() + pushListener.initPushListener() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() + expect(pushStore.usePushState.getState().pendingPushNotification?.type).toBe('chat.newmessage') + + currentUserStore.useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: '', + deviceName: '', + uid: otherUid, + username: 'testuser-mac', + }) + await flush() + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + expect(emitDeepLink).toHaveBeenCalledWith(`keybase://convid/${convID}`, {targetUid: otherUid}) + }) + + test('a tapped chat.newmessage for a stored account already listed switches to it', async () => { + const {configStore, pushListener, pushStore} = load() + const login = jest.fn() + const config = configStore.useConfigState.getState() + configStore.useConfigState.setState({ + configuredAccounts: [ + {hasStoredSecret: true, uid: currentUid, username: 'testuser'}, + {hasStoredSecret: true, uid: otherUid, username: 'testuser-mac'}, + ], + dispatch: {...config.dispatch, login}, + }) + pushListener.initPushListener() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: true}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() + await flush() + expect(login).toHaveBeenCalledWith('testuser-mac', '') + expect(pushStore.usePushState.getState().pendingPushNotification?.type).toBe('chat.newmessage') + expect(emitDeepLink).not.toHaveBeenCalled() + }) + + test('an untapped chat.newmessage for the current account neither picks the startup screen nor navigates', async () => { + const {pushListener, pushStore} = load() + pushListener.initPushListener() + getInitialNotification = async () => + Promise.resolve({...tapped['chat.newmessage'], uid: currentUid, userInteraction: false}) + await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() + await flush() + expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() + expect(emitDeepLink).not.toHaveBeenCalled() + }) + test('tapped pushes pick the startup screen', async () => { const {pushListener} = load() getInitialNotification = async () => Promise.resolve({...tapped['chat.newmessage'], userInteraction: true}) diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index d84733949eea..b2d982270e5b 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -14,6 +14,7 @@ import { } from 'react-native-kb' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' +import {useDaemonState} from '@/stores/daemon' import {usePushState} from '@/stores/push' import {useShellState} from '@/stores/shell' @@ -205,6 +206,23 @@ const isTap = (notification: T.Push.PushNotification) => // from holding startup, and a tap that still shows up after it is handled like a live one. const initialPushTimeoutMs = 3000 +// The account startup opens in. On a cold start the read can finish before bootstrap names it, so +// wait for the bootstrap status: the router mounts only after the handshake has loaded it, so the +// wait never holds back the first screen. +const getStartupUid = async () => { + const {uid} = useCurrentUserState.getState() + if (uid) return uid + const loaded = useDaemonState.getState().bootstrapStatus + if (loaded) return loaded.uid + return new Promise(resolve => { + const unsub = useDaemonState.subscribe(s => { + if (!s.bootstrapStatus) return + unsub() + resolve(s.bootstrapStatus.uid) + }) + }) +} + const getStartupDetailsFromInitialPush = async () => { const initialPush = getInitialPush() const timedOut = 'timedOut' as const @@ -234,12 +252,11 @@ const getStartupDetailsFromInitialPush = async () => { } } else if (notification.type === 'chat.newmessage') { 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.forUid) { - usePushState.getState().dispatch.setPendingPushNotification(notification) + // A tap for another account can't open here: it would show that conversation under the + // wrong account. handlePush switches accounts, or keeps it pending until the account list + // lists that account, and replays it once the switch lands. + if (notification.forUid && notification.forUid !== (await getStartupUid())) { + usePushState.getState().dispatch.handlePush(notification) return } return { diff --git a/shared/stores/push.tsx b/shared/stores/push.tsx index 7ef0f8fe2d50..5c6151f506ab 100644 --- a/shared/stores/push.tsx +++ b/shared/stores/push.tsx @@ -29,7 +29,6 @@ type State = Store & { 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 } @@ -71,7 +70,6 @@ export const usePushState = Z.createZustand('push', (set, get) => { rejectPermissions: () => {}, requestPermissions: () => {}, resetState: Z.defaultReset, - setPendingPushNotification: () => {}, setPushToken: () => {}, showPermissionsPrompt: () => {}, } @@ -365,11 +363,6 @@ export const usePushState = Z.createZustand('push', (set, get) => { pendingPushNotification, })) }, - setPendingPushNotification: (notification: T.Push.PushNotification) => { - set(s => { - s.pendingPushNotification = notification - }) - }, setPushToken: (token: string) => { set(s => { s.token = token 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 index 7103d2529aa2..3b69518e33ee 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -13,6 +13,7 @@ import { findNotification, goLogMark, goLogSince, + jsEval, metroClientLogSince, metroLogMark, openSelfConversation, @@ -98,12 +99,15 @@ describe('app lifecycle: deep links', () => { 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, }) // The payload JS logs for a push, found by its unique body. const jsPushes = (lines: Array, body: string) => findLines(lines, /\[onNotification\]/).filter(l => l.includes(body)) @@ -113,6 +117,8 @@ describe('app lifecycle: push notifications', () => { 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 () => { @@ -182,8 +188,12 @@ describe('app lifecycle: push notifications', () => { await waitForAppState('active', undefined, 90000) await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) // The tap reaches JS as the initial notification and picks the startup conversation. - const startup = findLines(metroClientLogSince(metroMark), /initialState: push /) + const lines = metroClientLogSince(metroMark) + const startup = findLines(lines, /initialState: push /) expect(startup).toEqual([expect.stringContaining(`initialState: push ${convID}`)]) + // 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), /\[Push\] handleLoudMessage: ignore non userInteraction/)).toEqual([]) }) }) From cd218696b058b1516e134c0a7e70d8bd63509579 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 09:01:00 -0400 Subject: [PATCH 040/127] test(ios): wait for the native conversation push before openSelfConversation returns JS reports chatConversation before the native push lands, so a reset right after saw a tab root, skipped popping, and tab taps landed under the conversation. --- shared/tests/e2e/ios-appium/helpers/lifecycle.ts | 9 ++++++++- shared/tests/e2e/ios-appium/helpers/navigate.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts index d9ad8388a57d..ec6d5fabf1f9 100644 --- a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts +++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts @@ -3,7 +3,9 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import {udidForName} from './app' -import {escapeToTabs, navigateToChat} from './navigate' +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). @@ -477,6 +479,11 @@ export const openSelfConversation = async (username: string) => { }, {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 } 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 From 8168ea76482b9c315154c7ebe1b32a1c390ce851 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 09:09:19 -0400 Subject: [PATCH 041/127] fix(ios): append startup timing lines to ios.log instead of replacing the file Go logs to writeStartupTimingLog created ios.log with FileManager.createFile, which writes a temp file and renames it over the path. When KeybaseInit's Go logger opened ios.log between the existence check and that rename, Go logged the whole session to an unlinked file, so logsend and anything reading ios.log lost it. Open with O_CREAT|O_APPEND instead. --- shared/ios/Keybase/AppDelegate.swift | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 9226c54913b8..882ebdbaf478 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -122,20 +122,19 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi 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 { From 10b5537e57de10cc857ff31ee980252de6ccafe7 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 12:23:23 -0400 Subject: [PATCH 042/127] refactor(appstate): derive the app state from the UI state and background-work holds --- go/bind/keybase.go | 77 ++- go/bind/location_test.go | 2 +- go/chat/maps/livelocation.go | 16 +- go/chat/maps/livelocation_appstate_test.go | 15 +- go/ephemeral/keygen_loop_test.go | 77 ++- go/ephemeral/lib.go | 16 +- go/kbhttp/manager/manager.go | 21 +- go/kbhttp/manager/manager_test.go | 4 +- go/libkb/appstate.go | 63 +-- go/libkb/appstate_test.go | 121 +--- go/libkb/globals.go | 2 +- go/libkb/leveldb_cleaner_test.go | 6 +- go/libkb/lifecycle/controller_test.go | 265 ++++----- go/libkb/lifecycle/export_test.go | 8 +- go/libkb/lifecycle/lifecycle.go | 515 +++++++++++------- go/libkb/lifecycle/lifecycletest/harness.go | 46 +- go/libkb/lifecycle/lifecycletest/scenarios.go | 448 ++++++++------- go/libkb/lifecycle/scenario_test.go | 4 +- .../keybase/ossifrage/AppLifecycleReporter.kt | 35 +- .../keybase/ossifrage/KeybaseLifecycleBind.kt | 11 +- .../ossifrage/AppLifecycleReporterTest.kt | 84 +-- shared/ios/Keybase/AppDelegate.swift | 36 +- .../flows/lifecycle-app-state.test.ts | 26 +- .../flows/lifecycle-links-push.test.ts | 4 +- .../flows/lifecycle-location.test.ts | 10 +- 25 files changed, 961 insertions(+), 951 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 7c9dd8e22ad0..c451e394ac69 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -885,33 +885,23 @@ func FlushLogs() { logger.FlushLogFile() } -// AppWillEnterForeground reports iOS applicationWillEnterForeground, or -// Android's process start. -func AppWillEnterForeground() { +// AppUIActive reports the app on screen and receiving events: iOS didBecomeActive, Android process resume. +func AppUIActive() { if !isInited() { return } - defer kbCtx.Trace("AppWillEnterForeground", nil)() - kbCtx.MobileLifecycle.WillEnterForeground() + defer kbCtx.Trace("AppUIActive", nil)() + kbCtx.MobileLifecycle.UIActive() } -// AppDidBecomeActive reports iOS applicationDidBecomeActive, or Android's -// process resume. -func AppDidBecomeActive() { +// AppUIInactive reports the app on screen but not active: iOS willEnterForeground and +// willResignActive, Android process start. +func AppUIInactive() { if !isInited() { return } - defer kbCtx.Trace("AppDidBecomeActive", nil)() - kbCtx.MobileLifecycle.DidBecomeActive() -} - -// AppWillResignActive reports iOS applicationWillResignActive. -func AppWillResignActive() { - if !isInited() { - return - } - defer kbCtx.Trace("AppWillResignActive", nil)() - kbCtx.MobileLifecycle.WillResignActive() + defer kbCtx.Trace("AppUIInactive", nil)() + kbCtx.MobileLifecycle.UIInactive() } // LocationUpdate reports a location fix from the native location service. @@ -983,8 +973,8 @@ func AppWillExit(pusher PushNotifier) { // AppBackgroundTaskExpired is called when the OS is about to suspend the app // before the background task started by AppBeginBackgroundTask finished. It -// returns to BACKGROUND, and warns about messages still waiting to send, only -// if nothing has updated the app state since the window opened. +// ends every background task hold, and warns about messages still waiting to +// send if one was open. func AppBackgroundTaskExpired(pusher PushNotifier) { if !isInited() { return @@ -1023,21 +1013,20 @@ func shouldStayRunningInBackground() bool { return false } -// 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 { +// AppUIBackground reports the app off screen. It returns a background task token +// for AppBeginBackgroundTask when work must keep running, 0 otherwise. +func AppUIBackground() int64 { if !isInited() { - return false + return 0 } - defer kbCtx.Trace("AppDidEnterBackground", nil)() - return kbCtx.MobileLifecycle.DidEnterBackground(shouldStayRunningInBackground) + defer kbCtx.Trace("AppUIBackground", nil)() + return kbCtx.MobileLifecycle.UIBackground(shouldStayRunningInBackground) } -// AppPushWindowBegin moves the app to BACKGROUNDACTIVE while a push -// notification is handled, unless the app is in the foreground. It returns a -// token for AppPushWindowEnd: positive when the window opened, 0 when the app -// is in the foreground (skip the work), and -1 when the service isn't -// initialized (no window, but the work may still run). +// AppPushWindowBegin holds the app up while a push notification is handled, +// unless the app is active. It returns a token for AppPushWindowEnd: positive +// when the hold opened, 0 when the app is active (skip the work), and -1 when +// the service isn't initialized (no hold, but the work may still run). func AppPushWindowBegin() int64 { if !isInited() { return -1 @@ -1046,34 +1035,34 @@ func AppPushWindowBegin() int64 { return kbCtx.MobileLifecycle.PushWindowBegin() } -// AppPushWindowEnd closes the window opened by AppPushWindowBegin, only if -// nothing has updated the app state since. It returns true when the caller -// should start AppBeginBackgroundTaskNonblock to keep running, as with -// AppDidEnterBackground. -func AppPushWindowEnd(token int64) bool { +// AppPushWindowEnd ends the hold opened by AppPushWindowBegin. It returns a +// background task token for AppBeginBackgroundTaskNonblock when work must keep +// running, 0 otherwise. +func AppPushWindowEnd(token int64) int64 { if !isInited() { - return false + return 0 } defer kbCtx.Trace("AppPushWindowEnd", nil)() return kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground) } -func AppBeginBackgroundTaskNonblock(pusher PushNotifier) { +func AppBeginBackgroundTaskNonblock(token int64, pusher PushNotifier) { if !isInited() { return } defer kbCtx.Trace("AppBeginBackgroundTaskNonblock", nil)() - go AppBeginBackgroundTask(pusher) + go AppBeginBackgroundTask(token, 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) { +// AppBeginBackgroundTask runs the background task whose token AppUIBackground or +// AppPushWindowEnd returned. It returns once we no longer need any time in the +// background. +func AppBeginBackgroundTask(token int64, pusher PushNotifier) { if !isInited() { return } defer kbCtx.Trace("AppBeginBackgroundTask", nil)() - kbCtx.MobileLifecycle.RunBackgroundTask(context.Background(), backgroundTaskDeps(pusher)) + kbCtx.MobileLifecycle.RunBackgroundTask(context.Background(), token, backgroundTaskDeps(pusher)) } func backgroundTaskDeps(pusher PushNotifier) lifecycle.BackgroundTaskDeps { diff --git a/go/bind/location_test.go b/go/bind/location_test.go index 54a6988e362e..b41c23b68890 100644 --- a/go/bind/location_test.go +++ b/go/bind/location_test.go @@ -41,7 +41,7 @@ func TestLocationUpdateReachesTrackers(t *testing.T) { tracker.SetClock(clock) tracker.TestingCoordsAddedCh = make(chan struct{}, 10) ctx := context.Background() - tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + require.Zero(t, tc.G.MobileLifecycle.UIBackground(func() bool { return false })) tracker.StartTracking(ctx, chat1.ConversationID("conv"), 1, clock.Now().Add(time.Hour)) select { diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 5968b58674bf..66a747aac7da 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -12,6 +12,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" @@ -32,6 +33,8 @@ type LiveLocationTracker struct { trackers map[types.LiveLocationKey]*locationTrack lastCoord chat1.Coordinate maxCoords int + // bgHold keeps the app running while tracking; guarded by the tracker's mutex. + bgHold *lifecycle.Hold nativeWatchMu sync.Mutex nativeWatchRefs int @@ -98,8 +101,9 @@ func (l *LiveLocationTracker) saveLocked(ctx context.Context) { func (l *LiveLocationTracker) removeTrackerLocked(ctx context.Context, t *locationTrack) { delete(l.trackers, t.Key()) l.saveLocked(ctx) - if len(l.trackers) == 0 { - l.G().MobileLifecycle.LiveLocationRelease() + if len(l.trackers) == 0 && l.bgHold != nil { + l.bgHold.Release() + l.bgHold = nil } } @@ -434,11 +438,9 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co defer l.Trace(ctx, nil, "LocationUpdate")() l.Lock() defer l.Unlock() - if l.G().IsMobileAppType() && len(l.trackers) > 0 { - // 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().MobileLifecycle.LiveLocationClaim() + if l.G().IsMobileAppType() && len(l.trackers) > 0 && (l.bgHold == nil || l.bgHold.Released()) { + // A location update can wake a backgrounded app; hold it up so the update gets out. + l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) } if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go index 74da1142a4f0..b9a7c697c134 100644 --- a/go/chat/maps/livelocation_appstate_test.go +++ b/go/chat/maps/livelocation_appstate_test.go @@ -34,9 +34,12 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { l.removeTrackerLocked(ctx, track) } - appState.Update(keybase1.MobileAppState_BACKGROUND) + noStay := func() bool { return false } + lc := tc.G.MobileLifecycle + require.Zero(t, lc.UIBackground(noStay)) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) l.LocationUpdate(ctx, coord(1)) - require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "no trackers, no claim") + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "no trackers, no hold") first := addTracker(1) second := addTracker(2) @@ -47,11 +50,13 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { removeTracker(second) require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) - // A foreground while tracking leaves the state to the foreground. + // 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()) + require.Zero(t, lc.UIBackground(noStay)) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) - appState.Update(keybase1.MobileAppState_FOREGROUND) removeTracker(third) - require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) } diff --git a/go/ephemeral/keygen_loop_test.go b/go/ephemeral/keygen_loop_test.go index 6fee409da7e3..96b5db48cb1f 100644 --- a/go/ephemeral/keygen_loop_test.go +++ b/go/ephemeral/keygen_loop_test.go @@ -10,14 +10,10 @@ import ( "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 +// startKeygenLoop runs keygenLoop without ticks or jitter. next waits until the +// loop is about to wait in the given state; stop ends the loop. +func startKeygenLoop(t *testing.T, mctx libkb.MetaContext) (runs *atomic.Int32, next func(keybase1.MobileAppState), stop func()) { + runs = new(atomic.Int32) waiting := make(chan keybase1.MobileAppState, 10) stopCh := make(chan struct{}) done := make(chan struct{}) @@ -29,7 +25,7 @@ func TestKeygenLoopSeedsFromState(t *testing.T) { func(state keybase1.MobileAppState) { waiting <- state }) }() - next := func(want keybase1.MobileAppState) { + next = func(want keybase1.MobileAppState) { t.Helper() select { case got := <-waiting: @@ -38,6 +34,27 @@ func TestKeygenLoopSeedsFromState(t *testing.T) { t.Fatal("keygen loop did not wait") } } + stop = func() { + t.Helper() + close(stopCh) + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("keygen loop did not stop") + } + } + return runs, next, stop +} + +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) + + runs, next, stop := startKeygenLoop(t, mctx) + defer stop() // A background-active launch is not a transition into BACKGROUNDACTIVE. next(keybase1.MobileAppState_BACKGROUNDACTIVE) @@ -50,11 +67,41 @@ func TestKeygenLoopSeedsFromState(t *testing.T) { 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 UI comes +// back from BACKGROUND (INACTIVE), but not when the UI merely stops being +// active. +func TestKeygenLoopRunsWhenLeavingTheBackground(t *testing.T) { + tc := libkb.SetupTest(t, "ephemeral", 2) + defer tc.Cleanup() + mctx := libkb.NewMetaContextForTest(tc) + appState := tc.G.MobileAppState + appState.Update(keybase1.MobileAppState_BACKGROUND) + + runs, next, stop := startKeygenLoop(t, mctx) + defer stop() + + next(keybase1.MobileAppState_BACKGROUND) + require.Zero(t, runs.Load()) + appState.Update(keybase1.MobileAppState_INACTIVE) + next(keybase1.MobileAppState_INACTIVE) + require.EqualValues(t, 1, runs.Load()) + appState.Update(keybase1.MobileAppState_FOREGROUND) + next(keybase1.MobileAppState_FOREGROUND) + require.EqualValues(t, 1, runs.Load()) + // INACTIVE from the foreground. + appState.Update(keybase1.MobileAppState_INACTIVE) + next(keybase1.MobileAppState_INACTIVE) + require.EqualValues(t, 1, runs.Load()) + appState.Update(keybase1.MobileAppState_BACKGROUND) + next(keybase1.MobileAppState_BACKGROUND) + require.EqualValues(t, 1, runs.Load()) + appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + next(keybase1.MobileAppState_BACKGROUNDACTIVE) + require.EqualValues(t, 2, runs.Load()) + // INACTIVE from BACKGROUNDACTIVE. + appState.Update(keybase1.MobileAppState_INACTIVE) + next(keybase1.MobileAppState_INACTIVE) + require.EqualValues(t, 2, runs.Load()) } diff --git a/go/ephemeral/lib.go b/go/ephemeral/lib.go index 97ee2064e8da..d3ab96e12e61 100644 --- a/go/ephemeral/lib.go +++ b/go/ephemeral/lib.go @@ -124,9 +124,10 @@ func (e *EKLib) backgroundKeygen(mctx libkb.MetaContext, stopCh <-chan struct{}) } // keygenLoop runs run on every tick, and also when the app enters -// BACKGROUNDACTIVE, 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. +// BACKGROUNDACTIVE or the UI comes back from BACKGROUND (INACTIVE), 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), ) { @@ -139,8 +140,9 @@ func (e *EKLib) keygenLoop(mctx libkb.MetaContext, stopCh <-chan struct{}, tick case <-tick: run() case <-mctx.G().MobileAppState.NextUpdate(state): + prev := state state = mctx.G().MobileAppState.State() - if state == keybase1.MobileAppState_BACKGROUNDACTIVE { + if keygenOnTransition(prev, state) { select { case <-time.After(jitter()): run() @@ -154,6 +156,12 @@ func (e *EKLib) keygenLoop(mctx libkb.MetaContext, stopCh <-chan struct{}, tick } } +// keygenOnTransition: work woke the app in the background, or the UI is coming back from it. +func keygenOnTransition(prev, state keybase1.MobileAppState) bool { + return state == keybase1.MobileAppState_BACKGROUNDACTIVE || + (prev == keybase1.MobileAppState_BACKGROUND && state == keybase1.MobileAppState_INACTIVE) +} + func (e *EKLib) SetClock(clock clockwork.Clock) { e.clock = clock } diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 7d615e325987..1d404f4a5bda 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -42,10 +42,12 @@ type Srv struct { httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint shutdown bool - // exitRestartGen is one past the app-state generation of the last restart - // after an unexpected exit, so a listener that keeps dying restarts at - // most once per generation. - exitRestartGen uint64 + // exitRestartChange is one past stateChanges at the last restart after an + // unexpected exit, so a listener that keeps dying restarts at most once + // per app state change. + exitRestartChange uint64 + // stateChanges counts the app state changes the monitor applied. + stateChanges uint64 // exits counts handled unexpected exits, for tests. exits int // beforeExitRestart, if set, runs in serverExited between reading the @@ -120,18 +122,18 @@ func (r *Srv) serverExited() { r.mu.Lock() // Read the state and start under mu, so a BACKGROUND the monitor applies // concurrently either comes first (seen here) or stops what starts here. - state, gen := r.G().MobileAppState.StateAndGeneration() + state := r.G().MobileAppState.State() if r.beforeExitRestart != nil { r.beforeExitRestart() } var info keybase1.HttpSrvInfo started := false - if r.wantUp(state) && r.exitRestartGen != gen+1 { - r.exitRestartGen = gen + 1 + if r.wantUp(state) && r.exitRestartChange != r.stateChanges+1 { + r.exitRestartChange = r.stateChanges + 1 r.debug(ctx, "serverExited: restarting in %v", state) info, started = r.startLocked(ctx) } else { - r.debug(ctx, "serverExited: not restarting in %v (generation %d)", state, gen) + r.debug(ctx, "serverExited: not restarting in %v", state) } r.exits++ r.mu.Unlock() @@ -233,6 +235,9 @@ func (r *Srv) monitorAppState(state keybase1.MobileAppState) { return } state = r.G().MobileAppState.State() + r.mu.Lock() + r.stateChanges++ + r.mu.Unlock() r.reconcile(state) } } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index e570d3ab0df0..934ad464c441 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -144,7 +144,7 @@ func waitExits(t *testing.T, srv *Srv, n int) { } // killUntilDown kills the listener until an unexpected exit is not -// restarted, because this app-state generation already had its restart. +// restarted, because this app state change already had its restart. func killUntilDown(t *testing.T, srv *Srv, l *listeners) { t.Helper() for range 2 { @@ -216,7 +216,7 @@ func TestUnexpectedExitRestartsOncePerGeneration(t *testing.T) { require.Equal(t, 2, l.Calls(), "restart loop on a failing listener") requireStopped(t, srv) - // A new generation allows one more restart after the monitor's own. + // A new app state change allows one more restart after the monitor's own. srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) waitMonitor(t, srv) waitExits(t, srv, 4) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 6be2e34d9053..3b5b5a5c7683 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -27,10 +27,6 @@ type MobileAppState struct { Contextified sync.Mutex state keybase1.MobileAppState - // generation increments on every accepted update, including one that - // sets the current value again, so a writer that read an older generation - // can tell that someone else has spoken since. - generation uint64 // changed is closed and replaced whenever state actually changes. Any // caller holding a reference to the previous channel is woken by the // close; they then re-read State() to see the new value. @@ -83,14 +79,12 @@ func (a *MobileAppState) NextUpdate(lastState keybase1.MobileAppState) <-chan st } func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bool) { - a.generation++ if a.state == state { - a.G().Log.Debug("MobileAppState.Update: same-value update: %v, generation: %d", - state, a.generation) + 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, generation: %d", - state, a.state, a.generation) + 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 @@ -109,28 +103,8 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo return true } -// UpdateWithCheck applies state only if check accepts the current state, -// evaluated under the same lock as the update. It returns the generation -// after the call, whether the update was applied, and whether the value -// changed. Owners keep newGen to undo their transition with -// UpdateIfGeneration. -func (a *MobileAppState) UpdateWithCheck(state keybase1.MobileAppState, - check func(keybase1.MobileAppState) bool, -) (newGen uint64, applied bool, changed bool) { - defer a.G().Trace(fmt.Sprintf("MobileAppState.UpdateWithCheck(%v)", state), nil)() - a.Lock() - defer a.Unlock() - if !check(a.state) { - a.G().Log.Debug("MobileAppState.UpdateWithCheck: skipping update, failed check") - return a.generation, false, false - } - changed = a.updateLocked(state) - return a.generation, true, changed -} - -// Update sets the current app state and bumps the generation, even when state -// is already current. It returns whether the value changed; only a change -// wakes NextUpdate callers and has side effects. +// Update sets the current app state and returns whether the value changed; +// only a change wakes NextUpdate callers and has side effects. func (a *MobileAppState) Update(state keybase1.MobileAppState) (changed bool) { defer a.G().Trace(fmt.Sprintf("MobileAppState.Update(%v)", state), nil)() a.Lock() @@ -138,25 +112,6 @@ func (a *MobileAppState) Update(state keybase1.MobileAppState) (changed bool) { return a.updateLocked(state) } -// UpdateIfGeneration applies state only if no update has been accepted since -// gen was read from StateAndGeneration. It returns the generation after the -// call (the new one when applied, the current one otherwise), whether the -// update was applied, and whether the value changed. -func (a *MobileAppState) UpdateIfGeneration(gen uint64, state keybase1.MobileAppState) ( - newGen uint64, applied bool, changed bool, -) { - defer a.G().Trace(fmt.Sprintf("MobileAppState.UpdateIfGeneration(%d, %v)", gen, state), nil)() - a.Lock() - defer a.Unlock() - if a.generation != gen { - a.G().Log.Debug("MobileAppState.UpdateIfGeneration: skipping update, generation %d is now %d", - gen, a.generation) - return a.generation, false, false - } - changed = a.updateLocked(state) - return a.generation, true, changed -} - // State returns the current app state func (a *MobileAppState) State() keybase1.MobileAppState { a.Lock() @@ -164,14 +119,6 @@ func (a *MobileAppState) State() keybase1.MobileAppState { return a.state } -// StateAndGeneration returns the current app state together with the -// generation that produced it, for use with UpdateIfGeneration. -func (a *MobileAppState) StateAndGeneration() (keybase1.MobileAppState, uint64) { - a.Lock() - defer a.Unlock() - return a.state, a.generation -} - func (a *MobileAppState) StateAndMtime() (keybase1.MobileAppState, *time.Time) { a.Lock() defer a.Unlock() diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index 6b04690feedf..73834f35fe8b 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -6,7 +6,6 @@ package libkb import ( "context" "sync" - "sync/atomic" "testing" "time" @@ -39,70 +38,6 @@ func TestMobileAppStateInitialState(t *testing.T) { require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("linux")) } -func TestMobileAppStateGeneration(t *testing.T) { - tc := SetupTest(t, "MobileAppStateGeneration", 0) - defer tc.Cleanup() - a := NewMobileAppState(tc.G) - - state, gen := a.StateAndGeneration() - require.Equal(t, keybase1.MobileAppState_FOREGROUND, state) - - require.True(t, a.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)) - state, gen1 := a.StateAndGeneration() - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) - require.Greater(t, gen1, gen) - - // A same-value update is accepted and bumps the generation. - require.False(t, a.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)) - state, gen2 := a.StateAndGeneration() - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) - require.Greater(t, gen2, gen1) - - // A CAS against the generation read before that same-value update fails. - newGen, applied, changed := a.UpdateIfGeneration(gen1, keybase1.MobileAppState_BACKGROUND) - require.False(t, applied) - require.False(t, changed) - require.Equal(t, gen2, newGen) - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, a.State()) - - // A CAS against the current generation applies. - newGen, applied, changed = a.UpdateIfGeneration(gen2, keybase1.MobileAppState_BACKGROUND) - require.True(t, applied) - require.True(t, changed) - state, gen3 := a.StateAndGeneration() - require.Equal(t, keybase1.MobileAppState_BACKGROUND, state) - require.Equal(t, gen3, newGen) - require.Greater(t, gen3, gen2) - - // A same-value CAS applies, bumps the generation, and reports no change. - newGen, applied, changed = a.UpdateIfGeneration(gen3, keybase1.MobileAppState_BACKGROUND) - require.True(t, applied) - require.False(t, changed) - require.Greater(t, newGen, gen3) -} - -func TestMobileAppStateUpdateWithCheck(t *testing.T) { - tc := SetupTest(t, "MobileAppStateUpdateWithCheck", 0) - defer tc.Cleanup() - a := NewMobileAppState(tc.G) - isBackground := func(s keybase1.MobileAppState) bool { return s == keybase1.MobileAppState_BACKGROUND } - - _, gen := a.StateAndGeneration() - newGen, applied, changed := a.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, isBackground) - require.False(t, applied) - require.False(t, changed) - require.Equal(t, gen, newGen) - require.Equal(t, keybase1.MobileAppState_FOREGROUND, a.State()) - - a.Update(keybase1.MobileAppState_BACKGROUND) - newGen, applied, changed = a.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, isBackground) - require.True(t, applied) - require.True(t, changed) - state, cur := a.StateAndGeneration() - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) - require.Equal(t, cur, newGen) -} - func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) { tc := SetupTest(t, "MobileAppStateSideEffects", 0) defer tc.Cleanup() @@ -118,10 +53,6 @@ func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) { _, mtime2 := a.StateAndMtime() require.Same(t, mtime, mtime2) - _, gen := a.StateAndGeneration() - _, _, _ = a.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUND) - requireOpen(t, next) - // A stale lastState wakes immediately. requireClosed(t, a.NextUpdate(keybase1.MobileAppState_FOREGROUND)) @@ -161,18 +92,14 @@ func TestMobileAppStateStress(t *testing.T) { } const ( writers = 8 - casWriters = 8 waiters = 8 iterations = 300 ) - _, startGen := a.StateAndGeneration() var ( - accepted atomic.Uint64 waitersWG sync.WaitGroup writersWG sync.WaitGroup ) - errs := make(chan string, casWriters*iterations) for i := 0; i < waiters; i++ { waitersWG.Add(1) @@ -194,59 +121,15 @@ func TestMobileAppStateStress(t *testing.T) { defer writersWG.Done() for j := 0; j < iterations; j++ { a.Update(states[(i+j)%len(states)]) - accepted.Add(1) - } - }(i) - } - - for i := 0; i < casWriters; i++ { - writersWG.Add(1) - go func(i int) { - defer writersWG.Done() - for j := 0; j < iterations; j++ { - next := states[(i+j)%len(states)] - if j%2 == 0 { - // Our own update in between makes gen stale, whatever else runs. - _, gen := a.StateAndGeneration() - a.Update(next) - accepted.Add(1) - if _, applied, _ := a.UpdateIfGeneration(gen, next); applied { - errs <- "a CAS with a stale generation applied" - } - continue - } - _, gen := a.StateAndGeneration() - newGen, applied, _ := a.UpdateIfGeneration(gen, next) - if applied { - accepted.Add(1) - if newGen != gen+1 { - errs <- "an applied CAS did not advance the generation by one" - } - } else if newGen <= gen { - errs <- "a rejected CAS reported a generation that did not move" - } } }(i) } requireDoneWithin(t, &writersWG, 30*time.Second, "writers deadlocked") - _, gen := a.StateAndGeneration() - require.Equal(t, startGen+accepted.Load(), gen, "every accepted update bumps the generation exactly once") - - // A CAS on the current generation applies and is the last update, so it - // must be the final state. - newGen, applied, _ := a.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) - require.True(t, applied) - state, finalGen := a.StateAndGeneration() - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, state) - require.Equal(t, newGen, finalGen) - + require.True(t, a.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, a.State()) requireDoneWithin(t, &waitersWG, 30*time.Second, "a NextUpdate waiter missed the final change") - close(errs) - for err := range errs { - require.Fail(t, err) - } } func requireDoneWithin(t *testing.T, wg *sync.WaitGroup, timeout time.Duration, msg string) { diff --git a/go/libkb/globals.go b/go/libkb/globals.go index f86d4ac5049a..8348e7df879b 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -70,7 +70,7 @@ type GlobalContext struct { DNSNSFetcher DNSNameServerFetcher // The mobile apps potentially pass an implementor of this interface which is used to grab currently configured DNS name servers MobileNetState *MobileNetState // The kind of network connection for the currently running instance of the app MobileAppState *MobileAppState // The state of focus for the currently running instance of the app - MobileLifecycle *lifecycle.Controller // Turns native lifecycle events into MobileAppState updates + MobileLifecycle *lifecycle.Controller // Derives MobileAppState from native UI reports and background-work holds DesktopAppState *DesktopAppState // The state of focus for the currently running instance of the app ChatHelper ChatHelper // conveniently send chat messages RPCCanceler *RPCCanceler // register live RPCs so they can be cancelleed en masse diff --git a/go/libkb/leveldb_cleaner_test.go b/go/libkb/leveldb_cleaner_test.go index 2cd1f7ac65e2..f6cb914baaed 100644 --- a/go/libkb/leveldb_cleaner_test.go +++ b/go/libkb/leveldb_cleaner_test.go @@ -226,10 +226,8 @@ func TestLevelDbCleanerScenarioReplay(t *testing.T) { switch { case step.Want != prev && step.Want != keybase1.MobileAppState_BACKGROUNDACTIVE: require.True(t, canceled, "step %d %v: clean not canceled in %v", i, step.Do, step.Want) - case step.Want == keybase1.MobileAppState_BACKGROUNDACTIVE && step.Gen <= 1: - require.False(t, canceled, "step %d %v: clean canceled in BACKGROUNDACTIVE", i, step.Do) - case step.Want == prev && step.Gen <= 1: - require.False(t, canceled, "step %d %v: clean canceled without a transition", i, step.Do) + case step.Want == keybase1.MobileAppState_BACKGROUNDACTIVE || step.Want == prev: + require.False(t, canceled, "step %d %v: clean canceled without a transition out of BACKGROUNDACTIVE", i, step.Do) } prev = step.Want } diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 60208db851ba..f4e924263efe 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -39,107 +39,128 @@ func noDeliveries() lifecycle.BackgroundTaskDeps { } } -func requireDone(t *testing.T, done chan struct{}) { - t.Helper() - select { - case <-done: - case <-time.After(5 * time.Second): - require.Fail(t, "did not finish") - } +func TestHoldReleaseIsIdempotent(t *testing.T) { + appState, _ := newAppState(t) + flushes := 0 + c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) + require.Zero(t, c.UIBackground(noStay)) + require.Equal(t, background, appState.State()) + require.Equal(t, 1, flushes) + h := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) + require.Equal(t, backgroundActive, appState.State()) + require.True(t, h.Release()) + require.True(t, h.Released()) + require.Equal(t, background, appState.State()) + require.Equal(t, 2, flushes) + require.False(t, h.Release()) + require.Equal(t, 2, flushes) +} + +// An id is never reused, so releasing an old hold again can't end a newer one. +func TestReleasingAnOldHoldNeverReleasesANewerOne(t *testing.T) { + appState, _ := newAppState(t) + c := lifecycle.New(appState, lifecycle.Config{}) + c.UIBackground(noStay) + first := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) + require.True(t, first.Release()) + second := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) + require.NotEqual(t, first.ID(), second.ID()) + require.False(t, first.Release()) + require.False(t, second.Released()) + require.Equal(t, backgroundActive, appState.State()) + require.True(t, second.Release()) + require.Equal(t, background, appState.State()) } -// Two windows open concurrently and record their generations in the opposite -// order; the newer window must stay recorded so its task can close it. -func TestWindowsRecordedOutOfOrder(t *testing.T) { - openers := map[string]func(*lifecycle.Controller){ - "didEnterBackground": func(c *lifecycle.Controller) { c.DidEnterBackground(stay) }, - "pushWindowEnd": func(c *lifecycle.Controller) { c.PushWindowEnd(c.PushWindowBegin(), stay) }, +func TestLaunchHoldEndsAtTheFirstUIReport(t *testing.T) { + reports := map[string]func(c *lifecycle.Controller){ + "background": func(c *lifecycle.Controller) { c.UIBackground(noStay) }, + "inactive": func(c *lifecycle.Controller) { c.UIInactive() }, + "active": func(c *lifecycle.Controller) { c.UIActive() }, } - for name, openFirst := range openers { + for name, report := range reports { t.Run(name, func(t *testing.T) { appState, _ := newAppState(t) - appState.Update(background) - c := lifecycle.New(appState, lifecycle.Config{BackgroundTaskPollInterval: time.Millisecond}) - paused := make(chan struct{}) - release := make(chan struct{}) - calls := 0 - var mu sync.Mutex - lifecycle.SetTestHookAfterWindowUpdate(c, func() { - mu.Lock() - calls++ - first := calls == 1 - mu.Unlock() - if first { - close(paused) - <-release - } - }) - - firstDone := make(chan struct{}) - go func() { - openFirst(c) - close(firstDone) - }() - <-paused - require.True(t, c.DidEnterBackground(stay)) - _, newest := appState.StateAndGeneration() - close(release) - requireDone(t, firstDone) - require.Equal(t, newest, lifecycle.TaskGen(c)) - - c.RunBackgroundTask(context.Background(), noDeliveries()) - require.Equal(t, background, appState.State()) + appState.Update(backgroundActive) + c := lifecycle.New(appState, lifecycle.Config{}) + require.Equal(t, 1, lifecycle.Holds(c)) + report(c) + require.Equal(t, 0, lifecycle.Holds(c)) }) } } -func TestLiveLocationClaimsRecordedOutOfOrder(t *testing.T) { +func TestUILeavingBackgroundEndsTaskAndSyncHolds(t *testing.T) { appState, _ := newAppState(t) appState.Update(background) c := lifecycle.New(appState, lifecycle.Config{}) - paused := make(chan struct{}) - release := make(chan struct{}) - calls := 0 - var mu sync.Mutex - lifecycle.SetTestHookAfterWindowUpdate(c, func() { - mu.Lock() - calls++ - first := calls == 1 - mu.Unlock() - if first { - close(paused) - <-release - } - }) - firstDone := make(chan struct{}) - go func() { - c.LiveLocationClaim() - close(firstDone) - }() - <-paused - // Another owner's round trip, then a newer claim. + require.Positive(t, c.UIBackground(stay)) + push := c.PushWindowBegin() + require.Positive(t, push) + live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + synced := make(chan string, 1) + go func() { synced <- c.BackgroundSync() }() + require.Eventually(t, func() bool { return lifecycle.Holds(c) == 4 }, 5*time.Second, time.Millisecond) + c.UIInactive() + select { + case msg := <-synced: + require.Contains(t, msg, "bailing out early") + case <-time.After(5 * time.Second): + require.Fail(t, "BackgroundSync kept its window after the UI left the background") + } + require.Equal(t, 2, lifecycle.Holds(c)) + require.Zero(t, c.PushWindowEnd(push, stay)) + require.True(t, live.Release()) + require.Equal(t, 0, lifecycle.Holds(c)) + require.Equal(t, inactive, appState.State()) +} + +func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { + appState, _ := newAppState(t) appState.Update(background) - c.LiveLocationClaim() - close(release) - requireDone(t, firstDone) - c.LiveLocationRelease() + c := lifecycle.New(appState, lifecycle.Config{}) + require.Positive(t, c.UIBackground(stay)) + push := c.PushWindowBegin() + live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + 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") + require.Zero(t, c.PushWindowEnd(push, noStay)) + require.True(t, live.Release()) + require.Equal(t, background, appState.State()) +} + +func TestWillTerminateEndsEveryHold(t *testing.T) { + appState, _ := newAppState(t) + c := lifecycle.New(appState, lifecycle.Config{}) + live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + require.Zero(t, c.PushWindowBegin(), "no push window in the foreground") + c.UIInactive() + push := c.PushWindowBegin() + require.Positive(t, push) + c.WillTerminate(noop) + require.Equal(t, 0, lifecycle.Holds(c)) + require.True(t, live.Released()) require.Equal(t, background, appState.State()) + require.Zero(t, c.PushWindowEnd(push, stay)) } -func TestPushWindowEndStaleTokenSkipsStayRunning(t *testing.T) { +func TestPushWindowEndOutsideBackgroundSkipsStayRunning(t *testing.T) { appState, _ := newAppState(t) appState.Update(background) c := lifecycle.New(appState, lifecycle.Config{}) token := c.PushWindowBegin() - c.DidBecomeActive() + c.UIInactive() called := false - require.False(t, c.PushWindowEnd(token, func() bool { - called = true - return true - })) + require.Zero(t, c.PushWindowEnd(token, func() bool { called = true; return true })) require.False(t, called) - require.False(t, c.PushWindowEnd(-1, stay)) - require.Equal(t, foreground, appState.State()) + require.Zero(t, c.PushWindowEnd(-1, stay)) + require.Equal(t, inactive, appState.State()) + require.Equal(t, 0, lifecycle.Holds(c)) } // Native gives these last events only a short wait, so the state change and @@ -150,11 +171,11 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { do func(c *lifecycle.Controller, notifyPending func()) }{ "willTerminate": { - prepare: func(c *lifecycle.Controller) { c.DidBecomeActive() }, + prepare: func(c *lifecycle.Controller) { c.UIActive() }, do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, }, "backgroundTaskExpired": { - prepare: func(c *lifecycle.Controller) { require.True(t, c.DidEnterBackground(stay)) }, + prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(stay)) }, do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) }, }, } @@ -177,16 +198,17 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { } func TestEventString(t *testing.T) { - require.Equal(t, "willEnterForeground", lifecycle.EventWillEnterForeground.String()) - require.Equal(t, "liveLocationRelease", lifecycle.EventLiveLocationRelease.String()) + require.Equal(t, "uiInactive", lifecycle.EventUIInactive.String()) + require.Equal(t, "release", lifecycle.EventRelease.String()) require.Equal(t, "Event(99)", lifecycle.Event(99).String()) + require.Equal(t, "liveLocation", lifecycle.ReasonLiveLocation.String()) } -// Owners run concurrently with lifecycle events, then each phase ends on a -// known last event and checks nothing is left stuck: FOREGROUND stays -// FOREGROUND, a background task window closes to BACKGROUND, and a plain -// BACKGROUND stays BACKGROUND. Owner goroutines must all exit. -func TestOwnersStress(t *testing.T) { +// 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 must all exit. +func TestHoldsStress(t *testing.T) { appState, _ := newAppState(t) appState.Update(background) c := lifecycle.New(appState, lifecycle.Config{ @@ -194,13 +216,6 @@ func TestOwnersStress(t *testing.T) { BackgroundTaskPollInterval: time.Millisecond, BackgroundTaskMaxDuration: time.Minute, }) - // Widen the gap between opening a window and recording it, where a - // competing opener can slip in. - lifecycle.SetTestHookAfterWindowUpdate(c, func() { - if rand.Intn(2) == 0 { - time.Sleep(time.Duration(rand.Intn(200)) * time.Microsecond) - } - }) baseline := runtime.NumGoroutine() chaos := func(t *testing.T, iterations int) { @@ -227,75 +242,79 @@ func TestOwnersStress(t *testing.T) { if r.Intn(2) == 0 { time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) } - if c.PushWindowEnd(token, func() bool { return r.Intn(3) == 0 }) { - c.RunBackgroundTask(context.Background(), noDeliveries()) + if task := c.PushWindowEnd(token, func() bool { return r.Intn(3) == 0 }); task > 0 { + c.RunBackgroundTask(context.Background(), task, noDeliveries()) } }) runOwner(func(*rand.Rand) { c.BackgroundSync() }) runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) runOwner(func(r *rand.Rand) { - c.LiveLocationClaim() + h := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) - c.LiveLocationRelease() + h.Release() }) } r := rand.New(rand.NewSource(time.Now().UnixNano())) for range iterations { - switch r.Intn(6) { + switch r.Intn(5) { case 0: - c.DidBecomeActive() + c.UIActive() case 1: - c.WillEnterForeground() + c.UIInactive() case 2: - c.WillResignActive() + if task := c.UIBackground(func() bool { return r.Intn(2) == 0 }); task > 0 { + owners.Add(1) + go func() { + defer owners.Done() + c.RunBackgroundTask(context.Background(), task, noDeliveries()) + }() + } case 3: - c.DidEnterBackground(func() bool { return r.Intn(2) == 0 }) + c.UIBackground(noStay) case 4: - c.DidEnterBackground(noStay) - case 5: if r.Intn(10) == 0 { c.WillTerminate(noop) } } time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond) } - c.DidBecomeActive() + 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) }) - // Android's process stop and the push service both open a window and - // start a task; the newest window must close once the tasks are done. - t.Run("ends in background task", func(t *testing.T) { + t.Run("ends in background", func(t *testing.T) { + chaos(t, 300) + c.UIBackground(noStay) + 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) - var tasks sync.WaitGroup + c.UIBackground(noStay) + var holders sync.WaitGroup for range 4 { - tasks.Add(1) + holders.Add(1) go func() { - defer tasks.Done() - if c.DidEnterBackground(stay) { - c.RunBackgroundTask(context.Background(), noDeliveries()) - } + defer holders.Done() + c.AcquireBackgroundWork(lifecycle.ReasonPushWindow).Release() }() } - waitGroupWithin(t, &tasks, "background tasks deadlocked") + waitGroupWithin(t, &holders, "holders deadlocked") require.Equal(t, background, appState.State()) + require.Equal(t, 0, lifecycle.Holds(c)) } }) - t.Run("ends in background", func(t *testing.T) { - chaos(t, 300) - c.DidEnterBackground(noStay) - require.Equal(t, background, appState.State()) - }) - // 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); { diff --git a/go/libkb/lifecycle/export_test.go b/go/libkb/lifecycle/export_test.go index a18729ed23d1..cd2cd9673b58 100644 --- a/go/libkb/lifecycle/export_test.go +++ b/go/libkb/lifecycle/export_test.go @@ -3,6 +3,8 @@ package lifecycle -func SetTestHookAfterWindowUpdate(c *Controller, hook func()) { c.testHookAfterWindowUpdate = hook } - -func TaskGen(c *Controller) uint64 { return c.taskGen.Load() } +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 index 7a8651644e67..44e00ba603e2 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -1,10 +1,10 @@ // Copyright 2026 Keybase, Inc. All rights reserved. Use of // this source code is governed by the included BSD license. -// Package lifecycle turns the mobile app's lifecycle events, as reported by -// native code, into MobileAppState updates. Owners of a transition (background -// sync, background tasks, push windows, live location) undo only their own -// transition, by generation. +// 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. @@ -14,7 +14,8 @@ import ( "context" "errors" "fmt" - "sync/atomic" + "slices" + "sync" "time" "github.com/keybase/client/go/protocol/chat1" @@ -23,40 +24,85 @@ import ( "golang.org/x/sync/errgroup" ) +type UIState int + +const ( + UIBackground UIState = iota + UIInactive + UIActive +) + +func (s UIState) String() string { + switch s { + case UIBackground: + return "background" + case UIInactive: + return "inactive" + case UIActive: + return "active" + default: + return fmt.Sprintf("UIState(%d)", int(s)) + } +} + +// Reason says what a hold keeps running, and so which events end it. +type Reason int + +const ( + ReasonLaunch Reason = iota + 1 + ReasonBackgroundTask + ReasonBackgroundSync + ReasonPushWindow + ReasonLiveLocation +) + +var reasonNames = map[Reason]string{ + ReasonLaunch: "launch", + ReasonBackgroundTask: "backgroundTask", + ReasonBackgroundSync: "backgroundSync", + ReasonPushWindow: "pushWindow", + ReasonLiveLocation: "liveLocation", +} + +func (r Reason) String() string { + if name, ok := reasonNames[r]; ok { + return name + } + return fmt.Sprintf("Reason(%d)", int(r)) +} + type Event int const ( - EventWillEnterForeground Event = iota - EventDidBecomeActive - EventWillResignActive - EventDidEnterBackground + EventUIActive Event = iota + EventUIInactive + EventUIBackground EventWillTerminate + EventBackgroundTaskExpired EventBackgroundTaskBegin EventBackgroundTaskEnd - EventBackgroundTaskExpired EventPushWindowBegin EventPushWindowEnd EventBackgroundSyncBegin EventBackgroundSyncEnd - EventLiveLocationClaim - EventLiveLocationRelease + EventAcquire + EventRelease ) var eventNames = map[Event]string{ - EventWillEnterForeground: "willEnterForeground", - EventDidBecomeActive: "didBecomeActive", - EventWillResignActive: "willResignActive", - EventDidEnterBackground: "didEnterBackground", + EventUIActive: "uiActive", + EventUIInactive: "uiInactive", + EventUIBackground: "uiBackground", EventWillTerminate: "willTerminate", + EventBackgroundTaskExpired: "backgroundTaskExpired", EventBackgroundTaskBegin: "backgroundTaskBegin", EventBackgroundTaskEnd: "backgroundTaskEnd", - EventBackgroundTaskExpired: "backgroundTaskExpired", EventPushWindowBegin: "pushWindowBegin", EventPushWindowEnd: "pushWindowEnd", EventBackgroundSyncBegin: "backgroundSyncBegin", EventBackgroundSyncEnd: "backgroundSyncEnd", - EventLiveLocationClaim: "liveLocationClaim", - EventLiveLocationRelease: "liveLocationRelease", + EventAcquire: "acquire", + EventRelease: "release", } func (e Event) String() string { @@ -69,11 +115,7 @@ func (e Event) String() string { // AppState is the part of libkb.MobileAppState the controller drives. type AppState interface { State() keybase1.MobileAppState - StateAndGeneration() (keybase1.MobileAppState, uint64) Update(state keybase1.MobileAppState) (changed bool) - UpdateWithCheck(state keybase1.MobileAppState, check func(keybase1.MobileAppState) bool) ( - newGen uint64, applied bool, changed bool) - UpdateIfGeneration(gen uint64, state keybase1.MobileAppState) (newGen uint64, applied bool, changed bool) NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} } @@ -90,25 +132,56 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // Flush runs after every real change into BACKGROUND, and into a - // background task window, where the OS may suspend or kill the process - // next. It must not block. + // Flush runs when the UI enters the background and when the state + // changes into 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 { + 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 chan struct{} +} + +func (h *Hold) ID() int64 { return h.id } + +// Done is closed once the hold has ended, by Release or by the controller. +func (h *Hold) Done() <-chan struct{} { return h.done } + +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.id) } + type Controller struct { appState AppState cfg Config - // taskGen is the generation of the open background task window, 0 when - // none is open. - taskGen atomic.Uint64 - // liveLocationGen is the generation of live location's claim, 0 when it - // holds none. - liveLocationGen atomic.Uint64 - // testHookAfterWindowUpdate runs between opening a window and recording - // its generation. - testHookAfterWindowUpdate func() + + // mu serializes every UI report and hold change with the state it writes. + mu sync.Mutex + ui UIState + nextID int64 + holds map[int64]*Hold } func New(appState AppState, cfg Config) *Controller { @@ -130,230 +203,261 @@ func New(appState AppState, cfg Config) *Controller { if cfg.Debug == nil { cfg.Debug = func(string, ...interface{}) {} } - return &Controller{appState: appState, cfg: cfg} + c := &Controller{appState: appState, cfg: cfg, holds: make(map[int64]*Hold)} + switch appState.State() { + case keybase1.MobileAppState_FOREGROUND: + c.ui = UIActive + case keybase1.MobileAppState_INACTIVE: + c.ui = UIInactive + case keybase1.MobileAppState_BACKGROUNDACTIVE: + // Android starts its process up; the first UI report ends this. + c.ui = UIBackground + c.acquireLocked(ReasonLaunch) + default: + c.ui = UIBackground + } + return c } -func (c *Controller) debug(ev Event, format string, args ...interface{}) { - state, gen := c.appState.StateAndGeneration() - c.cfg.Debug("lifecycle: %v: %s (state: %v, generation: %d)", ev, fmt.Sprintf(format, args...), state, gen) +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) update(state keybase1.MobileAppState) { - if c.appState.Update(state) && state == keybase1.MobileAppState_BACKGROUND { +func (c *Controller) debugLocked(ev Event, format string, args ...interface{}) { + c.cfg.Debug("lifecycle: %v: %s (ui: %v, holds: %d, state: %v)", ev, 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 UI just entered the background or the state just changed +// into BACKGROUND. +func (c *Controller) applyLocked(uiEnteredBackground bool) { + state := derive(c.ui, len(c.holds)) + changed := c.appState.Update(state) + if uiEnteredBackground || (changed && state == keybase1.MobileAppState_BACKGROUND) { c.cfg.Flush() } } -// recordGen raises owner to gen. Opening a window and recording it are -// separate steps, so concurrent openers can record out of order; only raising -// keeps the newest window recorded. -func (c *Controller) recordGen(owner *atomic.Uint64, gen uint64) { - if c.testHookAfterWindowUpdate != nil { - c.testHookAfterWindowUpdate() - } - for { - cur := owner.Load() - if cur >= gen || owner.CompareAndSwap(cur, gen) { - return - } - } +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 } -// undoToBackground returns to BACKGROUND only if nothing has updated the app -// state since the owner's own transition at gen. -func (c *Controller) undoToBackground(gen uint64) (applied bool) { - if gen == 0 { +func (c *Controller) dropLocked(id int64) bool { + h, ok := c.holds[id] + if !ok { return false } - _, applied, changed := c.appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUND) - if changed { - c.cfg.Flush() + delete(c.holds, id) + close(h.done) + return true +} + +func (c *Controller) dropReasonsLocked(reasons ...Reason) (dropped int) { + for id, h := range c.holds { + if slices.Contains(reasons, h.reason) && c.dropLocked(id) { + dropped++ + } } - return applied + return dropped } -func always(keybase1.MobileAppState) bool { return true } +// setUILocked records a UI report. Any report ends the launch hold; leaving +// the background ends the holds that only keep a backgrounded app alive. +func (c *Controller) setUILocked(ui UIState) (enteredBackground bool) { + c.dropReasonsLocked(ReasonLaunch) + prev := c.ui + c.ui = ui + if prev == UIBackground && ui != UIBackground { + c.dropReasonsLocked(ReasonBackgroundTask, ReasonBackgroundSync) + } + return prev != UIBackground && ui == UIBackground +} -func isState(want keybase1.MobileAppState) func(keybase1.MobileAppState) bool { - return func(s keybase1.MobileAppState) bool { return s == want } +// AcquireBackgroundWork opens a hold that keeps a backgrounded app +// BACKGROUNDACTIVE until it is released. +func (c *Controller) AcquireBackgroundWork(reason Reason) *Hold { + c.mu.Lock() + defer c.mu.Unlock() + h := c.acquireLocked(reason) + c.applyLocked(false) + c.debugLocked(EventAcquire, "%v hold %d", reason, h.id) + return h } -// WillEnterForeground brings networking up before the UI resumes, without -// claiming the user is looking at the app yet. -func (c *Controller) WillEnterForeground() { - c.update(keybase1.MobileAppState_BACKGROUNDACTIVE) - c.debug(EventWillEnterForeground, "applied") +func (c *Controller) release(id int64) bool { + c.mu.Lock() + defer c.mu.Unlock() + h, ok := c.holds[id] + if !ok { + return false + } + c.dropLocked(id) + c.applyLocked(false) + c.debugLocked(EventRelease, "%v hold %d", h.reason, id) + return true } -func (c *Controller) DidBecomeActive() { - c.update(keybase1.MobileAppState_FOREGROUND) - c.debug(EventDidBecomeActive, "applied") +func (c *Controller) UIActive() { + c.mu.Lock() + defer c.mu.Unlock() + c.applyLocked(c.setUILocked(UIActive)) + c.debugLocked(EventUIActive, "applied") } -// WillResignActive covers the app being on screen without receiving events: -// Control Center, system alerts, the app switcher, iPad focus loss. -func (c *Controller) WillResignActive() { - c.update(keybase1.MobileAppState_INACTIVE) - c.debug(EventWillResignActive, "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.applyLocked(c.setUILocked(UIInactive)) + c.debugLocked(EventUIInactive, "applied") } -// DidEnterBackground moves to BACKGROUND, or, when stayRunning says work must -// keep going, opens a BACKGROUNDACTIVE window for a background task and -// returns true. -func (c *Controller) DidEnterBackground(stayRunning func() bool) bool { - if !stayRunning() { - c.taskGen.Store(0) - c.update(keybase1.MobileAppState_BACKGROUND) - c.debug(EventDidEnterBackground, "no work to keep running") - return false - } - gen, _, changed := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, always) - c.recordGen(&c.taskGen, gen) - // The OS may still suspend or kill us once the background task runs out. - if changed { - c.cfg.Flush() +// UIBackground records the UI leaving the screen. When stayRunning says work +// must keep going it opens a background task hold and returns its token for +// RunBackgroundTask; otherwise it returns 0. +func (c *Controller) UIBackground(stayRunning func() bool) int64 { + stay := stayRunning() + c.mu.Lock() + defer c.mu.Unlock() + entered := c.setUILocked(UIBackground) + var token int64 + if stay { + token = c.acquireLocked(ReasonBackgroundTask).id } - c.debug(EventDidEnterBackground, "opened background task window at %d", gen) - return true + c.applyLocked(entered) + c.debugLocked(EventUIBackground, "background task hold %d", token) + return token } -// WillTerminate forces BACKGROUND regardless of owners: the process is about -// to die. notifyPending warns about messages that won't send. It runs last: -// it can take seconds (an outbox query and a local notification), and native -// only waits briefly before the process exits, so the state change and the -// flush must not wait behind it. +// 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.taskGen.Store(0) - c.update(keybase1.MobileAppState_BACKGROUND) + c.mu.Lock() + entered := c.setUILocked(UIBackground) + for id := range c.holds { + c.dropLocked(id) + } + c.applyLocked(entered) + c.debugLocked(EventWillTerminate, "ended every hold") + c.mu.Unlock() notifyPending() - c.debug(EventWillTerminate, "applied") } -// BackgroundTaskExpired ends the background task window without clobbering a -// state reported after the window opened, such as a return to the foreground. -// notifyPending runs only when the window was still open, since otherwise we -// aren't about to be suspended. +// BackgroundTaskExpired ends every background task hold: iOS is ending the +// app's background time. Native drops stale expirations, so these are the +// current entry's holds and any older ones still running. Live location, +// push window and sync holds keep their own lifetimes. func (c *Controller) BackgroundTaskExpired(notifyPending func()) { - gen := c.taskGen.Swap(0) - applied := c.undoToBackground(gen) - if applied { + c.mu.Lock() + ended := c.dropReasonsLocked(ReasonBackgroundTask) + c.applyLocked(false) + c.debugLocked(EventBackgroundTaskExpired, "ended %d background task holds", ended) + c.mu.Unlock() + if ended > 0 { notifyPending() } - c.debug(EventBackgroundTaskExpired, "window %d closed: %v", gen, applied) } -// PushWindowBegin moves to BACKGROUNDACTIVE while a push is handled, unless -// the app is in the foreground. It returns the token for PushWindowEnd, or 0 -// if the app is in the foreground. +// 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 { - gen, applied, _ := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - func(s keybase1.MobileAppState) bool { return s != keybase1.MobileAppState_FOREGROUND }) - if !applied { - c.debug(EventPushWindowBegin, "skipped in the foreground") + c.mu.Lock() + defer c.mu.Unlock() + if c.ui == UIActive { + c.debugLocked(EventPushWindowBegin, "skipped in the foreground") return 0 } - c.debug(EventPushWindowBegin, "opened at %d", gen) - return int64(gen) + h := c.acquireLocked(ReasonPushWindow) + c.applyLocked(false) + c.debugLocked(EventPushWindowBegin, "hold %d", h.id) + return h.id } -// PushWindowEnd closes the window opened at token, only if nothing has updated -// the app state since. It returns true when it hands the window over to a -// background task (as DidEnterBackground does), and false when it moved to -// BACKGROUND or someone else owns the state now. -func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) bool { +// PushWindowEnd ends the push window's hold. If the UI is still in the +// background and work must keep going, it first opens a background task hold +// and returns its token. +func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) int64 { if token <= 0 { - return false + return 0 } - gen := uint64(token) - if _, cur := c.appState.StateAndGeneration(); cur != gen { - c.debug(EventPushWindowEnd, "window %d superseded", gen) - return false + c.mu.Lock() + h, ok := c.holds[token] + query := ok && h.reason == ReasonPushWindow && c.ui == UIBackground + c.mu.Unlock() + stay := query && stayRunning() + c.mu.Lock() + defer c.mu.Unlock() + var task int64 + if stay && c.ui == UIBackground { + task = c.acquireLocked(ReasonBackgroundTask).id } - if stayRunning() { - newGen, applied, _ := c.appState.UpdateIfGeneration(gen, keybase1.MobileAppState_BACKGROUNDACTIVE) - if !applied { - c.debug(EventPushWindowEnd, "window %d superseded", gen) - return false - } - c.recordGen(&c.taskGen, newGen) - c.debug(EventPushWindowEnd, "window %d handed to background task at %d", gen, newGen) - return true + if h, ok := c.holds[token]; ok && h.reason == ReasonPushWindow { + c.dropLocked(token) } - applied := c.undoToBackground(gen) - c.debug(EventPushWindowEnd, "window %d closed: %v", gen, applied) - return false + c.applyLocked(false) + c.debugLocked(EventPushWindowEnd, "hold %d ended, background task hold %d", token, task) + return task } -// BackgroundSync moves BACKGROUND to BACKGROUNDACTIVE for the sync window, -// then undoes that transition unless someone else updated the state meanwhile. -// It returns a status for native logs. +// 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 { - gen, applied, _ := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - isState(keybase1.MobileAppState_BACKGROUND)) - if !applied { + c.mu.Lock() + if c.ui != UIBackground { msg := "skipping, app not in background state: " + c.appState.State().String() - c.debug(EventBackgroundSyncBegin, "%s", msg) + c.debugLocked(EventBackgroundSyncBegin, "%s", msg) + c.mu.Unlock() return msg } - c.debug(EventBackgroundSyncBegin, "opened at %d", gen) - timer := c.cfg.Clock.After(c.cfg.BackgroundSyncWindow) + h := c.acquireLocked(ReasonBackgroundSync) + c.applyLocked(false) + c.debugLocked(EventBackgroundSyncBegin, "hold %d", h.id) + c.mu.Unlock() var msg string select { - case <-c.appState.NextUpdate(keybase1.MobileAppState_BACKGROUNDACTIVE): - msg = "bailing out early, appstate change: " + c.appState.State().String() - case <-timer: - if c.undoToBackground(gen) { - msg = "completed window" - } else { - msg = "completed window, app state updated meanwhile: " + c.appState.State().String() - } + case <-h.Done(): + msg = "bailing out early, hold ended: " + c.appState.State().String() + case <-c.cfg.Clock.After(c.cfg.BackgroundSyncWindow): + msg = "completed window" } - c.debug(EventBackgroundSyncEnd, "%s", msg) + h.Release() + c.mu.Lock() + c.debugLocked(EventBackgroundSyncEnd, "%s", msg) + c.mu.Unlock() return msg } -// LiveLocationClaim moves BACKGROUND to BACKGROUNDACTIVE while live location -// is tracking, so location updates get out. -func (c *Controller) LiveLocationClaim() { - gen, applied, _ := c.appState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE, - isState(keybase1.MobileAppState_BACKGROUND)) - if !applied { - return - } - c.recordGen(&c.liveLocationGen, gen) - c.debug(EventLiveLocationClaim, "claimed at %d", gen) -} - -// LiveLocationRelease returns to BACKGROUND, flushing like every other return -// to BACKGROUND, only if nothing has updated the app state since the claim. -func (c *Controller) LiveLocationRelease() { - gen := c.liveLocationGen.Swap(0) - if gen == 0 { - return - } - applied := c.undoToBackground(gen) - c.debug(EventLiveLocationRelease, "claim %d released: %v", gen, applied) -} - -type BackgroundTaskDeps struct { - ActiveDeliveries func(context.Context) ([]chat1.OutboxRecord, error) - NextFailure func() (chan []chat1.OutboxRecord, func()) - NotifyFailure func([]chat1.OutboxRecord) -} - -// RunBackgroundTask waits while the background task window opened by -// DidEnterBackground or PushWindowEnd is still current, until outgoing -// messages are delivered, one fails, time runs out or ctx is done; then it -// returns to BACKGROUND unless someone else has updated the app state since -// the window opened. -func (c *Controller) RunBackgroundTask(ctx context.Context, deps BackgroundTaskDeps) { - gen := c.taskGen.Load() - state, cur := c.appState.StateAndGeneration() - if state != keybase1.MobileAppState_BACKGROUNDACTIVE || gen == 0 || cur != gen { - c.debug(EventBackgroundTaskBegin, "no background task window, early out") +// RunBackgroundTask keeps the background task hold at token until outgoing +// messages are delivered, one fails, time runs out, the hold is ended (the UI +// left the background, expiration, termination) or ctx is done. +func (c *Controller) RunBackgroundTask(ctx context.Context, token int64, deps BackgroundTaskDeps) { + c.mu.Lock() + // Task holds exist only while the UI is in the background: leaving it ends them. + h, ok := c.holds[token] + if !ok || h.reason != ReasonBackgroundTask { + c.debugLocked(EventBackgroundTaskBegin, "hold %d not open, early out", token) + c.mu.Unlock() return } - c.debug(EventBackgroundTaskBegin, "window %d", gen) + c.debugLocked(EventBackgroundTaskBegin, "hold %d", token) + c.mu.Unlock() clock := c.cfg.Clock // Round(0) drops the monotonic reading, so time the device spends asleep // counts toward the maximum. @@ -361,8 +465,8 @@ func (c *Controller) RunBackgroundTask(ctx context.Context, deps BackgroundTaskD g, ctx := errgroup.WithContext(ctx) g.Go(func() error { select { - case <-c.appState.NextUpdate(state): - return errors.New("app state change") + case <-h.Done(): + return errors.New("hold ended") case <-ctx.Done(): return ctx.Err() } @@ -406,7 +510,8 @@ func (c *Controller) RunBackgroundTask(ctx context.Context, deps BackgroundTaskD } }) err := g.Wait() - // A matching CAS also clears the window, so a later expiration is a no-op. - closed := c.taskGen.CompareAndSwap(gen, 0) && c.undoToBackground(gen) - c.debug(EventBackgroundTaskEnd, "window %d done because: %v, closed: %v", gen, err, closed) + released := h.Release() + c.mu.Lock() + c.debugLocked(EventBackgroundTaskEnd, "hold %d done because: %v, released: %v", token, err, released) + c.mu.Unlock() } diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index c5889b048e07..b355c2c967ac 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -46,7 +46,9 @@ const ( // pauses the activity. Nothing Action = iota + 1 - // Native lifecycle events. + // Native lifecycle events, as native reports them: willEnterForeground and + // willResignActive are UIInactive, didBecomeActive is UIActive, + // didEnterBackground is UIBackground. WillEnterForeground DidBecomeActive WillResignActive @@ -135,9 +137,6 @@ type Step struct { // Slot names the push window for PushWindowBegin/End. Slot int Want keybase1.MobileAppState - // Gen is how much the generation moves: 1 for an accepted update, even a - // same-value one, 0 for a rejected or skipped one. - Gen int // Flush: local DBs were flushed. Flush bool // Warn: the user was warned about messages that won't send. @@ -169,6 +168,10 @@ type Harness struct { pending atomic.Int32 failures chan []chat1.OutboxRecord tokens map[int]int64 + // taskToken is the background task hold the last UIBackground or + // PushWindowEnd opened, for BackgroundTaskStart. + taskToken int64 + liveLocation *lifecycle.Hold cancel context.CancelFunc ctx context.Context @@ -265,17 +268,13 @@ func (h *Harness) wait(done chan struct{}, what string) { func (h *Harness) Do(step Step) { t := h.T t.Helper() - _, gen := h.AppState.StateAndGeneration() flushes, warnings := h.Flushes(), h.Warnings() ret := h.perform(step) h.Recorder.Sync(t) - state, newGen := h.AppState.StateAndGeneration() + state := h.AppState.State() if state != step.Want { t.Fatalf("%v: state %v, want %v", step.Do, state, step.Want) } - if got := newGen - gen; got != uint64(step.Gen) { - t.Fatalf("%v: generation moved by %d, want %d", step.Do, got, step.Gen) - } if got := h.Flushes() - flushes; got != boolInt(step.Flush) { t.Fatalf("%v: %d flushes, want %d", step.Do, got, boolInt(step.Flush)) } @@ -299,14 +298,13 @@ func (h *Harness) perform(step Step) bool { c := h.Controller switch step.Do { case Nothing: - case WillEnterForeground: - c.WillEnterForeground() + case WillEnterForeground, WillResignActive: + c.UIInactive() case DidBecomeActive: - c.DidBecomeActive() - case WillResignActive: - c.WillResignActive() + c.UIActive() case DidEnterBackground: - return c.DidEnterBackground(h.stayRunning) + h.taskToken = c.UIBackground(h.stayRunning) + return h.taskToken > 0 case WillTerminate: c.WillTerminate(h.warn) case BackgroundTaskExpired: @@ -315,11 +313,20 @@ func (h *Harness) perform(step Step) bool { h.tokens[step.Slot] = c.PushWindowBegin() return h.tokens[step.Slot] > 0 case PushWindowEnd: - return c.PushWindowEnd(h.tokens[step.Slot], h.stayRunning) + if task := c.PushWindowEnd(h.tokens[step.Slot], h.stayRunning); task > 0 { + h.taskToken = task + return true + } + return false case LiveLocationClaim: - c.LiveLocationClaim() + if h.liveLocation == nil || h.liveLocation.Released() { + h.liveLocation = c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + } case LiveLocationRelease: - c.LiveLocationRelease() + if h.liveLocation != nil { + h.liveLocation.Release() + h.liveLocation = nil + } case BackgroundSyncStart: h.syncDone = h.goRun(func() { c.BackgroundSync() }) return h.Clock.WaitForAfter(h.T, syncWindow, h.syncDone) @@ -329,7 +336,8 @@ func (h *Harness) perform(step Step) bool { case BackgroundSyncWait: h.wait(h.syncDone, "BackgroundSync") case BackgroundTaskStart: - h.taskDone = h.goRun(func() { c.RunBackgroundTask(h.ctx, h.deps()) }) + token := h.taskToken + h.taskDone = h.goRun(func() { c.RunBackgroundTask(h.ctx, token, h.deps()) }) return h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone) case BackgroundTaskDelivered: h.pending.Store(0) diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index f226b777aafe..1ca611bc1f65 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -12,9 +12,7 @@ const ( ina = keybase1.MobileAppState_INACTIVE ) -func step(do Action, want keybase1.MobileAppState, gen int) Step { - return Step{Do: do, Want: want, Gen: gen} -} +func step(do Action, want keybase1.MobileAppState) Step { return Step{Do: do, Want: want} } func (s Step) flush() Step { s.Flush = true @@ -53,331 +51,327 @@ func states(s ...keybase1.MobileAppState) []keybase1.MobileAppState { return s } // iosLaunch brings a freshly started iOS service (BACKGROUND) to the // foreground: the scene connects and becomes active. var iosLaunch = []Step{ - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), } // iosToBackgroundTask backgrounds a foreground app with a message still // sending, and starts the background task. var iosToBackgroundTask = []Step{ - step(WorkStarts, fg, 0), - step(WillResignActive, ina, 1), - step(DidEnterBackground, bga, 1).flush().returns(true), - step(BackgroundTaskStart, bga, 0).returns(true), + step(WorkStarts, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bga).flush().returns(true), + step(BackgroundTaskStart, bga).returns(true), } -// androidStart is the process lifecycle's start and resume, from any state. -// The observed states assume it starts from BACKGROUND. +// androidStart is the process lifecycle's start and resume. var androidStart = []Step{ - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), } -// androidLaunch starts the UI in a fresh process (BACKGROUNDACTIVE). +// androidLaunch starts the UI in a fresh process (BACKGROUNDACTIVE until the first report). var androidLaunch = androidStart // 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: iosLaunch, - Observed: states(bg, bga, fg), - }, + {Name: "ios cold foreground launch", Platform: IOS, Steps: iosLaunch, Observed: states(bg, ina, fg)}, { Name: "ios background launch by silent push stays in the background, then foreground", Platform: IOS, - Steps: steps([]Step{ - step(Nothing, bg, 0), - step(BackgroundTaskExpired, bg, 0), - }, iosLaunch), - Observed: states(bg, bga, fg), + Steps: steps([]Step{step(Nothing, bg), step(BackgroundTaskExpired, bg)}, iosLaunch), + Observed: states(bg, ina, fg), }, { Name: "ios background launch by BGAppRefresh, then foreground", Platform: IOS, Steps: steps([]Step{ - step(BackgroundSyncStart, bga, 1).returns(true), - step(BackgroundSyncTimerFires, bg, 1).flush(), + step(BackgroundSyncStart, bga).returns(true), + step(BackgroundSyncTimerFires, bg).flush(), }, iosLaunch), - Observed: states(bg, bga, bg, bga, fg), + Observed: states(bg, bga, bg, ina, fg), }, { Name: "ios home and return", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WillResignActive, ina, 1), - step(DidEnterBackground, bg, 1).flush().returns(false), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flush().returns(false), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, bg, bga, fg), + Observed: states(bg, ina, fg, ina, bg, ina, fg), }, { Name: "ios quick background and foreground cycles with duplicate events", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WillResignActive, ina, 1), - step(WillResignActive, ina, 1), - step(DidEnterBackground, bg, 1).flush().returns(false), - step(DidEnterBackground, bg, 1).returns(false), - step(WillEnterForeground, bga, 1), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), - step(DidBecomeActive, fg, 1), + step(WillResignActive, ina), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flush().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, 1), - step(DidBecomeActive, fg, 1), - step(WillResignActive, ina, 1), - step(DidEnterBackground, bg, 1).flush().returns(false), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), + step(WillResignActive, ina), + step(DidBecomeActive, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flush().returns(false), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, bg, bga, fg, ina, fg, ina, bg, bga, fg), + Observed: states(bg, ina, fg, ina, bg, ina, fg, ina, fg, ina, bg, ina, fg), }, { Name: "ios control center or system alert keeps things up", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WillResignActive, ina, 1), - step(DidBecomeActive, fg, 1), - step(WillResignActive, ina, 1), - step(DidBecomeActive, fg, 1), + step(WillResignActive, ina), step(DidBecomeActive, fg), + step(WillResignActive, ina), step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, fg, ina, fg), + Observed: states(bg, ina, fg, ina, fg, ina, fg), }, { Name: "ipad focus loss keeps things up", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WillResignActive, ina, 1), - step(WillResignActive, ina, 1), - step(DidBecomeActive, fg, 1), - step(WillResignActive, ina, 1), - step(DidBecomeActive, fg, 1), - step(DidBecomeActive, fg, 1), + step(WillResignActive, ina), step(WillResignActive, ina), step(DidBecomeActive, fg), + step(WillResignActive, ina), step(DidBecomeActive, fg), step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, fg, ina, fg), + Observed: states(bg, ina, fg, ina, fg, ina, fg), }, { Name: "ios lock and unlock", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WillResignActive, ina, 1), - step(DidEnterBackground, bg, 1).flush().returns(false), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flush().returns(false), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, bg, bga, fg), + Observed: states(bg, ina, fg, ina, 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, 1).returns(true), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), - step(BackgroundSyncWait, fg, 0), + step(BackgroundSyncStart, bga).returns(true), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundSyncWait, fg), }, - Observed: states(bg, bga, fg), + Observed: states(bg, bga, ina, fg), }, { Name: "ios slow didBecomeActive after the BackgroundSync window ends", Platform: IOS, Steps: []Step{ - step(BackgroundSyncStart, bga, 1).returns(true), - step(WillEnterForeground, bga, 1), - step(BackgroundSyncTimerFires, bga, 0), - step(DidBecomeActive, fg, 1), + step(BackgroundSyncStart, bga).returns(true), + step(WillEnterForeground, ina), + step(BackgroundSyncTimerFires, ina), + step(DidBecomeActive, fg), }, - Observed: states(bg, bga, fg), + Observed: states(bg, bga, ina, fg), }, { Name: "ios BackgroundSync skips outside the background", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(BackgroundSyncStart, fg, 0).returns(false), - step(WillResignActive, ina, 1), - step(BackgroundSyncStart, ina, 0).returns(false), + step(BackgroundSyncStart, fg).returns(false), + step(WillResignActive, ina), + step(BackgroundSyncStart, ina).returns(false), }), - Observed: states(bg, bga, fg, ina), + Observed: states(bg, ina, fg, ina), }, { Name: "ios background task completes", Platform: IOS, Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(BackgroundTaskDelivered, bg, 1).flush(), - step(BackgroundTaskExpired, bg, 0), + step(BackgroundTaskDelivered, bg).flush(), + step(BackgroundTaskExpired, bg), }, iosLaunch), - Observed: states(bg, bga, fg, ina, bga, bg, bga, fg), + Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), }, { Name: "ios background task fails", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(BackgroundTaskFails, bg, 1).flush().warn(), - }), - Observed: states(bg, bga, fg, ina, bga, bg), + Steps: steps(iosLaunch, 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(iosLaunch, iosToBackgroundTask, []Step{ - step(BackgroundTaskTimesUp, bg, 1).flush().warn(), - }), - Observed: states(bg, bga, fg, ina, bga, bg), + Steps: steps(iosLaunch, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}), + Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task expires", Platform: IOS, Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(BackgroundTaskExpired, bg, 1).flush().warn(), - step(BackgroundTaskWait, bg, 0), - step(BackgroundTaskExpired, bg, 0), + step(BackgroundTaskExpired, bg).flush().warn(), + step(BackgroundTaskWait, bg), + step(BackgroundTaskExpired, bg), }), - Observed: states(bg, bga, fg, ina, bga, bg), + Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task expires after return to foreground", Platform: IOS, Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), - step(BackgroundTaskWait, fg, 0), - step(BackgroundTaskExpired, fg, 0), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundTaskWait, fg), + step(BackgroundTaskExpired, fg), }), - Observed: states(bg, bga, fg, ina, bga, fg), + Observed: states(bg, ina, fg, ina, bga, ina, fg), }, { Name: "ios background task expires between willEnterForeground and didBecomeActive", Platform: IOS, Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(WillEnterForeground, bga, 1), - step(BackgroundTaskExpired, bga, 0), - // The same-value update doesn't wake the task; it finishes - // later and leaves the state alone. - step(BackgroundTaskDelivered, bga, 0), - step(DidBecomeActive, fg, 1), + step(WillEnterForeground, ina), + step(BackgroundTaskExpired, ina), + step(BackgroundTaskDelivered, ina), + step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, bga, 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(iosLaunch, iosToBackgroundTask, []Step{ - step(WillEnterForeground, bga, 1), - // The same-value update doesn't wake the task; when it finishes, - // the window is no longer current, so it leaves the state alone. - step(BackgroundTaskDelivered, bga, 0), - step(DidBecomeActive, fg, 1), + step(WillEnterForeground, ina), + step(BackgroundTaskDelivered, ina), + step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, bga, fg), + Observed: states(bg, ina, fg, ina, bga, ina, fg), }, { Name: "ios background task superseded before it starts", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WorkStarts, fg, 0), - step(WillResignActive, ina, 1), - step(DidEnterBackground, bga, 1).flush().returns(true), - step(WillEnterForeground, bga, 1), + step(WorkStarts, fg), + step(WillResignActive, ina), + step(DidEnterBackground, bga).flush().returns(true), + step(WillEnterForeground, ina), // Returning false means it exited without polling deliveries. - step(BackgroundTaskStart, bga, 0).returns(false), - step(DidBecomeActive, fg, 1), + step(BackgroundTaskStart, ina).returns(false), + step(DidBecomeActive, fg), }), - Observed: states(bg, bga, fg, ina, bga, fg), + Observed: states(bg, ina, fg, ina, bga, ina, fg), }, { Name: "ios live location across background", Platform: IOS, Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(BackgroundTaskDelivered, bg, 1).flush(), + step(BackgroundTaskDelivered, bg).flush(), // A location update wakes the app while tracking. - step(LiveLocationClaim, bga, 1), - step(LiveLocationClaim, bga, 0), + step(LiveLocationClaim, bga), + step(LiveLocationClaim, bga), // Tracking ends. - step(LiveLocationRelease, bg, 1).flush(), - step(LiveLocationRelease, bg, 0), - step(LiveLocationClaim, bga, 1), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), - step(LiveLocationRelease, fg, 0), - // Claims only from BACKGROUND. - step(LiveLocationClaim, fg, 0), + step(LiveLocationRelease, bg).flush(), + step(LiveLocationRelease, bg), + step(LiveLocationClaim, bga), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(LiveLocationRelease, fg), + // A claim in the foreground keeps the app running once it backgrounds. + step(LiveLocationClaim, 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), + }, + { + Name: "ios background task expiration keeps live location running", + Platform: IOS, + Steps: steps(iosLaunch, []Step{step(LiveLocationClaim, fg)}, iosToBackgroundTask, []Step{ + step(BackgroundTaskExpired, bga).warn(), + step(BackgroundTaskWait, bga), + step(LiveLocationRelease, bg).flush(), }), - Observed: states(bg, bga, fg, ina, bga, bg, bga, bg, bga, fg), + Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios termination from the background", Platform: IOS, Steps: steps(iosLaunch, []Step{ - step(WillResignActive, ina, 1), - step(DidEnterBackground, bg, 1).flush().returns(false), - step(WillTerminate, bg, 1).warn(), + step(WillResignActive, ina), + step(DidEnterBackground, bg).flush().returns(false), + step(WillTerminate, bg).warn(), }), - Observed: states(bg, bga, fg, ina, bg), + Observed: states(bg, ina, fg, ina, bg), }, { Name: "ios termination from the foreground", Platform: IOS, - Steps: steps(iosLaunch, []Step{ - step(WillTerminate, bg, 1).flush().warn(), - }), - Observed: states(bg, bga, fg, bg), + Steps: steps(iosLaunch, []Step{step(WillTerminate, bg).flush().warn()}), + Observed: states(bg, ina, fg, bg), }, { Name: "ios termination during a background task", Platform: IOS, Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ - step(WillTerminate, bg, 1).flush().warn(), - step(BackgroundTaskWait, bg, 0), - step(BackgroundTaskExpired, bg, 0), + step(WillTerminate, bg).flush().warn(), + step(BackgroundTaskWait, bg), + step(BackgroundTaskExpired, bg), }), - Observed: states(bg, bga, fg, ina, bga, bg), + Observed: states(bg, ina, fg, ina, bga, bg), }, { - Name: "android cold launch", - Platform: Android, - Steps: androidLaunch, - Observed: states(bga, fg), + Name: "ios termination ends live location's hold", + Platform: IOS, + Steps: steps(iosLaunch, []Step{ + step(LiveLocationClaim, 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: androidLaunch, Observed: states(bga, ina, fg)}, { Name: "android process stop and start", Platform: Android, Steps: steps(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), + step(DidEnterBackground, bg).flush().returns(false), }, androidStart, []Step{ - step(WorkStarts, fg, 0), - step(DidEnterBackground, bga, 1).flush().returns(true), - step(BackgroundTaskStart, bga, 0).returns(true), - // The same value, so the task keeps waiting, but the window is no - // longer its own. - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), - step(BackgroundTaskWait, fg, 0), + step(WorkStarts, fg), + step(DidEnterBackground, bga).flush().returns(true), + step(BackgroundTaskStart, bga).returns(true), + step(WillEnterForeground, ina), + step(DidBecomeActive, fg), + step(BackgroundTaskWait, fg), }), - Observed: states(bga, fg, bg, bga, fg, bga, fg), + Observed: states(bga, ina, fg, bg, ina, fg, bga, ina, fg), }, { Name: "android dialog, permission prompt or picker pause keeps the foreground", Platform: Android, Steps: steps(androidLaunch, []Step{ - step(Nothing, fg, 0), - step(PushWindowBegin, fg, 0).returns(false), - step(PushWindowEnd, fg, 0).returns(false), + 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, 1), + step(DidBecomeActive, fg), }), - Observed: states(bga, fg), + Observed: states(bga, ina, fg), }, { Name: "android background task without a window", Platform: Android, Steps: []Step{ - step(WorkStarts, bga, 0), - // Cold start is BACKGROUNDACTIVE, but no window was opened. - step(BackgroundTaskStart, bga, 0).returns(false), + step(WorkStarts, bga), + // Cold start holds the app up, but no background task hold was opened. + step(BackgroundTaskStart, bga).returns(false), }, Observed: states(bga), }, @@ -385,85 +379,80 @@ var Scenarios = []Scenario{ Name: "android push window in the background", Platform: Android, Steps: steps(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), - step(PushWindowEnd, bg, 1).flush().returns(false), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bga, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bg, bga, bg), }, { - // A process started without UI reports the background before the push - // window opens. + // A process started without UI reports the background before the push window opens. Name: "android push at cold start", Platform: Android, Steps: []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), - step(PushWindowEnd, bg, 1).flush().returns(false), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), }, Observed: states(bga, 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(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), - // The process start's first half matches the window's value, but - // still supersedes it. - step(WillEnterForeground, bga, 1), - step(PushWindowEnd, bga, 0).returns(false), - step(DidBecomeActive, fg, 1), - step(PushWindowBegin, fg, 0).returns(false), - step(PushWindowEnd, fg, 0).returns(false), - // Foreground and back to the background while the push is - // handled: the value matches, but the window isn't the push's. - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).returns(true), + step(WillEnterForeground, ina), + step(PushWindowEnd, ina).returns(false), + step(DidBecomeActive, fg), + step(PushWindowBegin, fg).returns(false), + step(PushWindowEnd, fg).returns(false), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).returns(true), }, androidStart, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowEnd, bg, 0).returns(false), + step(DidEnterBackground, bga).flush().returns(false), + step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bga, fg, bg, bga, fg, bg, bga, fg, bg), + Observed: states(bga, ina, fg, bg, bga, ina, fg, bg, bga, ina, fg, bga, bg), }, { Name: "android push window hands over to a background task", Platform: Android, Steps: steps(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), - step(WorkStarts, bga, 0), - step(PushWindowEnd, bga, 1).returns(true), - step(BackgroundTaskStart, bga, 0).returns(true), - step(BackgroundTaskDelivered, bg, 1).flush(), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).returns(true), + step(WorkStarts, bga), + step(PushWindowEnd, bga).returns(true), + step(BackgroundTaskStart, bga).returns(true), + step(BackgroundTaskDelivered, bg).flush(), }), - Observed: states(bga, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bg, bga, bg), }, { Name: "android overlapping push windows", Platform: Android, Steps: steps(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).slot(0).returns(true), - step(PushWindowBegin, bga, 1).slot(1).returns(true), - step(PushWindowEnd, bga, 0).slot(0).returns(false), - step(PushWindowEnd, bg, 1).slot(1).flush().returns(false), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).slot(0).returns(true), + step(PushWindowBegin, bga).slot(1).returns(true), + step(PushWindowEnd, bga).slot(0).returns(false), + step(PushWindowEnd, bg).slot(1).flush().returns(false), }), - Observed: states(bga, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bg, bga, bg), }, { - // BackgroundSyncWorker doesn't init Go, so it only syncs in a process - // where something else did. After a push (or quick reply) cold start, - // that component already reported the background, so the sync gets its - // window and returns to BACKGROUND. + // BackgroundSyncWorker doesn't init Go, so it only syncs in a process where + // something else did; a push (or quick reply) cold start already reported + // the background. Name: "android WorkManager BackgroundSync after a push cold start", Platform: Android, Steps: []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), - step(PushWindowEnd, bg, 1).flush().returns(false), - step(BackgroundSyncStart, bga, 1).returns(true), - step(BackgroundSyncTimerFires, bg, 1).flush(), + step(DidEnterBackground, bg).flush().returns(false), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + step(BackgroundSyncStart, bga).returns(true), + step(BackgroundSyncTimerFires, bg).flush(), }, Observed: states(bga, bg, bga, bg, bga, bg), }, @@ -471,34 +460,33 @@ var Scenarios = []Scenario{ Name: "android UI starts during a WorkManager sync after a push cold start", Platform: Android, Steps: []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(PushWindowBegin, bga, 1).returns(true), - step(PushWindowEnd, bg, 1).flush().returns(false), - step(BackgroundSyncStart, bga, 1).returns(true), - step(WillEnterForeground, bga, 1), - step(DidBecomeActive, fg, 1), - step(BackgroundSyncWait, fg, 0), + step(DidEnterBackground, bg).flush().returns(false), + 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(bga, bg, bga, bg, bga, fg), + Observed: states(bga, 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(androidLaunch, []Step{ - step(DidEnterBackground, bg, 1).flush().returns(false), - step(BackgroundSyncStart, bga, 1).returns(true), - step(PushWindowBegin, bga, 1).returns(true), - step(PushWindowEnd, bg, 1).flush().returns(false), - step(BackgroundSyncWait, bg, 0), + step(DidEnterBackground, bg).flush().returns(false), + step(BackgroundSyncStart, bga).returns(true), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bga).returns(false), + step(BackgroundSyncTimerFires, bg).flush(), }), - Observed: states(bga, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bg, bga, bg), }, { Name: "android termination", Platform: Android, - Steps: steps(androidLaunch, []Step{ - step(WillTerminate, bg, 1).flush().warn(), - }), - Observed: states(bga, fg, bg), + Steps: steps(androidLaunch, []Step{step(WillTerminate, bg).flush().warn()}), + Observed: states(bga, ina, fg, bg), }, } diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go index 2d768128b70e..f57be385eb24 100644 --- a/go/libkb/lifecycle/scenario_test.go +++ b/go/libkb/lifecycle/scenario_test.go @@ -72,11 +72,11 @@ func TestHarnessCloseEndsRunningWork(t *testing.T) { const bga = keybase1.MobileAppState_BACKGROUNDACTIVE cases := map[string][]lifecycletest.Step{ "background sync": { - {Do: lifecycletest.BackgroundSyncStart, Want: bga, Gen: 1, Returns: lifecycletest.ReturnTrue}, + {Do: lifecycletest.BackgroundSyncStart, Want: bga, Returns: lifecycletest.ReturnTrue}, }, "background task": { {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_BACKGROUND}, - {Do: lifecycletest.DidEnterBackground, Want: bga, Gen: 1, Flush: true, Returns: lifecycletest.ReturnTrue}, + {Do: lifecycletest.DidEnterBackground, Want: bga, Returns: lifecycletest.ReturnTrue}, {Do: lifecycletest.BackgroundTaskStart, Want: bga, Returns: lifecycletest.ReturnTrue}, }, } 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 index f128dd12d9c9..797663082917 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -10,13 +10,13 @@ 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 willEnterForeground() - fun didBecomeActive() - fun didEnterBackground(): Boolean + fun uiActive() + fun uiInactive() + fun uiBackground(): Long fun willExit() fun pushWindowBegin(): Long - fun pushWindowEnd(token: Long): Boolean - fun beginBackgroundTask() + fun pushWindowEnd(token: Long): Long + fun beginBackgroundTask(token: Long) } internal interface LifecycleExecutor { @@ -32,13 +32,12 @@ internal class SingleThreadLifecycleExecutor : LifecycleExecutor { // Reports the app's process lifecycle to Go as events; Go decides the state. // // Events reach Go in the order they happen, on one background thread: -// didEnterBackground queries the outbox, so it can't run on the main thread. +// uiBackground queries the outbox, so it can't run on the main thread. // // Only the process lifecycle counts. Activity pauses (dialogs, permission // prompts, choosers, the photo picker sheet) report nothing, not even -// willResignActive: INACTIVE would let a push window open and end in -// BACKGROUND while the app is on screen. A full-screen picker or camera stops -// the process like any other exit. +// UIInactive: only the process lifecycle decides what Go sees. A full-screen +// picker or camera stops the process like any other exit. internal class AppLifecycleReporter( private val bind: LifecycleBind, private val executor: LifecycleExecutor, @@ -51,12 +50,12 @@ internal class AppLifecycleReporter( override fun onStart(owner: LifecycleOwner) { started = true reported = true - enqueue("willEnterForeground") { bind.willEnterForeground() } + enqueue("uiInactive") { bind.uiInactive() } } @Synchronized override fun onResume(owner: LifecycleOwner) { - enqueue("didBecomeActive") { bind.didBecomeActive() } + enqueue("uiActive") { bind.uiActive() } } @Synchronized @@ -95,9 +94,10 @@ internal class AppLifecycleReporter( private fun reportBackground(why: String) { reported = true - enqueue("didEnterBackground: $why") { - if (bind.didEnterBackground()) { - bind.beginBackgroundTask() + enqueue("uiBackground: $why") { + val token = bind.uiBackground() + if (token > 0) { + bind.beginBackgroundTask(token) } } } @@ -134,8 +134,11 @@ internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, inForegro task() } finally { // Negative: Go isn't initialized, so no window opened. - if (token > 0 && bind.pushWindowEnd(token)) { - bind.beginBackgroundTask() + if (token > 0) { + val task = bind.pushWindowEnd(token) + if (task > 0) { + bind.beginBackgroundTask(task) + } } } return true 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 index db315136e7f3..95f70da58a70 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt @@ -5,17 +5,18 @@ import android.os.Bundle import keybase.Keybase internal class KeybaseLifecycleBind(private val context: Context) : LifecycleBind { - override fun willEnterForeground() = Keybase.appWillEnterForeground() + override fun uiActive() = Keybase.appUIActive() - override fun didBecomeActive() = Keybase.appDidBecomeActive() + override fun uiInactive() = Keybase.appUIInactive() - override fun didEnterBackground(): Boolean = Keybase.appDidEnterBackground() + override fun uiBackground(): Long = Keybase.appUIBackground() override fun willExit() = Keybase.appWillExit(KBPushNotifier(context, Bundle())) override fun pushWindowBegin(): Long = Keybase.appPushWindowBegin() - override fun pushWindowEnd(token: Long): Boolean = Keybase.appPushWindowEnd(token) + override fun pushWindowEnd(token: Long): Long = Keybase.appPushWindowEnd(token) - override fun beginBackgroundTask() = Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(context, Bundle())) + override fun beginBackgroundTask(token: Long) = + Keybase.appBeginBackgroundTaskNonblock(token, KBPushNotifier(context, Bundle())) } 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 index ad50c02fefe1..fd29519b1c76 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -17,23 +17,23 @@ import org.junit.Test private class FakeBind : LifecycleBind { val calls: MutableList = Collections.synchronizedList(mutableListOf()) - var stayRunning = false + var backgroundToken = 0L var token = 7L - var endHandsOver = false - var onDidEnterBackground: () -> Unit = {} + var endTaskToken = 0L + var onUiBackground: () -> Unit = {} - override fun willEnterForeground() { - calls.add("willEnterForeground") + override fun uiActive() { + calls.add("uiActive") } - override fun didBecomeActive() { - calls.add("didBecomeActive") + override fun uiInactive() { + calls.add("uiInactive") } - override fun didEnterBackground(): Boolean { - onDidEnterBackground() - calls.add("didEnterBackground") - return stayRunning + override fun uiBackground(): Long { + onUiBackground() + calls.add("uiBackground") + return backgroundToken } override fun willExit() { @@ -45,13 +45,13 @@ private class FakeBind : LifecycleBind { return token } - override fun pushWindowEnd(token: Long): Boolean { + override fun pushWindowEnd(token: Long): Long { calls.add("pushWindowEnd($token)") - return endHandsOver + return endTaskToken } - override fun beginBackgroundTask() { - calls.add("beginBackgroundTask") + override fun beginBackgroundTask(token: Long) { + calls.add("beginBackgroundTask($token)") } } @@ -107,9 +107,9 @@ class AppLifecycleReporterTest { assertTrue("nothing reaches Go on the calling thread", bind.calls.isEmpty()) assertEquals( listOf( - "willEnterForeground", "didBecomeActive", - "didEnterBackground", - "willEnterForeground", "didBecomeActive", + "uiInactive", "uiActive", + "uiBackground", + "uiInactive", "uiActive", ), calls(), ) @@ -118,10 +118,10 @@ class AppLifecycleReporterTest { @Test fun processStopWithWorkStartsTheBackgroundTask() { launch() - bind.stayRunning = true + bind.backgroundToken = 9L stop() assertEquals( - listOf("willEnterForeground", "didBecomeActive", "didEnterBackground", "beginBackgroundTask"), + listOf("uiInactive", "uiActive", "uiBackground", "beginBackgroundTask(9)"), calls(), ) } @@ -132,7 +132,7 @@ class AppLifecycleReporterTest { reporter.onPause(Owner) reporter.onResume(Owner) reporter.onPause(Owner) - assertEquals(listOf("willEnterForeground", "didBecomeActive", "didBecomeActive"), calls()) + assertEquals(listOf("uiInactive", "uiActive", "uiActive"), calls()) } // A full-screen picker or camera stops the process like any other exit. @@ -144,9 +144,9 @@ class AppLifecycleReporterTest { reporter.onResume(Owner) assertEquals( listOf( - "willEnterForeground", "didBecomeActive", - "didEnterBackground", - "willEnterForeground", "didBecomeActive", + "uiInactive", "uiActive", + "uiBackground", + "uiInactive", "uiActive", ), calls(), ) @@ -157,11 +157,11 @@ class AppLifecycleReporterTest { reporter.onCreate(Owner) reporter.reportHeadlessStart() reporter.reportHeadlessStart() - assertEquals(listOf("didEnterBackground"), calls()) + assertEquals(listOf("uiBackground"), calls()) reporter.onStart(Owner) reporter.onResume(Owner) reporter.reportHeadlessStart() - assertEquals(listOf("didEnterBackground", "willEnterForeground", "didBecomeActive"), calls()) + assertEquals(listOf("uiBackground", "uiInactive", "uiActive"), calls()) } @Test @@ -171,17 +171,17 @@ class AppLifecycleReporterTest { reporter.onResume(Owner) stop() reporter.reportHeadlessStart() - assertEquals(listOf("willEnterForeground", "didBecomeActive", "didEnterBackground"), calls()) + assertEquals(listOf("uiInactive", "uiActive", "uiBackground"), calls()) } @Test fun awaitReportedWaitsForQueuedEvents() { val executor = SingleThreadLifecycleExecutor() val reporter = AppLifecycleReporter(bind, executor) {} - bind.onDidEnterBackground = { Thread.sleep(100) } + bind.onUiBackground = { Thread.sleep(100) } reporter.reportHeadlessStart() reporter.awaitReported(5000) - assertEquals(listOf("didEnterBackground"), bind.calls.toList()) + assertEquals(listOf("uiBackground"), bind.calls.toList()) } @Test @@ -189,16 +189,16 @@ class AppLifecycleReporterTest { launch() reporter.onMainActivityDestroy(isFinishing = false, isChangingConfigurations = false) reporter.onMainActivityDestroy(isFinishing = true, isChangingConfigurations = true) - assertEquals(listOf("willEnterForeground", "didBecomeActive"), calls()) + assertEquals(listOf("uiInactive", "uiActive"), calls()) reporter.onMainActivityDestroy(isFinishing = true, isChangingConfigurations = false) stop() reporter.onStart(Owner) reporter.onResume(Owner) assertEquals( listOf( - "willEnterForeground", "didBecomeActive", - "willExit", "didEnterBackground", - "willEnterForeground", "didBecomeActive", + "uiInactive", "uiActive", + "willExit", "uiBackground", + "uiInactive", "uiActive", ), calls(), ) @@ -208,21 +208,21 @@ class AppLifecycleReporterTest { fun eventsReachGoInOrderOnOneBackgroundThread() { val threads = Collections.synchronizedSet(mutableSetOf()) val record = object : LifecycleBind by bind { - override fun willEnterForeground() { + override fun uiInactive() { threads.add(Thread.currentThread()) - bind.willEnterForeground() + bind.uiInactive() } - override fun didBecomeActive() { + override fun uiActive() { threads.add(Thread.currentThread()) - bind.didBecomeActive() + bind.uiActive() } - override fun didEnterBackground(): Boolean { + override fun uiBackground(): Long { threads.add(Thread.currentThread()) // Slow, like the outbox query, so later events queue behind it. Thread.sleep(5) - return bind.didEnterBackground() + return bind.uiBackground() } } val ordered = AppLifecycleReporter(record, SingleThreadLifecycleExecutor()) {} @@ -231,7 +231,7 @@ class AppLifecycleReporterTest { ordered.onStart(Owner) ordered.onResume(Owner) ordered.onStop(Owner) - expected += listOf("willEnterForeground", "didBecomeActive", "didEnterBackground") + expected += listOf("uiInactive", "uiActive", "uiBackground") } ordered.awaitReported(10_000) assertEquals(expected, bind.calls.toList()) @@ -275,9 +275,9 @@ class RunPushWindowTest { @Test fun windowHandedOverStartsTheBackgroundTask() { - bind.endHandsOver = true + bind.endTaskToken = 11L run() - assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)", "beginBackgroundTask"), bind.calls) + assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)", "beginBackgroundTask(11)"), bind.calls) } @Test diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 882ebdbaf478..dd905477a3b7 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -427,27 +427,26 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } -// The Go lifecycle events, one bind call each. Go decides what state each event -// means (go/libkb/lifecycle); nothing here may derive state, and +// The Go lifecycle entry points, one bind call each. Native reports only UI +// state and background task tokens; Go derives the app state +// (go/libkb/lifecycle). Nothing here may derive state, and // UIApplication.applicationState lags inside the scene-forwarded callbacks // anyway. protocol AppLifecycleEvents { - func willEnterForeground() - func didBecomeActive() - func willResignActive() - // True when Go wants to keep running; runBackgroundTask then does that work. - func didEnterBackground() -> Bool - func runBackgroundTask() + func uiActive() + func uiInactive() + // A background task token when Go wants to keep running, 0 otherwise; runBackgroundTask then does that work. + func uiBackground() -> Int64 + func runBackgroundTask(_ token: Int64) func backgroundTaskExpired() func willTerminate() } struct KeybaseLifecycleEvents: AppLifecycleEvents { - func willEnterForeground() { Keybasego.KeybaseAppWillEnterForeground() } - func didBecomeActive() { Keybasego.KeybaseAppDidBecomeActive() } - func willResignActive() { Keybasego.KeybaseAppWillResignActive() } - func didEnterBackground() -> Bool { Keybasego.KeybaseAppDidEnterBackground() } - func runBackgroundTask() { Keybasego.KeybaseAppBeginBackgroundTask(PushNotifier()) } + func uiActive() { Keybasego.KeybaseAppUIActive() } + func uiInactive() { Keybasego.KeybaseAppUIInactive() } + func uiBackground() -> Int64 { Keybasego.KeybaseAppUIBackground() } + func runBackgroundTask(_ token: Int64) { Keybasego.KeybaseAppBeginBackgroundTask(token, PushNotifier()) } func backgroundTaskExpired() { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } func willTerminate() { Keybasego.KeybaseAppWillExit(PushNotifier()) } } @@ -469,9 +468,9 @@ final class AppLifecycleForwarder { self.events = events } - func willEnterForeground() { queue.async { self.events.willEnterForeground() } } - func didBecomeActive() { queue.async { self.events.didBecomeActive() } } - func willResignActive() { queue.async { self.events.willResignActive() } } + func willEnterForeground() { queue.async { self.events.uiInactive() } } + func didBecomeActive() { queue.async { self.events.uiActive() } } + func willResignActive() { queue.async { self.events.uiInactive() } } func willTerminate() { runBounded { $0.willTerminate() } @@ -493,12 +492,13 @@ final class AppLifecycleForwarder { application.endBackgroundTask(previous) } queue.async { - guard self.events.didEnterBackground() else { + let token = self.events.uiBackground() + guard token > 0 else { DispatchQueue.main.async { self.endBackgroundTask(task) } return } DispatchQueue.global(qos: .default).async { - self.events.runBackgroundTask() + self.events.runBackgroundTask(token) DispatchQueue.main.async { self.endBackgroundTask(task) } } } 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 index bd2d1eb96890..438508300580 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -26,7 +26,7 @@ import { } from '../helpers/lifecycle' // Log lines these flows rely on: -// - Go (ios.log): "lifecycle: : ..." per native lifecycle event, +// - Go (ios.log): "lifecycle: : …" per native UI report, // "MobileAppState.Update: useful update: " per Go app state change, // "Srv: startHTTPSrv: addr:
" when the image server (re)starts. // - Metro (JS): "app focus changed: " when the shell store's app state changes. @@ -41,9 +41,9 @@ describe('app lifecycle: app state', () => { // 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: willEnterForeground: /, + /lifecycle: uiInactive: /, /MobileAppState\.Update: useful update: FOREGROUND/, - /lifecycle: didBecomeActive: /, + /lifecycle: uiActive: /, ]) expect(goLines).toHaveLength(3) // JS must hold the address of the server Go started, not a stale one. @@ -70,8 +70,8 @@ describe('app lifecycle: app state', () => { const goMark = goLogMark() await backgroundApp() await waitForLinesInOrder('Go to go to the background', () => goLogSince(goMark), [ - /lifecycle: willResignActive: /, - /lifecycle: didEnterBackground: /, + /lifecycle: uiInactive: /, + /lifecycle: uiBackground: /, ]) const text = `e2e-lifecycle-recv-${Date.now()}` @@ -82,10 +82,10 @@ describe('app lifecycle: app state', () => { const snap = await waitForAppState('active') expect(snap.screen?.params?.['conversationIDKey']).toBe(convID) await waitForLinesInOrder('Go to return to the foreground', () => goLogSince(goMark), [ - /lifecycle: didEnterBackground: /, - /lifecycle: willEnterForeground: /, + /lifecycle: uiBackground: /, + /lifecycle: uiInactive: /, /MobileAppState\.Update: useful update: FOREGROUND/, - /lifecycle: didBecomeActive: /, + /lifecycle: uiActive: /, ]) await waitForAvatar200(user) @@ -125,8 +125,8 @@ describe('app lifecycle: app state', () => { 'Go to see every cycle', () => { const lines = goLogSince(goMark) - const backgrounds = findLines(lines, /lifecycle: didEnterBackground: /).length - const actives = findLines(lines, /lifecycle: didBecomeActive: /).length + 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 @@ -154,18 +154,18 @@ describe('app lifecycle: app state', () => { const inactive = await waitForAppState('inactive', undefined, 15000) await waitForLinesInOrder('Go to go inactive', () => goLogSince(goMark), [ /MobileAppState\.Update: useful update: INACTIVE/, - /lifecycle: willResignActive: /, + /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: didEnterBackground: /)).toEqual([]) + 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: didBecomeActive: /, + /lifecycle: uiActive: /, ]) const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) expect(focus).toEqual([ 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 index 3b69518e33ee..1787dcee596f 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -40,7 +40,7 @@ const waitForScreen = async (what: string, match: (s: Awaited) => - waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [/lifecycle: didEnterBackground: /]) + 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 @@ -67,7 +67,7 @@ const onProfile = (s: Awaited>['screen']) => // a cold deep link; "[onNotification]: " for each push JS receives, whose payload // carries native's "userInteraction"; "[Push] handleLoudMessage: ignore non userInteraction" // when JS declines to navigate for an untapped push. -// - Go (ios.log): "lifecycle: didEnterBackground: " before a push is sent to a backgrounded app, +// - 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 () => { diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts index 4000aa01add0..ba3bcb3334ed 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -30,7 +30,7 @@ import { // - "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: liveLocationClaim: " when a fix keeps a backgrounded app's work running. +// - "lifecycle: acquire: liveLocation hold " when a fix holds a backgrounded app up. // 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 @@ -106,7 +106,7 @@ describe('app lifecycle: live location', () => { const goMark = goLogMark() await backgroundApp() await waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [ - /lifecycle: didEnterBackground: /, + /lifecycle: uiBackground: /, ]) // JS doesn't run in the background, so anything after this comes from native. const moveMark = goLogMark() @@ -117,7 +117,7 @@ describe('app lifecycle: live location', () => { [/\+ LiveLocationTracker: LocationUpdate/, /tracker\[\d+\]: got coords/, /\+ LiveLocationTracker: updateMapUnfurl/], 180000 ) - expect(findLines(goLogSince(goMark), /lifecycle: willEnterForeground: /)).toEqual([]) + expect(findLines(goLogSince(moveMark), /lifecycle: ui(Inactive|Active): /)).toEqual([]) await activateApp() await waitForAppState('active') }) @@ -145,8 +145,8 @@ describe('app lifecycle: live location', () => { 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: liveLocationClaim: /).length).toBeGreaterThan(0) - expect(findLines(relaunched, /lifecycle: willEnterForeground: /)).toEqual([]) + expect(findLines(relaunched, /lifecycle: acquire: liveLocation hold /).length).toBeGreaterThan(0) + expect(findLines(relaunched, /lifecycle: ui(Inactive|Active): /)).toEqual([]) expect(appPid()).toBe(pid) }) From b684c6c5bc27191bf784fa144a7eed112a6af1f8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 12:33:41 -0400 Subject: [PATCH 043/127] fix(appstate): tie the live location hold to the tracker map and remove trackers whose watch fails --- go/chat/maps/livelocation.go | 46 ++++++++++++++++--------- go/chat/maps/livelocation_watch_test.go | 37 ++++++++++++++++++++ go/ephemeral/keygen_loop_test.go | 14 ++++++-- go/ephemeral/lib.go | 14 ++++---- go/libkb/lifecycle/controller_test.go | 23 +++++++++++++ go/libkb/lifecycle/lifecycle.go | 3 ++ 6 files changed, 111 insertions(+), 26 deletions(-) diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 66a747aac7da..3798c025556f 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -33,7 +33,8 @@ type LiveLocationTracker struct { trackers map[types.LiveLocationKey]*locationTrack lastCoord chat1.Coordinate maxCoords int - // bgHold keeps the app running while tracking; guarded by the tracker's mutex. + // bgHold keeps the app running while tracking; guarded by the tracker's + // mutex and changed only by syncHoldLocked. bgHold *lifecycle.Hold nativeWatchMu sync.Mutex @@ -101,9 +102,22 @@ func (l *LiveLocationTracker) saveLocked(ctx context.Context) { func (l *LiveLocationTracker) removeTrackerLocked(ctx context.Context, t *locationTrack) { delete(l.trackers, t.Key()) l.saveLocked(ctx) - if len(l.trackers) == 0 && l.bgHold != nil { - l.bgHold.Release() - l.bgHold = nil + l.syncHoldLocked(false) +} + +// syncHoldLocked ties bgHold to the trackers map: no trackers means no hold, +// and a fix while tracking opens one if none is open (the controller may have +// ended it). Every removal from the map and every fix calls it. +func (l *LiveLocationTracker) syncHoldLocked(fix bool) { + switch { + case len(l.trackers) == 0: + if l.bgHold != nil { + l.bgHold.Release() + l.bgHold = nil + } + case fix && l.G().IsMobileAppType() && (l.bgHold == nil || l.bgHold.Released()): + // A location update can wake a backgrounded app; hold it up so the update gets out. + l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) } } @@ -132,6 +146,7 @@ func (l *LiveLocationTracker) runRestoredLocked(trackers []*locationTrack) { return l.tracker(myT) }) } + l.syncHoldLocked(false) } func (l *LiveLocationTracker) getLastCoord() chat1.Coordinate { @@ -326,11 +341,15 @@ func (l *LiveLocationTracker) startChatUIWatch(ctx context.Context, t *locationT 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() 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") } @@ -338,15 +357,11 @@ func (l *LiveLocationTracker) tracker(t *locationTrack) error { // start up the OS watch routine 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 - stopWatch() - l.Lock() - defer l.Unlock() - l.removeTrackerLocked(ctx, t) - }() + // 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 lastCoord := l.getLastCoord(); !lastCoord.IsZero() { @@ -438,10 +453,7 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co defer l.Trace(ctx, nil, "LocationUpdate")() l.Lock() defer l.Unlock() - if l.G().IsMobileAppType() && len(l.trackers) > 0 && (l.bgHold == nil || l.bgHold.Released()) { - // A location update can wake a backgrounded app; hold it up so the update gets out. - l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) - } + l.syncHoldLocked(true) if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") return diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go index fe12c3b38f8d..b7fe96361f3f 100644 --- a/go/chat/maps/livelocation_watch_test.go +++ b/go/chat/maps/livelocation_watch_test.go @@ -14,6 +14,7 @@ import ( "github.com/keybase/client/go/kbtest" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/clockwork" "github.com/stretchr/testify/require" ) @@ -262,3 +263,39 @@ func TestLiveLocationTrackerChatUIWatchGivesUp(t *testing.T) { } 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 + noStay := func() bool { return false } + + 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}) + require.Zero(t, tc.G.MobileLifecycle.UIBackground(noStay)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + + for ui.attempts.Load() < 22 { + clock.BlockUntil(1) + clock.Advance(time.Second) + n := ui.attempts.Load() + 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}) + require.Zero(t, tc.G.MobileLifecycle.UIBackground(noStay)) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) +} diff --git a/go/ephemeral/keygen_loop_test.go b/go/ephemeral/keygen_loop_test.go index 96b5db48cb1f..df9f500017ac 100644 --- a/go/ephemeral/keygen_loop_test.go +++ b/go/ephemeral/keygen_loop_test.go @@ -69,9 +69,8 @@ func TestKeygenLoopSeedsFromState(t *testing.T) { require.EqualValues(t, 1, runs.Load()) } -// Keygen runs when work wakes the app in the background and when the UI comes -// back from BACKGROUND (INACTIVE), but not when the UI merely stops being -// active. +// 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 TestKeygenLoopRunsWhenLeavingTheBackground(t *testing.T) { tc := libkb.SetupTest(t, "ephemeral", 2) defer tc.Cleanup() @@ -104,4 +103,13 @@ func TestKeygenLoopRunsWhenLeavingTheBackground(t *testing.T) { appState.Update(keybase1.MobileAppState_INACTIVE) next(keybase1.MobileAppState_INACTIVE) require.EqualValues(t, 2, runs.Load()) + appState.Update(keybase1.MobileAppState_BACKGROUND) + next(keybase1.MobileAppState_BACKGROUND) + require.EqualValues(t, 2, runs.Load()) + // NextUpdate collapses willEnterForeground's INACTIVE and didBecomeActive's + // FOREGROUND when they land together; the loop then sees BACKGROUND to + // FOREGROUND. + appState.Update(keybase1.MobileAppState_FOREGROUND) + next(keybase1.MobileAppState_FOREGROUND) + require.EqualValues(t, 3, runs.Load()) } diff --git a/go/ephemeral/lib.go b/go/ephemeral/lib.go index d3ab96e12e61..66ec64c9cb4e 100644 --- a/go/ephemeral/lib.go +++ b/go/ephemeral/lib.go @@ -124,10 +124,10 @@ func (e *EKLib) backgroundKeygen(mctx libkb.MetaContext, stopCh <-chan struct{}) } // keygenLoop runs run on every tick, and also when the app enters -// BACKGROUNDACTIVE or the UI comes back from BACKGROUND (INACTIVE), 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. +// 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), ) { @@ -156,10 +156,12 @@ func (e *EKLib) keygenLoop(mctx libkb.MetaContext, stopCh <-chan struct{}, tick } } -// keygenOnTransition: work woke the app in the background, or the UI is coming back from it. +// 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_INACTIVE) + (prev == keybase1.MobileAppState_BACKGROUND && state != keybase1.MobileAppState_BACKGROUND) } func (e *EKLib) SetClock(clock clockwork.Clock) { diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index f4e924263efe..4665e730871b 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -163,6 +163,29 @@ func TestPushWindowEndOutsideBackgroundSkipsStayRunning(t *testing.T) { require.Equal(t, 0, lifecycle.Holds(c)) } +// stayRunning reaches back into the controller (the live location tracker +// holds its own lock while acquiring a hold), so the controller must not hold +// its lock while calling it. +func TestStayRunningRunsOutsideTheLock(t *testing.T) { + appState, _ := newAppState(t) + c := lifecycle.New(appState, lifecycle.Config{}) + reenter := func() bool { + c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation).Release() + return true + } + done := make(chan struct{}) + go func() { + defer close(done) + c.UIBackground(reenter) + c.PushWindowEnd(c.PushWindowBegin(), reenter) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + require.Fail(t, "stayRunning deadlocked on the controller's lock") + } +} + // Native gives these last events only a short wait, so the state change and // the flush must happen before the slow pending-message warning. func TestExitEventsApplyBeforeNotifying(t *testing.T) { diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 44e00ba603e2..c154ba5a8d93 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -332,6 +332,8 @@ func (c *Controller) UIInactive() { // must keep going it opens a background task hold and returns its token for // RunBackgroundTask; otherwise it returns 0. func (c *Controller) UIBackground(stayRunning func() bool) int64 { + // stayRunning takes other locks (the live location tracker's, which is held + // while calling into the controller), so it must run outside c.mu. stay := stayRunning() c.mu.Lock() defer c.mu.Unlock() @@ -401,6 +403,7 @@ func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) int64 { h, ok := c.holds[token] query := ok && h.reason == ReasonPushWindow && c.ui == UIBackground c.mu.Unlock() + // Outside c.mu, as in UIBackground: stayRunning takes locks held while calling into the controller. stay := query && stayRunning() c.mu.Lock() defer c.mu.Unlock() From 5dc6692835435be3cb4b74f09cc065a1ac210ed4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 12:55:56 -0400 Subject: [PATCH 044/127] refactor(kbhttp): run the local http server from a single reconciling goroutine One goroutine owns the server: it starts and stops it on app state changes, restarts it after an unexpected exit at most once per observed state change, applies handler registrations and handles shutdown. Readers load a published status instead of taking a lock, and the status is published before HTTPSrvInfoUpdate is sent. The start log line is now "Srv: start: addr:". --- go/kbhttp/manager/manager.go | 344 +++++++++--------- go/kbhttp/manager/manager_test.go | 216 +++++++---- .../flows/lifecycle-app-state.test.ts | 9 +- 3 files changed, 310 insertions(+), 259 deletions(-) diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 1d404f4a5bda..53f4a01e43d8 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -8,6 +8,7 @@ import ( "net/http" "runtime" "sync" + "sync/atomic" "github.com/keybase/client/go/kbhttp" "github.com/keybase/client/go/libkb" @@ -26,70 +27,89 @@ type srvEndpoint struct { serve func(w http.ResponseWriter, req *http.Request) } +// srvStatus is what run last published; readers never wait on run. +type srvStatus struct { + active bool + info keybase1.HttpSrvInfo + // state is the app state run last acted on and wait the change channel it + // waits on for it; exits counts unexpected exits it handled. Tests use them. + state keybase1.MobileAppState + wait <-chan struct{} + exits int +} + +type handlerRequest struct { + endpoint string + desc srvEndpoint + done chan struct{} +} + +// Srv runs the local HTTP server. One goroutine, run, owns it: only run starts +// and stops it, reacting to app state changes, unexpected exits, handler +// registrations and shutdown. type Srv struct { libkb.Contextified - // token is set once in NewSrv and kept across restarts, so URLs handed - // out before a restart keep working. - token string - listenerSource func() kbhttp.ListenerSource - // stopInBackground is false on Android, where the server stays up in - // every state. - stopInBackground bool + // 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. + notify func(context.Context, keybase1.HttpSrvInfo) - // mu guards everything below and serializes starts and stops. - mu sync.Mutex + status atomic.Pointer[srvStatus] + exited chan struct{} + handlers chan handlerRequest + shutdownOnce sync.Once + shutdownCh chan struct{} + done chan struct{} + + // Owned by run. httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint - shutdown bool - // exitRestartChange is one past stateChanges at the last restart after an - // unexpected exit, so a listener that keeps dying restarts at most once - // per app state change. - exitRestartChange uint64 - // stateChanges counts the app state changes the monitor applied. - stateChanges uint64 - // exits counts handled unexpected exits, for tests. - exits int - // beforeExitRestart, if set, runs in serverExited between reading the - // app state and acting on it. Tests only. - beforeExitRestart func() - // monitorState is the state the monitor last acted on, and monitorWait - // the change channel it is waiting on for that state; tests use them to - // wait until the monitor has caught up. - monitorState keybase1.MobileAppState - monitorWait <-chan struct{} - - shutdownCh chan struct{} - monitorDone chan struct{} + state keybase1.MobileAppState + wait <-chan struct{} + exits int + // restartedSinceChange caps restarts after unexpected exits at one per app + // state change, so a listener that keeps dying doesn't spin. + restartedSinceChange bool } func NewSrv(g *libkb.GlobalContext) *Srv { listenerSource := func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) } - return newSrv(g, listenerSource, runtime.GOOS != "android") + return newSrv(g, listenerSource, runtime.GOOS != "android", func(ctx context.Context, info keybase1.HttpSrvInfo) { + // Read NotifyRouter when notifying: the service sets it after creating this server. + g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + }) } -func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, stopInBackground bool) *Srv { +func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, stopInBackground bool, + notify func(context.Context, keybase1.HttpSrvInfo), +) *Srv { token, _ := libkb.RandHexString("", 32) - h := &Srv{ + r := &Srv{ Contextified: libkb.NewContextified(g), token: token, listenerSource: listenerSource, stopInBackground: stopInBackground, - endpoints: make(map[string]srvEndpoint), + notify: notify, + exited: make(chan struct{}, 1), + handlers: make(chan handlerRequest), shutdownCh: make(chan struct{}), - monitorDone: make(chan struct{}), + done: make(chan struct{}), + endpoints: make(map[string]srvEndpoint), } - h.httpSrv = h.newHTTPSrv() - g.PushShutdownHook(func(mctx libkb.MetaContext) error { - h.stop() + r.httpSrv = r.newHTTPSrv() + g.PushShutdownHook(func(libkb.MetaContext) error { + r.stop() return nil }) - state := g.MobileAppState.State() - h.reconcile(state) - go h.monitorAppState(state) - return h + ready := make(chan struct{}) + go r.run(g.MobileAppState.State(), ready) + <-ready + return r } func (r *Srv) debug(ctx context.Context, msg string, args ...any) { @@ -106,7 +126,12 @@ func TokenPrefix(token string) string { func (r *Srv) newHTTPSrv() *kbhttp.Srv { srv := kbhttp.NewSrv(r.G().GetLog(), r.listenerSource()) - srv.OnUnexpectedExit(r.serverExited) + srv.OnUnexpectedExit(func() { + select { + case r.exited <- struct{}{}: + default: + } + }) return srv } @@ -114,152 +139,122 @@ func (r *Srv) wantUp(state keybase1.MobileAppState) bool { return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } -// serverExited restarts a server whose listener died without a Stop, for -// example one the OS reclaimed while the app was suspended without ever -// reaching BACKGROUND. -func (r *Srv) serverExited() { +func (r *Srv) run(state keybase1.MobileAppState, ready chan struct{}) { + defer close(r.done) ctx := context.Background() - r.mu.Lock() - // Read the state and start under mu, so a BACKGROUND the monitor applies - // concurrently either comes first (seen here) or stops what starts here. - state := r.G().MobileAppState.State() - if r.beforeExitRestart != nil { - r.beforeExitRestart() - } - var info keybase1.HttpSrvInfo - started := false - if r.wantUp(state) && r.exitRestartChange != r.stateChanges+1 { - r.exitRestartChange = r.stateChanges + 1 - r.debug(ctx, "serverExited: restarting in %v", state) - info, started = r.startLocked(ctx) - } else { - r.debug(ctx, "serverExited: not restarting in %v", state) - } - r.exits++ - r.mu.Unlock() - if started { - r.G().NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + r.state = state + r.debug(ctx, "run: starting up in %v", state) + r.reconcile(ctx) + for { + r.wait = r.G().MobileAppState.NextUpdate(r.state) + r.publish() + if ready != nil { + close(ready) + ready = nil + } + select { + case <-r.wait: + r.state = r.G().MobileAppState.State() + r.restartedSinceChange = false + r.reconcile(ctx) + case <-r.exited: + r.serverExited(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)) + } + close(req.done) + case <-r.shutdownCh: + <-r.httpSrv.Stop() + r.status.Store(&srvStatus{}) + return + } } } -// startHTTPSrv starts the server if it isn't serving, including after its -// listener died underneath it. -func (r *Srv) startHTTPSrv() { - ctx := context.Background() - r.mu.Lock() - info, started := r.startLocked(ctx) - r.mu.Unlock() - if !started { +// 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) { + if !r.wantUp(r.state) { + r.httpSrv.Stop() return } - r.G().NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + r.start(ctx) } -func (r *Srv) startLocked(ctx context.Context) (info keybase1.HttpSrvInfo, started bool) { - if r.shutdown || r.httpSrv.Active() { - return info, false +// serverExited restarts a server whose listener died without a Stop, for +// example one the OS reclaimed while the app was suspended without reaching BACKGROUND. +func (r *Srv) serverExited(ctx context.Context) { + if r.httpSrv.Active() { + return } - maxTries := 2 - success := false - for range maxTries { - if err := r.httpSrv.StartWithHandlers(r.registerEndpointsLocked); 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.httpSrv = r.newHTTPSrv() - continue - } - r.debug(ctx, "startHTTPSrv: failed to start HTTP server: %s", err) - break - } - success = true - break + r.exits++ + if !r.wantUp(r.state) || r.restartedSinceChange { + r.debug(ctx, "serverExited: not restarting in %v", r.state) + return } - if !success { - r.debug(ctx, "startHTTPSrv: exhausted attempts to start HTTP server, giving up") - return info, false + r.restartedSinceChange = true + r.debug(ctx, "serverExited: restarting in %v", r.state) + r.start(ctx) +} + +func (r *Srv) start(ctx context.Context) { + if r.httpSrv.Active() { + return + } + 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 = r.newHTTPSrv() + err = r.httpSrv.StartWithHandlers(r.registerEndpoints) } - addr, err := r.httpSrv.Addr() if err != nil { - r.debug(ctx, "startHTTPSrv: failed to get address after start?: %s", err) + r.debug(ctx, "start: failed to start HTTP server: %s", err) + return } - r.debug(ctx, "startHTTPSrv: addr: %s token: %s", addr, TokenPrefix(r.token)) - return keybase1.HttpSrvInfo{ - Address: addr, - Token: r.token, - }, true -} - -func (r *Srv) stopHTTPSrv() { - r.mu.Lock() - defer r.mu.Unlock() - r.httpSrv.Stop() -} - -func (r *Srv) stop() { - r.mu.Lock() - defer r.mu.Unlock() - if r.shutdown { + // Publish before notifying, so a listener reading Info gets the address it is told about. + r.publish() + info, err := r.Info() + if err != nil { // Serve already exited; run handles that exit next return } - r.shutdown = true - close(r.shutdownCh) - r.httpSrv.Stop() + r.debug(ctx, "start: addr: %s token: %s", info.Address, TokenPrefix(r.token)) + r.notify(ctx, info) } -// 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 restarts it if it isn't -// serving. -func (r *Srv) reconcile(state keybase1.MobileAppState) { - if !r.wantUp(state) { - r.stopHTTPSrv() - return +func (r *Srv) publish() { + st := &srvStatus{state: r.state, wait: r.wait, exits: r.exits} + if addr, err := r.httpSrv.Addr(); err == nil { + st.active = true + st.info = keybase1.HttpSrvInfo{Address: addr, Token: r.token} } - r.startHTTPSrv() + r.status.Store(st) } -func (r *Srv) monitorAppState(state keybase1.MobileAppState) { - defer close(r.monitorDone) - r.debug(context.Background(), "monitorAppState: starting up in %v", state) - for { - next := r.G().MobileAppState.NextUpdate(state) - r.mu.Lock() - r.monitorState, r.monitorWait = state, next - r.mu.Unlock() - select { - case <-next: - case <-r.shutdownCh: - return - } - state = r.G().MobileAppState.State() - r.mu.Lock() - r.stateChanges++ - r.mu.Unlock() - r.reconcile(state) +func (r *Srv) registerEndpoints(mux *http.ServeMux) { + for endpoint, desc := range r.endpoints { + mux.HandleFunc("/"+endpoint, r.checkToken(desc.tokenMode, desc.serve)) } } +func (r *Srv) stop() { + 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.mu.Lock() - defer r.mu.Unlock() - r.endpoints[endpoint] = srvEndpoint{ - tokenMode: tokenMode, - serve: serve, - } - // A stopped server has no mux; startHTTPSrv registers every endpoint. - if r.httpSrv.Active() { - r.httpSrv.HandleFunc("/"+endpoint, r.checkToken(tokenMode, serve)) - } -} - -func (r *Srv) registerEndpointsLocked(mux *http.ServeMux) { - for endpoint, desc := range r.endpoints { - mux.HandleFunc("/"+endpoint, r.checkToken(desc.tokenMode, desc.serve)) + req := handlerRequest{endpoint: endpoint, desc: srvEndpoint{tokenMode: tokenMode, serve: serve}, done: make(chan struct{})} + select { + case r.handlers <- req: + <-req.done + case <-r.done: } } @@ -282,29 +277,20 @@ func (r *Srv) checkToken(tokenMode SrvTokenMode, } } -func (r *Srv) Active() bool { - r.mu.Lock() - defer r.mu.Unlock() - return r.httpSrv.Active() -} +func (r *Srv) Active() bool { return r.status.Load().active } func (r *Srv) Addr() (string, error) { - r.mu.Lock() - defer r.mu.Unlock() - return r.httpSrv.Addr() + info, err := r.Info() + return info.Address, err } -func (r *Srv) Token() string { - return r.token -} +func (r *Srv) Token() string { return r.token } // Info returns the address and token together, for handing both to a client. func (r *Srv) Info() (keybase1.HttpSrvInfo, error) { - r.mu.Lock() - defer r.mu.Unlock() - addr, err := r.httpSrv.Addr() - if err != nil { - return keybase1.HttpSrvInfo{}, err + st := r.status.Load() + if !st.active { + return keybase1.HttpSrvInfo{}, errors.New("server not running") } - return keybase1.HttpSrvInfo{Address: addr, Token: r.token}, nil + return st.info, nil } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index 934ad464c441..e6ebef92af46 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -1,6 +1,7 @@ package manager import ( + "context" "errors" "fmt" "io" @@ -30,6 +31,11 @@ type listeners struct { // failing makes new listeners fail on their first Accept, so Serve // returns right away. failing atomic.Bool + // armed makes the next GetListener close blocked and wait on block, + // which release closes. + armed bool + block chan struct{} + blocked chan struct{} } type failingListener struct { @@ -46,6 +52,15 @@ type trackedSource struct { } func (s trackedSource) GetListener() (net.Listener, string, error) { + s.l.Lock() + armed := s.l.armed + s.l.armed = false + block, blocked := s.l.block, s.l.blocked + s.l.Unlock() + if armed { + close(blocked) + <-block + } listener, address, err := s.src.GetListener() s.l.Lock() defer s.l.Unlock() @@ -69,6 +84,34 @@ func (l *listeners) Calls() int { return l.calls } +// blockNext makes the next GetListener wait for release. +func (l *listeners) blockNext() { + l.Lock() + defer l.Unlock() + l.armed = true + l.block = make(chan struct{}) + l.blocked = make(chan struct{}) +} + +// waitBlocked waits until a GetListener is held by blockNext. +func (l *listeners) waitBlocked(t *testing.T) { + t.Helper() + l.Lock() + blocked := l.blocked + l.Unlock() + select { + case <-blocked: + case <-time.After(10 * time.Second): + require.Fail(t, "no GetListener reached the block") + } +} + +func (l *listeners) release() { + l.Lock() + defer l.Unlock() + close(l.block) +} + func (l *listeners) kill(t *testing.T) { l.Lock() defer l.Unlock() @@ -81,11 +124,19 @@ var client = &http.Client{ } 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{} - srv := newSrv(tc.G, l.source, stopInBackground) + srv := newSrv(tc.G, l.source, stopInBackground, notify) + // newSrv returns having acted on the launch state; HandleFunc below would wait for run anyway. + require.Equal(t, srv.wantUp(state), srv.Active(), "launch state not applied when newSrv returned") srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { fmt.Fprint(w, "ok") }) @@ -110,30 +161,25 @@ func fetch(info keybase1.HttpSrvInfo) (int, error) { return resp.StatusCode, nil } -// waitMonitor waits until the monitor has acted on the current state and is -// waiting for the next change. -func waitMonitor(t *testing.T, srv *Srv) { +// waitLoop waits until run has acted on the current app state and is waiting for the next change. +func waitLoop(t *testing.T, srv *Srv) { t.Helper() require.Eventually(t, func() bool { - srv.mu.Lock() - state, wait := srv.monitorState, srv.monitorWait - srv.mu.Unlock() - if wait == nil || wait != srv.G().MobileAppState.NextUpdate(state) { + st := srv.status.Load() + if st == nil || st.wait == nil || st.wait != srv.G().MobileAppState.NextUpdate(st.state) { return false } select { - case <-wait: + case <-st.wait: return false default: return true } - }, 10*time.Second, time.Millisecond, "monitor did not catch up") + }, 10*time.Second, time.Millisecond, "run did not catch up") } func exits(srv *Srv) int { - srv.mu.Lock() - defer srv.mu.Unlock() - return srv.exits + return srv.status.Load().exits } func waitExits(t *testing.T, srv *Srv, n int) { @@ -177,7 +223,7 @@ func requireStopped(t *testing.T, srv *Srv) { func TestDeadListenerRestartsOnTransition(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) for _, next := range []keybase1.MobileAppState{ keybase1.MobileAppState_INACTIVE, @@ -186,14 +232,14 @@ func TestDeadListenerRestartsOnTransition(t *testing.T) { } { killUntilDown(t, srv, l) srv.G().MobileAppState.Update(next) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) } } func TestDeadListenerRestartsWithoutTransition(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + waitLoop(t, srv) first := requireServing(t, srv) l.kill(t) waitExits(t, srv, 1) @@ -202,93 +248,111 @@ func TestDeadListenerRestartsWithoutTransition(t *testing.T) { require.Equal(t, 2, l.Calls()) } -func TestUnexpectedExitRestartsOncePerGeneration(t *testing.T) { +func TestUnexpectedExitRestartsOncePerStateChange(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) l.failing.Store(true) l.kill(t) // The restart's listener fails at once; its exit must not restart again. - // Each exit decides and starts under mu, so once two exits are handled - // the listener count is final. + // run handles each exit before the next start, so once two exits are + // handled the listener count is final. waitExits(t, srv, 2) require.Equal(t, 2, l.Calls(), "restart loop on a failing listener") requireStopped(t, srv) - // A new app state change allows one more restart after the monitor's own. + // A new app state change allows one more restart after run's own start. srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) - waitMonitor(t, srv) + waitLoop(t, srv) waitExits(t, srv, 4) require.Equal(t, 4, l.Calls(), "restart loop on a failing listener") l.failing.Store(false) srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) } -// A BACKGROUND applied while an unexpected exit is deciding whether to -// restart must not leave the server up. +// A BACKGROUND that lands while an exit-restart is starting must leave the server stopped. func TestUnexpectedExitRacingBackground(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) - - srv.mu.Lock() - srv.beforeExitRestart = func() { - // serverExited has read FOREGROUND. The monitor is idle, so mu is - // held here only if serverExited holds it; otherwise let the monitor - // fully apply BACKGROUND before serverExited acts on its stale read. - holdsMu := !srv.mu.TryLock() - if !holdsMu { - srv.mu.Unlock() - } - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - if !holdsMu { - waitMonitor(t, srv) - } - } - srv.mu.Unlock() - + l.blockNext() l.kill(t) - waitExits(t, srv, 1) - waitMonitor(t, srv) + l.waitBlocked(t) // run is inside start, waiting for a listener + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + l.release() + waitLoop(t, srv) require.Equal(t, keybase1.MobileAppState_BACKGROUND, srv.G().MobileAppState.State()) requireStopped(t, srv) } func TestNothingStartsAfterShutdown(t *testing.T) { - srv, _ := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + waitLoop(t, srv) requireServing(t, srv) srv.stop() requireStopped(t, srv) - srv.reconcile(keybase1.MobileAppState_FOREGROUND) - require.False(t, srv.Active(), "reconcile restarted the server after shutdown") - srv.serverExited() - require.False(t, srv.Active(), "an unexpected exit restarted the server after shutdown") + calls := l.Calls() + srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + srv.HandleFunc("late", SrvTokenModeDefault, func(http.ResponseWriter, *http.Request) {}) // returns: run is done + require.Never(t, func() bool { return srv.Active() || 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 + }) + srv.G().MobileAppState.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) - waitMonitor(t, srv) + waitLoop(t, srv) first := requireServing(t, srv) require.Equal(t, 1, l.Calls()) srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) - waitMonitor(t, srv) + waitLoop(t, srv) require.Equal(t, first, requireServing(t, srv)) require.Equal(t, 1, l.Calls(), "INACTIVE restarted the server") srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) requireStopped(t, srv) _, err := fetch(first) require.Error(t, err) srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) again := requireServing(t, srv) // Usually 2; another process may take the pinned port while stopped. require.GreaterOrEqual(t, l.Calls(), 2) @@ -302,29 +366,29 @@ 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") requireStopped(t, srv) - waitMonitor(t, srv) + waitLoop(t, srv) require.Equal(t, 0, l.Calls()) srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) } func TestNotStoppingInBackgroundStaysUp(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_BACKGROUND, false) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) for _, next := range []keybase1.MobileAppState{ keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUND, } { srv.G().MobileAppState.Update(next) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) } killUntilDown(t, srv, l) srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - waitMonitor(t, srv) + waitLoop(t, srv) requireServing(t, srv) } @@ -334,7 +398,7 @@ func TestScenarioReplay(t *testing.T) { stopInBackground := sc.Platform == lifecycletest.IOS srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, stopInBackground) lifecycletest.Play(t, srv.G().MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { - waitMonitor(t, srv) + waitLoop(t, srv) if !srv.wantUp(step.Want) { if srv.Active() { t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) @@ -364,12 +428,10 @@ func TestScenarioReplay(t *testing.T) { func TestPinnedPortTakenPicksNewAddress(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + waitLoop(t, srv) first := requireServing(t, srv) - // Each reader calls one accessor only, so no other locked call between - // its reads hides an unlocked read of the replaced server from the race - // detector. + // Readers race run replacing the server, for the race detector. stop := make(chan struct{}) var readers sync.WaitGroup for _, read := range []func(){ @@ -393,14 +455,14 @@ func TestPinnedPortTakenPicksNewAddress(t *testing.T) { } srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) requireStopped(t, srv) squatter, err := net.Listen("tcp", first.Address) require.NoError(t, err) defer squatter.Close() srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) close(stop) readers.Wait() @@ -442,7 +504,7 @@ func requestWorker(srv *Srv, stale keybase1.HttpSrvInfo, stop chan struct{}, ok func TestConcurrentRequestsDuringRestart(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitMonitor(t, srv) + waitLoop(t, srv) first := requireServing(t, srv) stop := make(chan struct{}) @@ -466,9 +528,9 @@ func TestConcurrentRequestsDuringRestart(t *testing.T) { for range 50 { srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) time.Sleep(time.Millisecond) } close(stop) @@ -490,11 +552,11 @@ func TestStressTransitionsAndRequests(t *testing.T) { baseline := runtime.NumGoroutine() l := &listeners{} - srv := newSrv(tc.G, l.source, true) + srv := newSrv(tc.G, l.source, true, func(context.Context, keybase1.HttpSrvInfo) {}) srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { fmt.Fprint(w, "ok") }) - waitMonitor(t, srv) + waitLoop(t, srv) first := requireServing(t, srv) token := first.Token states := []keybase1.MobileAppState{ @@ -570,24 +632,24 @@ func TestStressTransitionsAndRequests(t *testing.T) { default: } - // Make a real change so the monitor must wake for it. + // Make a real change so run must wake for it. final := keybase1.MobileAppState_INACTIVE if tc.G.MobileAppState.State() == final { final = keybase1.MobileAppState_FOREGROUND } tc.G.MobileAppState.Update(final) - waitMonitor(t, srv) + waitLoop(t, srv) require.Equal(t, token, requireServing(t, srv).Token) tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitMonitor(t, srv) + waitLoop(t, srv) requireStopped(t, srv) t.Logf("%d good responses, %d listeners", ok.Load(), l.Calls()) srv.stop() select { - case <-srv.monitorDone: + case <-srv.done: case <-time.After(10 * time.Second): - t.Fatal("monitor did not exit on shutdown") + t.Fatal("run did not exit on shutdown") } deadline := time.Now().Add(10 * time.Second) 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 index 438508300580..219b2480e760 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -28,8 +28,11 @@ import { // Log lines these flows rely on: // - Go (ios.log): "lifecycle: : …" per native UI report, // "MobileAppState.Update: useful update: " per Go app state change, -// "Srv: startHTTPSrv: addr:
" when the image server (re)starts. +// "Srv: start: addr:
" when the image server (re)starts. // - Metro (JS): "app focus changed: " when the shell store's app state changes. +// The cold launch test requires a match, 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: / describe('app lifecycle: app state', () => { it('cold launch reaches active under scenes and serves images', async () => { const user = requireSmokeUser() @@ -47,7 +50,7 @@ describe('app lifecycle: app state', () => { ]) expect(goLines).toHaveLength(3) // JS must hold the address of the server Go started, not a stale one. - const started = findLines(goLogSince(goMark), /Srv: startHTTPSrv: addr: /).at(-1) ?? '' + const started = findLines(goLogSince(goMark), httpSrvStarted).at(-1) ?? '' expect(started).toContain(`addr: ${snap.httpSrv.address} `) const avatar = await waitForAvatar200(user) @@ -172,7 +175,7 @@ describe('app lifecycle: app state', () => { expect.stringMatching(/app focus changed: inactive$/), expect.stringMatching(/app focus changed: active$/), ]) - expect(findLines(goLogSince(goMark), /Srv: startHTTPSrv: addr: /)).toEqual([]) + expect(findLines(goLogSince(goMark), httpSrvStarted)).toEqual([]) expect((await appSnapshot()).httpSrv.address).toBe(before.httpSrv.address) }) }) From 83df1280ab4d39cb071a978c492657728d2fa797 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 13:20:10 -0400 Subject: [PATCH 045/127] refactor(kbhttp): run the kbfs and kbhttp local servers from one shared reconciler --- go/kbfs/libhttpserver/appstate.go | 189 --------- go/kbfs/libhttpserver/appstate_test.go | 565 ------------------------- go/kbfs/libhttpserver/server.go | 26 +- go/kbhttp/manager/manager.go | 127 +++--- go/kbhttp/manager/manager_test.go | 293 ++++++++++--- 5 files changed, 312 insertions(+), 888 deletions(-) delete mode 100644 go/kbfs/libhttpserver/appstate.go delete mode 100644 go/kbfs/libhttpserver/appstate_test.go diff --git a/go/kbfs/libhttpserver/appstate.go b/go/kbfs/libhttpserver/appstate.go deleted file mode 100644 index c85ce48aa7c0..000000000000 --- a/go/kbfs/libhttpserver/appstate.go +++ /dev/null @@ -1,189 +0,0 @@ -// 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 libhttpserver - -import ( - "errors" - "net/http" - "sync" - - "github.com/keybase/client/go/kbfs/env" - "github.com/keybase/client/go/kbhttp" - "github.com/keybase/client/go/logger" - "github.com/keybase/client/go/protocol/keybase1" -) - -// appStateServer runs an HTTP server that is up in every app state except -// BACKGROUND, or in every state when it does not stop in the background. Moving between up states (for example an INACTIVE blip from -// Control Center) leaves a running server alone, so in-flight requests -// survive, and restarts one that is not serving. -type appStateServer struct { - appStateUpdater env.AppStateUpdater - logger logger.Logger - newSource func() kbhttp.ListenerSource - register func(mux *http.ServeMux) - // stopInBackground is false on Android, where the server stays up in - // every state. - stopInBackground bool - - // mu guards everything below and serializes starts and stops. - mu sync.Mutex - server *kbhttp.Srv - shutdown bool - // changes counts the app-state changes the monitor has acted on, and - // exitRestart is one past its value at the last restart after an - // unexpected exit, so a listener that keeps dying restarts at most once - // per change. - changes uint64 - exitRestart uint64 - // exits counts handled unexpected exits, for tests. - exits int - // beforeExitRestart, if set, runs in serverExited between reading the - // app state and acting on it. Tests only. - beforeExitRestart func() - // monitorState is the state the monitor last acted on, and monitorWait - // the change channel it waits on for that state; tests use them to wait - // until the monitor has caught up. - monitorState keybase1.MobileAppState - monitorWait <-chan struct{} - - shutdownCh chan struct{} - monitorDone chan struct{} -} - -func newAppStateServer( - appStateUpdater env.AppStateUpdater, log logger.Logger, - newSource func() kbhttp.ListenerSource, register func(mux *http.ServeMux), - stopInBackground bool, -) *appStateServer { - s := &appStateServer{ - appStateUpdater: appStateUpdater, - logger: log, - newSource: newSource, - register: register, - stopInBackground: stopInBackground, - shutdownCh: make(chan struct{}), - monitorDone: make(chan struct{}), - } - s.server = s.newServer() - return s -} - -// start starts serving unless the app is in BACKGROUND, and follows app-state -// changes until Shutdown. An error starting the server is returned, and -// nothing is left running. -func (s *appStateServer) start() error { - s.mu.Lock() - state := s.appStateUpdater.AppState() - var err error - if s.wantUp(state) { - err = s.startLocked() - } - s.mu.Unlock() - if err != nil { - close(s.monitorDone) - return err - } - go s.monitorAppState(state) - return nil -} - -func (s *appStateServer) wantUp(state keybase1.MobileAppState) bool { - return !s.stopInBackground || state != keybase1.MobileAppState_BACKGROUND -} - -func (s *appStateServer) newServer() *kbhttp.Srv { - server := kbhttp.NewSrv(s.logger, s.newSource()) - server.OnUnexpectedExit(s.serverExited) - return server -} - -// startLocked starts the server unless it is serving or shut down. Handlers -// are registered before it accepts connections, so a restart never answers -// 404. -func (s *appStateServer) startLocked() error { - if s.shutdown || s.server.Active() { - return nil - } - err := s.server.StartWithHandlers(s.register) - if errors.Is(err, kbhttp.ErrPinnedPortInUse) { - // Pick a new port like we never had a server before. - s.server = s.newServer() - err = s.server.StartWithHandlers(s.register) - } - return err -} - -func (s *appStateServer) reconcileLocked(state keybase1.MobileAppState) { - if !s.wantUp(state) { - <-s.server.Stop() - return - } - if err := s.startLocked(); err != nil { - s.logger.Error("Starting server in %v failed: %v", state, err) - } -} - -func (s *appStateServer) monitorAppState(state keybase1.MobileAppState) { - defer close(s.monitorDone) - for { - next := s.appStateUpdater.NextAppStateUpdate(state) - s.mu.Lock() - s.monitorState, s.monitorWait = state, next - s.mu.Unlock() - select { - case <-next: - case <-s.shutdownCh: - return - } - s.mu.Lock() - // Read the state under mu, so an unexpected exit deciding concurrently - // sees either the state before this change or its outcome. - state = s.appStateUpdater.AppState() - s.changes++ - s.reconcileLocked(state) - s.mu.Unlock() - } -} - -// serverExited restarts a server whose listener died without a Stop, for -// example one the OS reclaimed while the app was suspended. -func (s *appStateServer) serverExited() { - s.mu.Lock() - defer s.mu.Unlock() - state := s.appStateUpdater.AppState() - if s.beforeExitRestart != nil { - s.beforeExitRestart() - } - s.exits++ - if !s.wantUp(state) || s.exitRestart == s.changes+1 { - s.logger.Debug("Not restarting server after it exited in %v", state) - return - } - s.exitRestart = s.changes + 1 - if err := s.startLocked(); err != nil { - s.logger.Error("Restarting server after it exited failed: %v", err) - } -} - -// Addr returns the address the server is listening on, if it is running. -func (s *appStateServer) Addr() (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.server.Addr() -} - -// Shutdown stops the server for good and waits for it and the monitor to -// exit. -func (s *appStateServer) Shutdown() { - s.mu.Lock() - if !s.shutdown { - s.shutdown = true - close(s.shutdownCh) - <-s.server.Stop() - } - s.mu.Unlock() - <-s.monitorDone -} diff --git a/go/kbfs/libhttpserver/appstate_test.go b/go/kbfs/libhttpserver/appstate_test.go deleted file mode 100644 index 91e482f68c44..000000000000 --- a/go/kbfs/libhttpserver/appstate_test.go +++ /dev/null @@ -1,565 +0,0 @@ -// 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 libhttpserver - -import ( - "errors" - "fmt" - "io" - "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/logger" - "github.com/keybase/client/go/protocol/keybase1" - "github.com/stretchr/testify/require" -) - -// mobileAppState adapts libkb's app state to env.AppStateUpdater, as -// env.KBFSContext does. -type mobileAppState struct { - *libkb.MobileAppState -} - -func (m mobileAppState) NextAppStateUpdate(last keybase1.MobileAppState) <-chan struct{} { - return m.NextUpdate(last) -} - -func (m mobileAppState) AppState() keybase1.MobileAppState { return m.State() } - -func (m mobileAppState) NextNetworkStateUpdate(keybase1.MobileNetworkState) <-chan struct{} { - return nil -} - -func (m mobileAppState) NetworkState() keybase1.MobileNetworkState { - return keybase1.MobileNetworkState_NONE -} - -// listeners hands out pinned random-port listener sources and remembers the -// last listener, so a test can kill it underneath the server. -type listeners struct { - sync.Mutex - calls int - last net.Listener - // failing makes new listeners fail on their first Accept, so Serve - // returns right away. - failing atomic.Bool - // onListen, if set, runs with the address of each new listener before - // the server gets it. - onListen func(address string) -} - -type failingListener struct { - net.Listener -} - -func (failingListener) Accept() (net.Conn, error) { - return nil, errors.New("listener failed") -} - -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() - s.l.calls++ - onListen := s.l.onListen - if err == nil { - s.l.last = listener - if s.l.failing.Load() { - listener = failingListener{listener} - } - } - s.l.Unlock() - if err == nil && onListen != nil { - onListen(address) - } - 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: 10 * time.Second, - Transport: &http.Transport{DisableKeepAlives: true}, -} - -type testServer struct { - *appStateServer - l *listeners - appState *libkb.MobileAppState - // hold, while set, blocks requests to /files/hold until it closes; - // entered receives a value when such a request arrives. - hold chan struct{} - entered chan struct{} - // slowRegister delays handler registration. - slowRegister atomic.Bool -} - -func setupServer(t *testing.T, state keybase1.MobileAppState) *testServer { - tc := libkb.SetupTest(t, "libhttpserver", 2) - t.Cleanup(tc.Cleanup) - tc.G.MobileAppState.Update(state) - return startServer(t, tc.G.MobileAppState, true) -} - -func startServer(t *testing.T, appState *libkb.MobileAppState, stopInBackground bool) *testServer { - ts := &testServer{ - l: &listeners{}, - appState: appState, - hold: make(chan struct{}), - entered: make(chan struct{}, 10), - } - register := func(mux *http.ServeMux) { - if ts.slowRegister.Load() { - time.Sleep(100 * time.Millisecond) - } - mux.HandleFunc(requestPathRoot, func(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == requestPathRoot+"hold" { - ts.entered <- struct{}{} - <-ts.hold - } - fmt.Fprint(w, "ok") - }) - } - ts.appStateServer = newAppStateServer( - mobileAppState{appState}, logger.NewTestLogger(t), ts.l.source, register, - stopInBackground) - require.NoError(t, ts.start()) - t.Cleanup(ts.Shutdown) - return ts -} - -func fetchAddr(addr, path string) (int, error) { - resp, err := client.Get(fmt.Sprintf("http://%s%s%s", addr, requestPathRoot, path)) - if err != nil { - return 0, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return resp.StatusCode, 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 -} - -// waitMonitor waits until the monitor has acted on the current state and is -// waiting for the next change. -func (ts *testServer) waitMonitor(t *testing.T) { - t.Helper() - require.Eventually(t, func() bool { - ts.mu.Lock() - state, wait := ts.monitorState, ts.monitorWait - ts.mu.Unlock() - if wait == nil || wait != ts.appState.NextUpdate(state) { - return false - } - select { - case <-wait: - return false - default: - return true - } - }, 10*time.Second, time.Millisecond, "monitor did not catch up") -} - -func (ts *testServer) update(t *testing.T, state keybase1.MobileAppState) { - t.Helper() - ts.appState.Update(state) - ts.waitMonitor(t) -} - -func (ts *testServer) exitCount() int { - ts.mu.Lock() - defer ts.mu.Unlock() - return ts.exits -} - -func (ts *testServer) waitExits(t *testing.T, n int) { - t.Helper() - require.Eventually(t, func() bool { return ts.exitCount() >= n }, 10*time.Second, - time.Millisecond, "unexpected exit %d was not handled", n) - require.Equal(t, n, ts.exitCount()) -} - -func (ts *testServer) active() bool { - _, err := ts.Addr() - return err == nil -} - -// killUntilDown kills the listener until an unexpected exit is not -// restarted, because this app-state change already had its restart. -func (ts *testServer) killUntilDown(t *testing.T) { - t.Helper() - for range 2 { - n := ts.exitCount() - ts.l.kill(t) - ts.waitExits(t, n+1) - if !ts.active() { - return - } - } - t.Fatal("server kept restarting after unexpected exits") -} - -func (ts *testServer) requireServing(t *testing.T) string { - t.Helper() - addr, err := ts.Addr() - require.NoError(t, err, "server not running") - _, err = fetchAddr(addr, "x") - require.NoError(t, err) - return addr -} - -func (ts *testServer) requireStopped(t *testing.T) { - t.Helper() - _, err := ts.Addr() - require.Error(t, err, "server still running") -} - -var allStates = []keybase1.MobileAppState{ - keybase1.MobileAppState_FOREGROUND, - keybase1.MobileAppState_INACTIVE, - keybase1.MobileAppState_BACKGROUNDACTIVE, - keybase1.MobileAppState_BACKGROUND, -} - -func TestAppStateServerUpUnlessBackground(t *testing.T) { - for _, initial := range allStates { - t.Run(initial.String(), func(t *testing.T) { - ts := setupServer(t, initial) - ts.waitMonitor(t) - check := func() { - t.Helper() - if ts.appState.State() != keybase1.MobileAppState_BACKGROUND { - ts.requireServing(t) - } else { - ts.requireStopped(t) - } - } - check() - for range 2 { - for _, next := range allStates { - ts.update(t, next) - check() - } - } - }) - } - ts := setupServer(t, keybase1.MobileAppState_BACKGROUND) - require.Zero(t, ts.l.Calls(), "server started during a background launch") -} - -// An INACTIVE blip (Control Center, a system alert) neither restarts the -// server nor breaks a request in flight. -func TestAppStateServerInactiveBlipKeepsRequests(t *testing.T) { - for _, blip := range [][]keybase1.MobileAppState{ - {keybase1.MobileAppState_INACTIVE, keybase1.MobileAppState_FOREGROUND}, - {keybase1.MobileAppState_BACKGROUNDACTIVE, keybase1.MobileAppState_FOREGROUND}, - } { - t.Run(fmt.Sprint(blip), func(t *testing.T) { - ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) - ts.waitMonitor(t) - addr := ts.requireServing(t) - - res := make(chan error, 1) - go func() { - _, err := fetchAddr(addr, "hold") - res <- err - }() - select { - case <-ts.entered: - case <-time.After(10 * time.Second): - t.Fatal("request did not arrive") - } - for _, state := range blip { - ts.update(t, state) - } - close(ts.hold) - require.NoError(t, <-res, "in-flight request broke across %v", blip) - require.Equal(t, addr, ts.requireServing(t)) - require.Equal(t, 1, ts.l.Calls(), "server restarted across %v", blip) - }) - } -} - -func TestAppStateServerRestartsDeadServer(t *testing.T) { - ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) - ts.waitMonitor(t) - ts.requireServing(t) - - // Without a transition, a dead server restarts once. - ts.l.kill(t) - ts.waitExits(t, 1) - ts.requireServing(t) - require.Equal(t, 2, ts.l.Calls()) - - // A listener that keeps failing does not restart in a loop. - ts.l.failing.Store(true) - ts.l.kill(t) - ts.waitExits(t, 2) - require.Equal(t, 2, ts.l.Calls(), "restart loop on a failing listener") - ts.requireStopped(t) - - // Every up state brings a dead server back. - ts.l.failing.Store(false) - for _, next := range []keybase1.MobileAppState{ - keybase1.MobileAppState_INACTIVE, - keybase1.MobileAppState_FOREGROUND, - keybase1.MobileAppState_BACKGROUNDACTIVE, - } { - ts.update(t, next) - ts.requireServing(t) - ts.killUntilDown(t) - } - ts.update(t, keybase1.MobileAppState_FOREGROUND) - ts.requireServing(t) -} - -// A BACKGROUND applied while an unexpected exit is deciding whether to -// restart must not leave the server up. -func TestAppStateServerExitRacingBackground(t *testing.T) { - ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) - ts.waitMonitor(t) - ts.requireServing(t) - - ts.mu.Lock() - ts.beforeExitRestart = func() { - // serverExited has read FOREGROUND. The monitor is idle, so mu is - // held here only if serverExited holds it; otherwise let the monitor - // fully apply BACKGROUND before serverExited acts on its stale read. - holdsMu := !ts.mu.TryLock() - if !holdsMu { - ts.mu.Unlock() - } - ts.appState.Update(keybase1.MobileAppState_BACKGROUND) - if !holdsMu { - ts.waitMonitor(t) - } - } - ts.mu.Unlock() - - ts.l.kill(t) - ts.waitExits(t, 1) - ts.waitMonitor(t) - ts.requireStopped(t) -} - -// A request that reaches a restarting server is answered by its handler, -// never with a 404 from a server that has not registered it yet. -func TestAppStateServerNo404DuringRestart(t *testing.T) { - ts := setupServer(t, keybase1.MobileAppState_FOREGROUND) - ts.waitMonitor(t) - ts.requireServing(t) - ts.slowRegister.Store(true) - - restarts := map[string]func(){ - "exit": func() { - n := ts.exitCount() - ts.l.kill(t) - ts.waitExits(t, n+1) - }, - "foreground": func() { - ts.update(t, keybase1.MobileAppState_BACKGROUND) - ts.update(t, keybase1.MobileAppState_FOREGROUND) - }, - } - for name, restart := range restarts { - // The request connects as soon as the new listener exists and is - // served once the server accepts it. - res := make(chan error, 1) - ts.l.Lock() - ts.l.onListen = func(address string) { - go func() { - _, err := fetchAddr(address, "x") - res <- err - }() - } - ts.l.Unlock() - restart() - require.NoError(t, <-res, "request during a restart by %s", name) - ts.l.Lock() - ts.l.onListen = nil - ts.l.Unlock() - ts.requireServing(t) - } -} - -// Without stopping in the background (Android), the server serves in every -// state, and a dead one comes back on any transition or once after it exits. -func TestAppStateServerNotStoppingInBackgroundStaysUp(t *testing.T) { - tc := libkb.SetupTest(t, "libhttpserver", 2) - defer tc.Cleanup() - tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - ts := startServer(t, tc.G.MobileAppState, false) - ts.waitMonitor(t) - ts.requireServing(t) - for _, next := range []keybase1.MobileAppState{ - keybase1.MobileAppState_BACKGROUNDACTIVE, - keybase1.MobileAppState_BACKGROUND, - keybase1.MobileAppState_FOREGROUND, - keybase1.MobileAppState_INACTIVE, - keybase1.MobileAppState_BACKGROUND, - } { - ts.update(t, next) - ts.requireServing(t) - } - - n := ts.exitCount() - ts.l.kill(t) - ts.waitExits(t, n+1) - ts.requireServing(t) - - ts.killUntilDown(t) - ts.update(t, keybase1.MobileAppState_BACKGROUNDACTIVE) - ts.requireServing(t) - ts.killUntilDown(t) - ts.update(t, keybase1.MobileAppState_BACKGROUND) - ts.requireServing(t) -} - -func TestAppStateServerScenarioReplay(t *testing.T) { - for _, sc := range lifecycletest.Scenarios { - t.Run(sc.Name, func(t *testing.T) { - tc := libkb.SetupTest(t, "libhttpserver", 2) - defer tc.Cleanup() - tc.G.MobileAppState.Update(sc.Platform.InitialState()) - // Android keeps the server up in every state. - stopInBackground := sc.Platform == lifecycletest.IOS - wantUp := func(state keybase1.MobileAppState) bool { - return !stopInBackground || state != keybase1.MobileAppState_BACKGROUND - } - ts := startServer(t, tc.G.MobileAppState, stopInBackground) - lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { - ts.waitMonitor(t) - if !wantUp(step.Want) { - if ts.active() { - t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) - } - return - } - addr, err := ts.Addr() - if err != nil { - t.Fatalf("step %d %v: server down in %v", i, step.Do, step.Want) - } - if _, err := fetchAddr(addr, "x"); err != nil { - t.Fatalf("step %d %v: %v", i, step.Do, err) - } - // Leave the server dead before a step that moves to another - // up state, which must bring it back. - if i+1 < len(sc.Steps) { - next := sc.Steps[i+1].Want - if next != step.Want && wantUp(next) { - ts.killUntilDown(t) - } - } - }) - }) - } -} - -// Transitions, deaths and requests racing each other leave a working server -// and no goroutines after Shutdown. -func TestAppStateServerStress(t *testing.T) { - tc := libkb.SetupTest(t, "libhttpserver", 2) - defer tc.Cleanup() - baseline := runtime.NumGoroutine() - tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - ts := startServer(t, tc.G.MobileAppState, true) - - stop := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; ; i++ { - select { - case <-stop: - return - default: - } - tc.G.MobileAppState.Update(allStates[i%len(allStates)]) - } - }() - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - default: - } - if addr, err := ts.Addr(); err == nil { - if status, err := fetchAddr(addr, "x"); err != nil && status != 0 && status != http.StatusOK { - t.Errorf("request: %v", err) - } - } - } - }() - } - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - case <-time.After(5 * time.Millisecond): - } - ts.l.Lock() - if ts.l.last != nil { - _ = ts.l.last.Close() - } - ts.l.Unlock() - } - }() - time.Sleep(time.Second) - close(stop) - wg.Wait() - - tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - ts.waitMonitor(t) - ts.update(t, keybase1.MobileAppState_FOREGROUND) - ts.requireServing(t) - - ts.Shutdown() - ts.requireStopped(t) - tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - ts.serverExited() - require.False(t, ts.active(), "server started after Shutdown") - require.Eventually(t, func() bool { - return runtime.NumGoroutine() <= baseline+5 - }, 10*time.Second, 10*time.Millisecond, "goroutines outlived Shutdown") -} diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index d8e6c142939a..07667f955d4a 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -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" @@ -43,7 +44,7 @@ type Server struct { fs *lru.Cache - server *appStateServer + server *manager.Srv } const ( @@ -218,9 +219,15 @@ const ( requestPathRoot = "/files/" ) -func (s *Server) registerHandlers(mux *http.ServeMux) { - mux.Handle(requestPathRoot, - http.StripPrefix(requestPathRoot, http.HandlerFunc(s.serve))) +// appState adapts env.AppStateUpdater to manager.AppState. +type appState struct { + env.AppStateUpdater +} + +func (a appState) State() keybase1.MobileAppState { return a.AppState() } + +func (a appState) NextUpdate(last keybase1.MobileAppState) <-chan struct{} { + return a.NextAppStateUpdate(last) } // New creates and starts a new server. @@ -241,13 +248,18 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( if err != nil { return nil, err } - s.server = newAppStateServer(appStateUpdater, logger, + s.server, err = manager.New(logger, appState{appStateUpdater}, func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(portStart, portEnd) - }, s.registerHandlers, runtime.GOOS != "android") - if err = s.server.start(); err != nil { + }, runtime.GOOS != "android", func(context.Context, keybase1.HttpSrvInfo) {}) + if err != nil { + s.server.Shutdown() return nil, err } + // 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 } diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 53f4a01e43d8..04436cd71414 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -27,29 +28,24 @@ type srvEndpoint struct { serve func(w http.ResponseWriter, req *http.Request) } -// srvStatus is what run last published; readers never wait on run. -type srvStatus struct { - active bool - info keybase1.HttpSrvInfo - // state is the app state run last acted on and wait the change channel it - // waits on for it; exits counts unexpected exits it handled. Tests use them. - state keybase1.MobileAppState - wait <-chan struct{} - exits int -} - type handlerRequest struct { endpoint string desc srvEndpoint done chan struct{} } -// Srv runs the local HTTP server. One goroutine, run, owns it: only run starts +// AppState is the app state a Srv follows. +type AppState interface { + State() keybase1.MobileAppState + NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} +} + +// Srv runs a local HTTP server. One goroutine, run, owns it: only run starts // and stops it, reacting to app state changes, unexpected exits, handler // registrations and shutdown. type Srv struct { - libkb.Contextified - + log logger.Logger + appState AppState // token is set once and kept across restarts, so URLs handed out before a restart keep working. token string listenerSource func() kbhttp.ListenerSource @@ -57,7 +53,8 @@ type Srv struct { // notify runs on run, so it must not call HandleFunc. notify func(context.Context, keybase1.HttpSrvInfo) - status atomic.Pointer[srvStatus] + // status is what run last published, empty while not serving; readers never wait on run. + status atomic.Pointer[keybase1.HttpSrvInfo] exited chan struct{} handlers chan handlerRequest shutdownOnce sync.Once @@ -68,29 +65,37 @@ type Srv struct { httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint state keybase1.MobileAppState - wait <-chan struct{} - exits int // restartedSinceChange caps restarts after unexpected exits at one per app // state change, so a listener that keeps dying doesn't spin. restartedSinceChange bool } +// NewSrv runs the service's HTTP server until the service shuts down. func NewSrv(g *libkb.GlobalContext) *Srv { listenerSource := func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) } - return newSrv(g, listenerSource, runtime.GOOS != "android", func(ctx context.Context, info keybase1.HttpSrvInfo) { + // A failed start is logged, and the next app state change tries again. + r, _ := New(g.GetLog(), g.MobileAppState, listenerSource, runtime.GOOS != "android", func(ctx context.Context, info keybase1.HttpSrvInfo) { // Read NotifyRouter when notifying: the service sets it after creating this server. g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) }) + g.PushShutdownHook(func(libkb.MetaContext) error { + r.Shutdown() + return nil + }) + return r } -func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, stopInBackground bool, +// 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. +func New(log logger.Logger, appState AppState, listenerSource func() kbhttp.ListenerSource, stopInBackground bool, notify func(context.Context, keybase1.HttpSrvInfo), -) *Srv { +) (*Srv, error) { token, _ := libkb.RandHexString("", 32) r := &Srv{ - Contextified: libkb.NewContextified(g), + log: log, + appState: appState, token: token, listenerSource: listenerSource, stopInBackground: stopInBackground, @@ -102,18 +107,13 @@ func newSrv(g *libkb.GlobalContext, listenerSource func() kbhttp.ListenerSource, endpoints: make(map[string]srvEndpoint), } r.httpSrv = r.newHTTPSrv() - g.PushShutdownHook(func(libkb.MetaContext) error { - r.stop() - return nil - }) - ready := make(chan struct{}) - go r.run(g.MobileAppState.State(), ready) - <-ready - return r + 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, "Srv: %s", fmt.Sprintf(msg, args...)) } // TokenPrefix shortens a token for logging. @@ -125,7 +125,7 @@ func TokenPrefix(token string) string { } func (r *Srv) newHTTPSrv() *kbhttp.Srv { - srv := kbhttp.NewSrv(r.G().GetLog(), r.listenerSource()) + srv := kbhttp.NewSrv(r.log, r.listenerSource()) srv.OnUnexpectedExit(func() { select { case r.exited <- struct{}{}: @@ -139,24 +139,23 @@ func (r *Srv) wantUp(state keybase1.MobileAppState) bool { return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } -func (r *Srv) run(state keybase1.MobileAppState, ready chan struct{}) { +func (r *Srv) run(ready chan<- error) { defer close(r.done) ctx := context.Background() - r.state = state - r.debug(ctx, "run: starting up in %v", state) - r.reconcile(ctx) + r.state = r.appState.State() + r.debug(ctx, "run: starting up in %v", r.state) + err := r.reconcile(ctx) for { - r.wait = r.G().MobileAppState.NextUpdate(r.state) r.publish() if ready != nil { - close(ready) + ready <- err ready = nil } select { - case <-r.wait: - r.state = r.G().MobileAppState.State() + case <-r.appState.NextUpdate(r.state): + r.state = r.appState.State() r.restartedSinceChange = false - r.reconcile(ctx) + _ = r.reconcile(ctx) case <-r.exited: r.serverExited(ctx) case req := <-r.handlers: @@ -168,7 +167,7 @@ func (r *Srv) run(state keybase1.MobileAppState, ready chan struct{}) { close(req.done) case <-r.shutdownCh: <-r.httpSrv.Stop() - r.status.Store(&srvStatus{}) + r.status.Store(&keybase1.HttpSrvInfo{}) return } } @@ -177,12 +176,12 @@ func (r *Srv) run(state keybase1.MobileAppState, ready chan struct{}) { // 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) { +func (r *Srv) reconcile(ctx context.Context) error { if !r.wantUp(r.state) { r.httpSrv.Stop() - return + return nil } - r.start(ctx) + return r.start(ctx) } // serverExited restarts a server whose listener died without a Stop, for @@ -191,19 +190,18 @@ func (r *Srv) serverExited(ctx context.Context) { if r.httpSrv.Active() { return } - r.exits++ if !r.wantUp(r.state) || r.restartedSinceChange { r.debug(ctx, "serverExited: not restarting in %v", r.state) return } r.restartedSinceChange = true r.debug(ctx, "serverExited: restarting in %v", r.state) - r.start(ctx) + _ = r.start(ctx) } -func (r *Srv) start(ctx context.Context) { +func (r *Srv) start(ctx context.Context) error { if r.httpSrv.Active() { - return + return nil } err := r.httpSrv.StartWithHandlers(r.registerEndpoints) if errors.Is(err, kbhttp.ErrPinnedPortInUse) { @@ -214,26 +212,26 @@ func (r *Srv) start(ctx context.Context) { err = r.httpSrv.StartWithHandlers(r.registerEndpoints) } if err != nil { - r.debug(ctx, "start: failed to start HTTP server: %s", err) - return + r.log.CWarningf(ctx, "Srv: start: failed to start HTTP server: %s", err) + return err } // Publish before notifying, so a listener reading Info gets the address it is told about. - r.publish() - info, err := r.Info() - if err != nil { // Serve already exited; run handles that exit next - return + info := r.publish() + if info.Address == "" { // Serve already exited; run handles that exit next + return nil } r.debug(ctx, "start: addr: %s token: %s", info.Address, TokenPrefix(r.token)) r.notify(ctx, info) + return nil } -func (r *Srv) publish() { - st := &srvStatus{state: r.state, wait: r.wait, exits: r.exits} +func (r *Srv) publish() keybase1.HttpSrvInfo { + var info keybase1.HttpSrvInfo if addr, err := r.httpSrv.Addr(); err == nil { - st.active = true - st.info = keybase1.HttpSrvInfo{Address: addr, Token: r.token} + info = keybase1.HttpSrvInfo{Address: addr, Token: r.token} } - r.status.Store(st) + r.status.Store(&info) + return info } func (r *Srv) registerEndpoints(mux *http.ServeMux) { @@ -242,7 +240,8 @@ func (r *Srv) registerEndpoints(mux *http.ServeMux) { } } -func (r *Srv) stop() { +// 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 } @@ -277,7 +276,7 @@ func (r *Srv) checkToken(tokenMode SrvTokenMode, } } -func (r *Srv) Active() bool { return r.status.Load().active } +func (r *Srv) Active() bool { return r.status.Load().Address != "" } func (r *Srv) Addr() (string, error) { info, err := r.Info() @@ -288,9 +287,9 @@ func (r *Srv) Token() string { return r.token } // Info returns the address and token together, for handing both to a client. func (r *Srv) Info() (keybase1.HttpSrvInfo, error) { - st := r.status.Load() - if !st.active { + info := *r.status.Load() + if info.Address == "" { return keybase1.HttpSrvInfo{}, errors.New("server not running") } - return st.info, nil + return info, nil } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index e6ebef92af46..c0b9f9d73792 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -123,6 +123,26 @@ var client = &http.Client{ Transport: &http.Transport{DisableKeepAlives: true}, } +// appState records each turn of run, which asks for the next update once +// per turn, after publishing. +type appState struct { + *libkb.MobileAppState + mu sync.Mutex + turns int + 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.turns++ + a.wait = wait + return wait +} + +func app(srv *Srv) *appState { return srv.appState.(*appState) } + func setup(t *testing.T, state keybase1.MobileAppState, stopInBackground bool) (*Srv, *listeners) { return setupWithNotify(t, state, stopInBackground, func(context.Context, keybase1.HttpSrvInfo) {}) } @@ -134,19 +154,25 @@ func setupWithNotify(t *testing.T, state keybase1.MobileAppState, stopInBackgrou t.Cleanup(tc.Cleanup) tc.G.MobileAppState.Update(state) l := &listeners{} - srv := newSrv(tc.G, l.source, stopInBackground, notify) - // newSrv returns having acted on the launch state; HandleFunc below would wait for run anyway. - require.Equal(t, srv.wantUp(state), srv.Active(), "launch state not applied when newSrv returned") + srv, err := New(tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, stopInBackground, notify) + require.NoError(t, err) + 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), srv.Active(), "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 } -// fetch returns the HTTP status, or 0 with an error when no response came -// back. func fetch(info keybase1.HttpSrvInfo) (int, error) { - resp, err := client.Get(fmt.Sprintf("http://%s/test?token=%s", info.Address, info.Token)) + 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 } @@ -161,16 +187,21 @@ func fetch(info keybase1.HttpSrvInfo) (int, error) { return resp.StatusCode, nil } -// waitLoop waits until run has acted on the current app state and is waiting for the next change. +// waitLoop waits until run has published for the current app state and +// waits for its next change. Handler requests are synchronous, and exits are +// awaited with waitTurns, so no event a caller made is still pending. func waitLoop(t *testing.T, srv *Srv) { t.Helper() require.Eventually(t, func() bool { - st := srv.status.Load() - if st == nil || st.wait == nil || st.wait != srv.G().MobileAppState.NextUpdate(st.state) { + a := app(srv) + a.mu.Lock() + wait := a.wait + a.mu.Unlock() + if wait == nil { return false } select { - case <-st.wait: + case <-wait: return false default: return true @@ -178,15 +209,19 @@ func waitLoop(t *testing.T, srv *Srv) { }, 10*time.Second, time.Millisecond, "run did not catch up") } -func exits(srv *Srv) int { - return srv.status.Load().exits +func turns(srv *Srv) int { + a := app(srv) + a.mu.Lock() + defer a.mu.Unlock() + return a.turns } -func waitExits(t *testing.T, srv *Srv, n int) { +// waitTurns waits until run has handled events up to turn n, and no more. +func waitTurns(t *testing.T, srv *Srv, n int) { t.Helper() - require.Eventually(t, func() bool { return exits(srv) >= n }, 10*time.Second, time.Millisecond, - "unexpected exit %d was not handled", n) - require.Equal(t, n, exits(srv)) + require.Eventually(t, func() bool { return turns(srv) >= n }, 10*time.Second, time.Millisecond, + "run did not reach turn %d", n) + require.Equal(t, n, turns(srv)) } // killUntilDown kills the listener until an unexpected exit is not @@ -194,9 +229,9 @@ func waitExits(t *testing.T, srv *Srv, n int) { func killUntilDown(t *testing.T, srv *Srv, l *listeners) { t.Helper() for range 2 { - n := exits(srv) + n := turns(srv) l.kill(t) - waitExits(t, srv, n+1) + waitTurns(t, srv, n+1) if !srv.Active() { return } @@ -231,7 +266,7 @@ func TestDeadListenerRestartsOnTransition(t *testing.T) { keybase1.MobileAppState_BACKGROUNDACTIVE, } { killUntilDown(t, srv, l) - srv.G().MobileAppState.Update(next) + app(srv).Update(next) waitLoop(t, srv) requireServing(t, srv) } @@ -241,8 +276,9 @@ func TestDeadListenerRestartsWithoutTransition(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) waitLoop(t, srv) first := requireServing(t, srv) + n := turns(srv) l.kill(t) - waitExits(t, srv, 1) + waitTurns(t, srv, n+1) again := requireServing(t, srv) require.Equal(t, first.Token, again.Token) require.Equal(t, 2, l.Calls()) @@ -253,23 +289,25 @@ func TestUnexpectedExitRestartsOncePerStateChange(t *testing.T) { waitLoop(t, srv) requireServing(t, srv) + n := turns(srv) l.failing.Store(true) l.kill(t) // The restart's listener fails at once; its exit must not restart again. - // run handles each exit before the next start, so once two exits are + // run handles each exit before the next start, so once both exits are // handled the listener count is final. - waitExits(t, srv, 2) + waitTurns(t, srv, n+2) require.Equal(t, 2, l.Calls(), "restart loop on a failing listener") requireStopped(t, srv) - // A new app state change allows one more restart after run's own start. - srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) - waitLoop(t, srv) - waitExits(t, srv, 4) + // A new app state change allows one more restart after run's own start: + // turns for the change, the start's exit and the restart's exit. + n = turns(srv) + app(srv).Update(keybase1.MobileAppState_INACTIVE) + waitTurns(t, srv, n+3) require.Equal(t, 4, l.Calls(), "restart loop on a failing listener") l.failing.Store(false) - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) waitLoop(t, srv) requireServing(t, srv) } @@ -282,10 +320,10 @@ func TestUnexpectedExitRacingBackground(t *testing.T) { l.blockNext() l.kill(t) l.waitBlocked(t) // run is inside start, waiting for a listener - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + app(srv).Update(keybase1.MobileAppState_BACKGROUND) l.release() waitLoop(t, srv) - require.Equal(t, keybase1.MobileAppState_BACKGROUND, srv.G().MobileAppState.State()) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, app(srv).State()) requireStopped(t, srv) } @@ -293,12 +331,25 @@ func TestNothingStartsAfterShutdown(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) waitLoop(t, srv) requireServing(t, srv) - srv.stop() + srv.Shutdown() requireStopped(t, srv) calls := l.Calls() - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - srv.HandleFunc("late", SrvTokenModeDefault, func(http.ResponseWriter, *http.Request) {}) // returns: run is done + app(srv).Update(keybase1.MobileAppState_BACKGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) + select { // an exit signal nobody handles + case srv.exited <- struct{}{}: + default: + } + 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 srv.Active() || l.Calls() != calls }, 200*time.Millisecond, 10*time.Millisecond) } @@ -313,7 +364,7 @@ func TestInfoUpdateAnnouncesAPublishedAddress(t *testing.T) { } seen <- err }) - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) select { case err := <-seen: require.NoError(t, err) @@ -340,18 +391,18 @@ func TestInactiveKeepsServingBackgroundStops(t *testing.T) { first := requireServing(t, srv) require.Equal(t, 1, l.Calls()) - srv.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + 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") - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + app(srv).Update(keybase1.MobileAppState_BACKGROUND) waitLoop(t, srv) requireStopped(t, srv) _, err := fetch(first) require.Error(t, err) - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) waitLoop(t, srv) again := requireServing(t, srv) // Usually 2; another process may take the pinned port while stopped. @@ -369,35 +420,134 @@ func TestBackgroundLaunchStartsOnlyWhenLeavingBackground(t *testing.T) { waitLoop(t, srv) require.Equal(t, 0, l.Calls()) - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + 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 or BACKGROUNDACTIVE blip neither restarts the server nor breaks +// a request in flight. +func TestBlipKeepsRequestInFlight(t *testing.T) { + for _, blip := range []keybase1.MobileAppState{ + keybase1.MobileAppState_INACTIVE, + keybase1.MobileAppState_BACKGROUNDACTIVE, + } { + t.Run(blip.String(), func(t *testing.T) { + srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + 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 a dead one comes back on any transition or once after it exits. 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, } { - srv.G().MobileAppState.Update(next) + app(srv).Update(next) waitLoop(t, srv) requireServing(t, srv) } - killUntilDown(t, srv, l) - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - waitLoop(t, srv) + + n := turns(srv) + l.kill(t) + waitTurns(t, srv, n+1) requireServing(t, srv) + + for _, next := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUNDACTIVE, + keybase1.MobileAppState_BACKGROUND, + } { + killUntilDown(t, srv, l) + app(srv).Update(next) + waitLoop(t, srv) + requireServing(t, srv) + } +} + +type failingSource struct{} + +func (failingSource) GetListener() (net.Listener, string, error) { + return nil, "", errors.New("no listener") +} + +// New reports a failed first start, which kbfs treats as fatal. +func TestNewReturnsFirstStartError(t *testing.T) { + tc := libkb.SetupTest(t, "kbhttp", 2) + defer tc.Cleanup() + tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + srv, err := New(tc.G.Log, tc.G.MobileAppState, func() kbhttp.ListenerSource { return failingSource{} }, true, + func(context.Context, keybase1.HttpSrvInfo) {}) + require.Error(t, err) + requireStopped(t, srv) + srv.Shutdown() } 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, keybase1.MobileAppState_FOREGROUND, stopInBackground) - lifecycletest.Play(t, srv.G().MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { + srv, l := setup(t, sc.Platform.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 srv.Active() { @@ -454,14 +604,14 @@ func TestPinnedPortTakenPicksNewAddress(t *testing.T) { }() } - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + 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() - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) waitLoop(t, srv) close(stop) readers.Wait() @@ -527,9 +677,9 @@ func TestConcurrentRequestsDuringRestart(t *testing.T) { }() for range 50 { - srv.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + app(srv).Update(keybase1.MobileAppState_BACKGROUND) waitLoop(t, srv) - srv.G().MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) + app(srv).Update(keybase1.MobileAppState_FOREGROUND) waitLoop(t, srv) time.Sleep(time.Millisecond) } @@ -546,13 +696,17 @@ func TestConcurrentRequestsDuringRestart(t *testing.T) { 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{} - srv := newSrv(tc.G, l.source, true, func(context.Context, keybase1.HttpSrvInfo) {}) + srv, err := New(tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, true, + func(context.Context, keybase1.HttpSrvInfo) {}) + require.NoError(t, err) srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { fmt.Fprint(w, "ok") }) @@ -600,13 +754,29 @@ func TestStressTransitionsAndRequests(t *testing.T) { 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 { - tc.G.MobileAppState.Update(states[rng.Intn(len(states))]) + app(srv).Update(states[rng.Intn(len(states))]) if rng.Intn(4) == 0 { time.Sleep(time.Duration(rng.Intn(200)) * time.Microsecond) } @@ -632,25 +802,22 @@ func TestStressTransitionsAndRequests(t *testing.T) { default: } - // Make a real change so run must wake for it. - final := keybase1.MobileAppState_INACTIVE - if tc.G.MobileAppState.State() == final { - final = keybase1.MobileAppState_FOREGROUND + // BACKGROUND stops every server, so no exit from a killed listener can + // restart one later; leaving it is a real change run must wake for. + for _, state := range []keybase1.MobileAppState{ + keybase1.MobileAppState_BACKGROUND, + keybase1.MobileAppState_FOREGROUND, + } { + app(srv).Update(state) + waitLoop(t, srv) } - tc.G.MobileAppState.Update(final) - waitLoop(t, srv) require.Equal(t, token, requireServing(t, srv).Token) - tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + app(srv).Update(keybase1.MobileAppState_BACKGROUND) waitLoop(t, srv) requireStopped(t, srv) t.Logf("%d good responses, %d listeners", ok.Load(), l.Calls()) - srv.stop() - select { - case <-srv.done: - case <-time.After(10 * time.Second): - t.Fatal("run did not exit on shutdown") - } + srv.Shutdown() deadline := time.Now().Add(10 * time.Second) for runtime.NumGoroutine() > baseline+5 && time.Now().Before(deadline) { From d94e71c7b83ab9e557d2db65c1fe1fecf43a71ee Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 13:25:18 -0400 Subject: [PATCH 046/127] fix(kbhttp): name each local server in its log lines and keep the start marker to the service server --- go/kbfs/libhttpserver/server.go | 2 +- go/kbhttp/manager/manager.go | 13 ++++++++----- go/kbhttp/manager/manager_test.go | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index 07667f955d4a..82ddc39d7ec0 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -248,7 +248,7 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( if err != nil { return nil, err } - s.server, err = manager.New(logger, appState{appStateUpdater}, + s.server, err = manager.New("kbfsHTTP", logger, appState{appStateUpdater}, func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(portStart, portEnd) }, runtime.GOOS != "android", func(context.Context, keybase1.HttpSrvInfo) {}) diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 04436cd71414..c9e45dc78c29 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -44,6 +44,7 @@ type AppState interface { // and stops it, reacting to app state changes, unexpected exits, handler // registrations and shutdown. type Srv struct { + name string // prefixes every log line, so each server's lines are told apart log logger.Logger appState AppState // token is set once and kept across restarts, so URLs handed out before a restart keep working. @@ -76,7 +77,9 @@ func NewSrv(g *libkb.GlobalContext) *Srv { return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) } // A failed start is logged, and the next app state change tries again. - r, _ := New(g.GetLog(), g.MobileAppState, listenerSource, runtime.GOOS != "android", func(ctx context.Context, info keybase1.HttpSrvInfo) { + r, _ := New("Srv", g.GetLog(), g.MobileAppState, 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)) // Read NotifyRouter when notifying: the service sets it after creating this server. g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) }) @@ -89,11 +92,12 @@ func NewSrv(g *libkb.GlobalContext) *Srv { // 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. -func New(log logger.Logger, appState AppState, listenerSource func() kbhttp.ListenerSource, stopInBackground bool, +func New(name string, log logger.Logger, appState AppState, 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, token: token, @@ -113,7 +117,7 @@ func New(log logger.Logger, appState AppState, listenerSource func() kbhttp.List } func (r *Srv) debug(ctx context.Context, msg string, args ...any) { - r.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. @@ -212,7 +216,7 @@ func (r *Srv) start(ctx context.Context) error { err = r.httpSrv.StartWithHandlers(r.registerEndpoints) } if err != nil { - r.log.CWarningf(ctx, "Srv: start: failed to start HTTP server: %s", err) + r.log.CWarningf(ctx, "%s: start: failed to start HTTP server: %s", r.name, err) return err } // Publish before notifying, so a listener reading Info gets the address it is told about. @@ -220,7 +224,6 @@ func (r *Srv) start(ctx context.Context) error { if info.Address == "" { // Serve already exited; run handles that exit next return nil } - r.debug(ctx, "start: addr: %s token: %s", info.Address, TokenPrefix(r.token)) r.notify(ctx, info) return nil } diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index c0b9f9d73792..db92e83d3ae7 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -154,7 +154,7 @@ func setupWithNotify(t *testing.T, state keybase1.MobileAppState, stopInBackgrou t.Cleanup(tc.Cleanup) tc.G.MobileAppState.Update(state) l := &listeners{} - srv, err := New(tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, stopInBackground, notify) + srv, err := New("Srv", tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, stopInBackground, notify) require.NoError(t, err) t.Cleanup(srv.Shutdown) // New returns having acted on the launch state; HandleFunc below would wait for run anyway. @@ -535,7 +535,7 @@ func TestNewReturnsFirstStartError(t *testing.T) { tc := libkb.SetupTest(t, "kbhttp", 2) defer tc.Cleanup() tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - srv, err := New(tc.G.Log, tc.G.MobileAppState, func() kbhttp.ListenerSource { return failingSource{} }, true, + srv, err := New("Srv", tc.G.Log, tc.G.MobileAppState, func() kbhttp.ListenerSource { return failingSource{} }, true, func(context.Context, keybase1.HttpSrvInfo) {}) require.Error(t, err) requireStopped(t, srv) @@ -704,7 +704,7 @@ func TestStressTransitionsAndRequests(t *testing.T) { baseline := runtime.NumGoroutine() l := &listeners{} - srv, err := New(tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, true, + srv, err := New("Srv", tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, true, func(context.Context, keybase1.HttpSrvInfo) {}) require.NoError(t, err) srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { From 3b137dc0fc5b8c7910c9c5d05277bc81e0951280 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 13:44:34 -0400 Subject: [PATCH 047/127] refactor(lifecycle): let Go own the background task and take stay as a bool The controller no longer calls back into its callers: bind decides whether work must keep running before it calls, so PushWindowEnd is one critical section. UIBackground and PushWindowEnd start the background task themselves and Close ends the ones still running, so native only waits (iOS) or does nothing (Android). Reason, UIState and the log labels are plain strings, one predicate ends holds, and the flush rule reads from the state transition. --- go/bind/keybase.go | 41 +-- go/bind/location_test.go | 3 +- go/chat/maps/livelocation_appstate_test.go | 6 +- go/chat/maps/livelocation_watch_test.go | 6 +- go/libkb/lifecycle/controller_test.go | 157 +-------- go/libkb/lifecycle/lifecycle.go | 325 +++++++----------- go/libkb/lifecycle/lifecycletest/harness.go | 80 ++--- go/libkb/lifecycle/lifecycletest/scenarios.go | 145 ++++---- go/libkb/lifecycle/scenario_test.go | 1 - .../keybase/ossifrage/AppLifecycleReporter.kt | 17 +- .../keybase/ossifrage/KeybaseLifecycleBind.kt | 9 +- .../ossifrage/AppLifecycleReporterTest.kt | 34 +- shared/ios/Keybase/AppDelegate.swift | 58 +--- 13 files changed, 300 insertions(+), 582 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index c451e394ac69..4ac276ba2508 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -972,7 +972,7 @@ func AppWillExit(pusher PushNotifier) { } // AppBackgroundTaskExpired is called when the OS is about to suspend the app -// before the background task started by AppBeginBackgroundTask finished. It +// 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) { @@ -1013,14 +1013,15 @@ func shouldStayRunningInBackground() bool { return false } -// AppUIBackground reports the app off screen. It returns a background task token -// for AppBeginBackgroundTask when work must keep running, 0 otherwise. -func AppUIBackground() int64 { +// AppUIBackground reports the app off screen. When work must keep running it +// starts a background task and returns its token for AppWaitBackgroundTask, +// 0 otherwise. +func AppUIBackground(pusher PushNotifier) int64 { if !isInited() { return 0 } defer kbCtx.Trace("AppUIBackground", nil)() - return kbCtx.MobileLifecycle.UIBackground(shouldStayRunningInBackground) + return kbCtx.MobileLifecycle.UIBackground(shouldStayRunningInBackground(), backgroundTaskDeps(pusher)) } // AppPushWindowBegin holds the app up while a push notification is handled, @@ -1035,34 +1036,24 @@ func AppPushWindowBegin() int64 { return kbCtx.MobileLifecycle.PushWindowBegin() } -// AppPushWindowEnd ends the hold opened by AppPushWindowBegin. It returns a -// background task token for AppBeginBackgroundTaskNonblock when work must keep -// running, 0 otherwise. -func AppPushWindowEnd(token int64) int64 { - if !isInited() { - return 0 - } - defer kbCtx.Trace("AppPushWindowEnd", nil)() - return kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground) -} - -func AppBeginBackgroundTaskNonblock(token int64, pusher PushNotifier) { +// AppPushWindowEnd ends the hold opened by AppPushWindowBegin, first starting +// a background task when work must keep running. +func AppPushWindowEnd(token int64, pusher PushNotifier) { if !isInited() { return } - defer kbCtx.Trace("AppBeginBackgroundTaskNonblock", nil)() - go AppBeginBackgroundTask(token, pusher) + defer kbCtx.Trace("AppPushWindowEnd", nil)() + kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground(), backgroundTaskDeps(pusher)) } -// AppBeginBackgroundTask runs the background task whose token AppUIBackground or -// AppPushWindowEnd returned. It returns once we no longer need any time in the -// background. -func AppBeginBackgroundTask(token int64, pusher PushNotifier) { +// AppWaitBackgroundTask returns once the background task whose token +// AppUIBackground returned no longer needs any time in the background. +func AppWaitBackgroundTask(token int64) { if !isInited() { return } - defer kbCtx.Trace("AppBeginBackgroundTask", nil)() - kbCtx.MobileLifecycle.RunBackgroundTask(context.Background(), token, backgroundTaskDeps(pusher)) + defer kbCtx.Trace("AppWaitBackgroundTask", nil)() + kbCtx.MobileLifecycle.WaitBackgroundTask(token) } func backgroundTaskDeps(pusher PushNotifier) lifecycle.BackgroundTaskDeps { diff --git a/go/bind/location_test.go b/go/bind/location_test.go index b41c23b68890..37a021bf5d95 100644 --- a/go/bind/location_test.go +++ b/go/bind/location_test.go @@ -11,6 +11,7 @@ import ( "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" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/clockwork" @@ -41,7 +42,7 @@ func TestLocationUpdateReachesTrackers(t *testing.T) { tracker.SetClock(clock) tracker.TestingCoordsAddedCh = make(chan struct{}, 10) ctx := context.Background() - require.Zero(t, tc.G.MobileLifecycle.UIBackground(func() bool { return false })) + require.Zero(t, tc.G.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{})) tracker.StartTracking(ctx, chat1.ConversationID("conv"), 1, clock.Now().Add(time.Hour)) select { diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go index b9a7c697c134..ac57f68daedc 100644 --- a/go/chat/maps/livelocation_appstate_test.go +++ b/go/chat/maps/livelocation_appstate_test.go @@ -9,6 +9,7 @@ import ( "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle" "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" ) @@ -34,9 +35,8 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { l.removeTrackerLocked(ctx, track) } - noStay := func() bool { return false } lc := tc.G.MobileLifecycle - require.Zero(t, lc.UIBackground(noStay)) + require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) 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") @@ -55,7 +55,7 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { third := addTracker(3) l.LocationUpdate(ctx, coord(3)) require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) - require.Zero(t, lc.UIBackground(noStay)) + require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) removeTracker(third) require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go index b7fe96361f3f..9ad83b9827f4 100644 --- a/go/chat/maps/livelocation_watch_test.go +++ b/go/chat/maps/livelocation_watch_test.go @@ -13,6 +13,7 @@ import ( "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" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/clockwork" @@ -274,14 +275,13 @@ func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { l := newWatchTestTracker(t, tc, nil, ui) clock := l.clock.(clockwork.FakeClock) appState := tc.G.MobileAppState - noStay := func() bool { return false } 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}) - require.Zero(t, tc.G.MobileLifecycle.UIBackground(noStay)) + require.Zero(t, tc.G.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{})) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) for ui.attempts.Load() < 22 { @@ -296,6 +296,6 @@ func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { // 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}) - require.Zero(t, tc.G.MobileLifecycle.UIBackground(noStay)) + require.Zero(t, tc.G.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{})) require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) } diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 4665e730871b..af6e81d0365e 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -25,9 +25,7 @@ const ( inactive = keybase1.MobileAppState_INACTIVE ) -func stay() bool { return true } -func noStay() bool { return false } -func noop() {} +func noop() {} func noDeliveries() lifecycle.BackgroundTaskDeps { return lifecycle.BackgroundTaskDeps{ @@ -39,87 +37,35 @@ func noDeliveries() lifecycle.BackgroundTaskDeps { } } +// An id is never reused, so releasing an old hold again can't end a newer one. func TestHoldReleaseIsIdempotent(t *testing.T) { appState, _ := newAppState(t) flushes := 0 c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) - require.Zero(t, c.UIBackground(noStay)) + require.Zero(t, c.UIBackground(false, noDeliveries())) require.Equal(t, background, appState.State()) require.Equal(t, 1, flushes) - h := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) + first := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) require.Equal(t, backgroundActive, appState.State()) - require.True(t, h.Release()) - require.True(t, h.Released()) + require.True(t, first.Release()) + require.True(t, first.Released()) require.Equal(t, background, appState.State()) require.Equal(t, 2, flushes) - require.False(t, h.Release()) - require.Equal(t, 2, flushes) -} - -// An id is never reused, so releasing an old hold again can't end a newer one. -func TestReleasingAnOldHoldNeverReleasesANewerOne(t *testing.T) { - appState, _ := newAppState(t) - c := lifecycle.New(appState, lifecycle.Config{}) - c.UIBackground(noStay) - first := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) - require.True(t, first.Release()) second := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) - require.NotEqual(t, first.ID(), second.ID()) require.False(t, first.Release()) require.False(t, second.Released()) require.Equal(t, backgroundActive, appState.State()) require.True(t, second.Release()) require.Equal(t, background, appState.State()) -} - -func TestLaunchHoldEndsAtTheFirstUIReport(t *testing.T) { - reports := map[string]func(c *lifecycle.Controller){ - "background": func(c *lifecycle.Controller) { c.UIBackground(noStay) }, - "inactive": func(c *lifecycle.Controller) { c.UIInactive() }, - "active": func(c *lifecycle.Controller) { c.UIActive() }, - } - for name, report := range reports { - t.Run(name, func(t *testing.T) { - appState, _ := newAppState(t) - appState.Update(backgroundActive) - c := lifecycle.New(appState, lifecycle.Config{}) - require.Equal(t, 1, lifecycle.Holds(c)) - report(c) - require.Equal(t, 0, lifecycle.Holds(c)) - }) - } -} - -func TestUILeavingBackgroundEndsTaskAndSyncHolds(t *testing.T) { - appState, _ := newAppState(t) - appState.Update(background) - c := lifecycle.New(appState, lifecycle.Config{}) - require.Positive(t, c.UIBackground(stay)) - push := c.PushWindowBegin() - require.Positive(t, push) - live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) - synced := make(chan string, 1) - go func() { synced <- c.BackgroundSync() }() - require.Eventually(t, func() bool { return lifecycle.Holds(c) == 4 }, 5*time.Second, time.Millisecond) - c.UIInactive() - select { - case msg := <-synced: - require.Contains(t, msg, "bailing out early") - case <-time.After(5 * time.Second): - require.Fail(t, "BackgroundSync kept its window after the UI left the background") - } - require.Equal(t, 2, lifecycle.Holds(c)) - require.Zero(t, c.PushWindowEnd(push, stay)) - require.True(t, live.Release()) - require.Equal(t, 0, lifecycle.Holds(c)) - require.Equal(t, inactive, appState.State()) + require.Equal(t, 3, flushes) } func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { appState, _ := newAppState(t) appState.Update(background) c := lifecycle.New(appState, lifecycle.Config{}) - require.Positive(t, c.UIBackground(stay)) + defer c.Close() + require.Positive(t, c.UIBackground(true, noDeliveries())) push := c.PushWindowBegin() live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) notified := 0 @@ -129,63 +75,11 @@ func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) c.BackgroundTaskExpired(func() { notified++ }) require.Equal(t, 1, notified, "nothing was left to expire") - require.Zero(t, c.PushWindowEnd(push, noStay)) + require.Zero(t, c.PushWindowEnd(push, false, noDeliveries())) require.True(t, live.Release()) require.Equal(t, background, appState.State()) } -func TestWillTerminateEndsEveryHold(t *testing.T) { - appState, _ := newAppState(t) - c := lifecycle.New(appState, lifecycle.Config{}) - live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) - require.Zero(t, c.PushWindowBegin(), "no push window in the foreground") - c.UIInactive() - push := c.PushWindowBegin() - require.Positive(t, push) - c.WillTerminate(noop) - require.Equal(t, 0, lifecycle.Holds(c)) - require.True(t, live.Released()) - require.Equal(t, background, appState.State()) - require.Zero(t, c.PushWindowEnd(push, stay)) -} - -func TestPushWindowEndOutsideBackgroundSkipsStayRunning(t *testing.T) { - appState, _ := newAppState(t) - appState.Update(background) - c := lifecycle.New(appState, lifecycle.Config{}) - token := c.PushWindowBegin() - c.UIInactive() - called := false - require.Zero(t, c.PushWindowEnd(token, func() bool { called = true; return true })) - require.False(t, called) - require.Zero(t, c.PushWindowEnd(-1, stay)) - require.Equal(t, inactive, appState.State()) - require.Equal(t, 0, lifecycle.Holds(c)) -} - -// stayRunning reaches back into the controller (the live location tracker -// holds its own lock while acquiring a hold), so the controller must not hold -// its lock while calling it. -func TestStayRunningRunsOutsideTheLock(t *testing.T) { - appState, _ := newAppState(t) - c := lifecycle.New(appState, lifecycle.Config{}) - reenter := func() bool { - c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation).Release() - return true - } - done := make(chan struct{}) - go func() { - defer close(done) - c.UIBackground(reenter) - c.PushWindowEnd(c.PushWindowBegin(), reenter) - }() - select { - case <-done: - case <-time.After(5 * time.Second): - require.Fail(t, "stayRunning deadlocked on the controller's lock") - } -} - // 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) { @@ -198,7 +92,7 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, }, "backgroundTaskExpired": { - prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(stay)) }, + prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(true, noDeliveries())) }, do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) }, }, } @@ -207,6 +101,7 @@ func TestExitEventsApplyBeforeNotifying(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 @@ -220,17 +115,10 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { } } -func TestEventString(t *testing.T) { - require.Equal(t, "uiInactive", lifecycle.EventUIInactive.String()) - require.Equal(t, "release", lifecycle.EventRelease.String()) - require.Equal(t, "Event(99)", lifecycle.Event(99).String()) - require.Equal(t, "liveLocation", lifecycle.ReasonLiveLocation.String()) -} - // 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 must all exit. +// goroutines and the background tasks the controller runs must all exit. func TestHoldsStress(t *testing.T) { appState, _ := newAppState(t) appState.Update(background) @@ -239,6 +127,7 @@ func TestHoldsStress(t *testing.T) { BackgroundTaskPollInterval: time.Millisecond, BackgroundTaskMaxDuration: time.Minute, }) + defer c.Close() baseline := runtime.NumGoroutine() chaos := func(t *testing.T, iterations int) { @@ -265,9 +154,7 @@ func TestHoldsStress(t *testing.T) { if r.Intn(2) == 0 { time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) } - if task := c.PushWindowEnd(token, func() bool { return r.Intn(3) == 0 }); task > 0 { - c.RunBackgroundTask(context.Background(), task, noDeliveries()) - } + c.PushWindowEnd(token, r.Intn(3) == 0, noDeliveries()) }) runOwner(func(*rand.Rand) { c.BackgroundSync() }) runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) @@ -286,15 +173,9 @@ func TestHoldsStress(t *testing.T) { case 1: c.UIInactive() case 2: - if task := c.UIBackground(func() bool { return r.Intn(2) == 0 }); task > 0 { - owners.Add(1) - go func() { - defer owners.Done() - c.RunBackgroundTask(context.Background(), task, noDeliveries()) - }() - } + c.UIBackground(r.Intn(2) == 0, noDeliveries()) case 3: - c.UIBackground(noStay) + c.UIBackground(false, noDeliveries()) case 4: if r.Intn(10) == 0 { c.WillTerminate(noop) @@ -315,7 +196,7 @@ func TestHoldsStress(t *testing.T) { t.Run("ends in background", func(t *testing.T) { chaos(t, 300) - c.UIBackground(noStay) + c.UIBackground(false, noDeliveries()) require.Equal(t, background, appState.State()) require.Equal(t, 0, lifecycle.Holds(c)) }) @@ -323,7 +204,7 @@ func TestHoldsStress(t *testing.T) { t.Run("concurrent holds end in background", func(t *testing.T) { for range 50 { chaos(t, 20) - c.UIBackground(noStay) + c.UIBackground(false, noDeliveries()) var holders sync.WaitGroup for range 4 { holders.Add(1) diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index c154ba5a8d93..9a28db54cb0f 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -14,6 +14,7 @@ import ( "context" "errors" "fmt" + "maps" "slices" "sync" "time" @@ -24,94 +25,25 @@ import ( "golang.org/x/sync/errgroup" ) -type UIState int +type UIState string const ( - UIBackground UIState = iota - UIInactive - UIActive + UIBackground UIState = "background" + UIInactive UIState = "inactive" + UIActive UIState = "active" ) -func (s UIState) String() string { - switch s { - case UIBackground: - return "background" - case UIInactive: - return "inactive" - case UIActive: - return "active" - default: - return fmt.Sprintf("UIState(%d)", int(s)) - } -} - // Reason says what a hold keeps running, and so which events end it. -type Reason int +type Reason string const ( - ReasonLaunch Reason = iota + 1 - ReasonBackgroundTask - ReasonBackgroundSync - ReasonPushWindow - ReasonLiveLocation + ReasonLaunch Reason = "launch" + ReasonBackgroundTask Reason = "backgroundTask" + ReasonBackgroundSync Reason = "backgroundSync" + ReasonPushWindow Reason = "pushWindow" + ReasonLiveLocation Reason = "liveLocation" ) -var reasonNames = map[Reason]string{ - ReasonLaunch: "launch", - ReasonBackgroundTask: "backgroundTask", - ReasonBackgroundSync: "backgroundSync", - ReasonPushWindow: "pushWindow", - ReasonLiveLocation: "liveLocation", -} - -func (r Reason) String() string { - if name, ok := reasonNames[r]; ok { - return name - } - return fmt.Sprintf("Reason(%d)", int(r)) -} - -type Event int - -const ( - EventUIActive Event = iota - EventUIInactive - EventUIBackground - EventWillTerminate - EventBackgroundTaskExpired - EventBackgroundTaskBegin - EventBackgroundTaskEnd - EventPushWindowBegin - EventPushWindowEnd - EventBackgroundSyncBegin - EventBackgroundSyncEnd - EventAcquire - EventRelease -) - -var eventNames = map[Event]string{ - EventUIActive: "uiActive", - EventUIInactive: "uiInactive", - EventUIBackground: "uiBackground", - EventWillTerminate: "willTerminate", - EventBackgroundTaskExpired: "backgroundTaskExpired", - EventBackgroundTaskBegin: "backgroundTaskBegin", - EventBackgroundTaskEnd: "backgroundTaskEnd", - EventPushWindowBegin: "pushWindowBegin", - EventPushWindowEnd: "pushWindowEnd", - EventBackgroundSyncBegin: "backgroundSyncBegin", - EventBackgroundSyncEnd: "backgroundSyncEnd", - EventAcquire: "acquire", - EventRelease: "release", -} - -func (e Event) String() string { - if name, ok := eventNames[e]; ok { - return name - } - return fmt.Sprintf("Event(%d)", int(e)) -} - // AppState is the part of libkb.MobileAppState the controller drives. type AppState interface { State() keybase1.MobileAppState @@ -132,8 +64,8 @@ type Config struct { BackgroundSyncWindow time.Duration BackgroundTaskPollInterval time.Duration BackgroundTaskMaxDuration time.Duration - // Flush runs when the UI enters the background and when the state - // changes into BACKGROUND, where the OS may suspend or kill the process + // 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() @@ -152,14 +84,10 @@ type Hold struct { c *Controller id int64 reason Reason - done chan struct{} + // done is closed once the hold has ended, by Release or by the controller. + done chan struct{} } -func (h *Hold) ID() int64 { return h.id } - -// Done is closed once the hold has ended, by Release or by the controller. -func (h *Hold) Done() <-chan struct{} { return h.done } - func (h *Hold) Released() bool { select { case <-h.done: @@ -171,17 +99,23 @@ func (h *Hold) Released() bool { // 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.id) } +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 // mu serializes every UI report and hold change with the state it writes. mu sync.Mutex ui UIState nextID int64 holds map[int64]*Hold + // tasks maps each running background task's hold id to a channel closed + // when its goroutine returns. + tasks map[int64]chan struct{} } func New(appState AppState, cfg Config) *Controller { @@ -203,7 +137,8 @@ func New(appState AppState, cfg Config) *Controller { if cfg.Debug == nil { cfg.Debug = func(string, ...interface{}) {} } - c := &Controller{appState: appState, cfg: cfg, holds: make(map[int64]*Hold)} + c := &Controller{appState: appState, cfg: cfg, holds: make(map[int64]*Hold), tasks: make(map[int64]chan struct{})} + c.ctx, c.cancel = context.WithCancel(context.Background()) switch appState.State() { case keybase1.MobileAppState_FOREGROUND: c.ui = UIActive @@ -219,6 +154,18 @@ func New(appState AppState, cfg Config) *Controller { return c } +// Close ends the background tasks the controller runs and waits for them to +// return. Tasks started later end at once. +func (c *Controller) Close() { + c.cancel() + c.mu.Lock() + tasks := slices.Collect(maps.Values(c.tasks)) + c.mu.Unlock() + for _, done := range tasks { + <-done + } +} + func derive(ui UIState, holds int) keybase1.MobileAppState { switch { case ui == UIActive: @@ -232,19 +179,20 @@ func derive(ui UIState, holds int) keybase1.MobileAppState { } } -func (c *Controller) debugLocked(ev Event, format string, args ...interface{}) { - c.cfg.Debug("lifecycle: %v: %s (ui: %v, holds: %d, state: %v)", ev, fmt.Sprintf(format, args...), +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 UI just entered the background or the state just changed -// into BACKGROUND. -func (c *Controller) applyLocked(uiEnteredBackground bool) { +// 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)) - changed := c.appState.Update(state) - if uiEnteredBackground || (changed && state == keybase1.MobileAppState_BACKGROUND) { + if c.appState.Update(state) && prev != keybase1.MobileAppState_BACKGROUND && + (state == keybase1.MobileAppState_BACKGROUND || state == keybase1.MobileAppState_BACKGROUNDACTIVE) { c.cfg.Flush() } } @@ -256,19 +204,12 @@ func (c *Controller) acquireLocked(reason Reason) *Hold { return h } -func (c *Controller) dropLocked(id int64) bool { - h, ok := c.holds[id] - if !ok { - return false - } - delete(c.holds, id) - close(h.done) - return true -} - -func (c *Controller) dropReasonsLocked(reasons ...Reason) (dropped int) { +// 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 slices.Contains(reasons, h.reason) && c.dropLocked(id) { + if match(h) { + delete(c.holds, id) + close(h.done) dropped++ } } @@ -277,14 +218,28 @@ func (c *Controller) dropReasonsLocked(reasons ...Reason) (dropped int) { // setUILocked records a UI report. Any report ends the launch hold; leaving // the background ends the holds that only keep a backgrounded app alive. -func (c *Controller) setUILocked(ui UIState) (enteredBackground bool) { - c.dropReasonsLocked(ReasonLaunch) - prev := c.ui - c.ui = ui - if prev == UIBackground && ui != UIBackground { - c.dropReasonsLocked(ReasonBackgroundTask, ReasonBackgroundSync) +func (c *Controller) setUILocked(ui UIState) { + c.dropLocked(func(h *Hold) bool { return h.reason == ReasonLaunch }) + if c.ui == UIBackground && ui != UIBackground { + c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask || h.reason == ReasonBackgroundSync }) } - return prev != UIBackground && ui == UIBackground + c.ui = ui +} + +// startTaskLocked opens a background task hold and runs the task that keeps +// it until the work is done. +func (c *Controller) startTaskLocked(deps BackgroundTaskDeps) int64 { + h := c.acquireLocked(ReasonBackgroundTask) + done := make(chan struct{}) + c.tasks[h.id] = done + go func() { + c.runBackgroundTask(h, deps) + c.mu.Lock() + delete(c.tasks, h.id) + c.mu.Unlock() + close(done) + }() + return h.id } // AcquireBackgroundWork opens a hold that keeps a backgrounded app @@ -293,29 +248,28 @@ func (c *Controller) AcquireBackgroundWork(reason Reason) *Hold { c.mu.Lock() defer c.mu.Unlock() h := c.acquireLocked(reason) - c.applyLocked(false) - c.debugLocked(EventAcquire, "%v hold %d", reason, h.id) + c.applyLocked() + c.debugLocked("acquire", "%v hold %d", reason, h.id) return h } -func (c *Controller) release(id int64) bool { +func (c *Controller) release(h *Hold) bool { c.mu.Lock() defer c.mu.Unlock() - h, ok := c.holds[id] - if !ok { + if c.dropLocked(func(o *Hold) bool { return o == h }) == 0 { return false } - c.dropLocked(id) - c.applyLocked(false) - c.debugLocked(EventRelease, "%v hold %d", h.reason, id) + 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.applyLocked(c.setUILocked(UIActive)) - c.debugLocked(EventUIActive, "applied") + c.setUILocked(UIActive) + c.applyLocked() + c.debugLocked("uiActive", "applied") } // UIInactive covers the app on screen without receiving events (Control @@ -324,40 +278,46 @@ func (c *Controller) UIActive() { func (c *Controller) UIInactive() { c.mu.Lock() defer c.mu.Unlock() - c.applyLocked(c.setUILocked(UIInactive)) - c.debugLocked(EventUIInactive, "applied") + c.setUILocked(UIInactive) + c.applyLocked() + c.debugLocked("uiInactive", "applied") } -// UIBackground records the UI leaving the screen. When stayRunning says work -// must keep going it opens a background task hold and returns its token for -// RunBackgroundTask; otherwise it returns 0. -func (c *Controller) UIBackground(stayRunning func() bool) int64 { - // stayRunning takes other locks (the live location tracker's, which is held - // while calling into the controller), so it must run outside c.mu. - stay := stayRunning() +// UIBackground records the UI leaving the screen. When stay says work must +// keep going it starts a background task and returns its hold's token for +// WaitBackgroundTask; otherwise it returns 0. +func (c *Controller) UIBackground(stay bool, deps BackgroundTaskDeps) int64 { c.mu.Lock() defer c.mu.Unlock() - entered := c.setUILocked(UIBackground) + c.setUILocked(UIBackground) var token int64 if stay { - token = c.acquireLocked(ReasonBackgroundTask).id + token = c.startTaskLocked(deps) } - c.applyLocked(entered) - c.debugLocked(EventUIBackground, "background task hold %d", token) + c.applyLocked() + c.debugLocked("uiBackground", "background task hold %d", token) return token } +// WaitBackgroundTask returns once the background task at token has returned. +func (c *Controller) WaitBackgroundTask(token int64) { + c.mu.Lock() + done, ok := c.tasks[token] + c.mu.Unlock() + if ok { + <-done + } +} + // 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() - entered := c.setUILocked(UIBackground) - for id := range c.holds { - c.dropLocked(id) - } - c.applyLocked(entered) - c.debugLocked(EventWillTerminate, "ended every hold") + c.setUILocked(UIBackground) + c.dropLocked(func(*Hold) bool { return true }) + c.applyLocked() + c.debugLocked("willTerminate", "ended every hold") c.mu.Unlock() notifyPending() } @@ -368,9 +328,9 @@ func (c *Controller) WillTerminate(notifyPending func()) { // push window and sync holds keep their own lifetimes. func (c *Controller) BackgroundTaskExpired(notifyPending func()) { c.mu.Lock() - ended := c.dropReasonsLocked(ReasonBackgroundTask) - c.applyLocked(false) - c.debugLocked(EventBackgroundTaskExpired, "ended %d background task holds", ended) + 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() @@ -383,39 +343,30 @@ func (c *Controller) PushWindowBegin() int64 { c.mu.Lock() defer c.mu.Unlock() if c.ui == UIActive { - c.debugLocked(EventPushWindowBegin, "skipped in the foreground") + c.debugLocked("pushWindowBegin", "skipped in the foreground") return 0 } h := c.acquireLocked(ReasonPushWindow) - c.applyLocked(false) - c.debugLocked(EventPushWindowBegin, "hold %d", h.id) + 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 and work must keep going, it first opens a background task hold -// and returns its token. -func (c *Controller) PushWindowEnd(token int64, stayRunning func() bool) int64 { - if token <= 0 { - return 0 - } - c.mu.Lock() - h, ok := c.holds[token] - query := ok && h.reason == ReasonPushWindow && c.ui == UIBackground - c.mu.Unlock() - // Outside c.mu, as in UIBackground: stayRunning takes locks held while calling into the controller. - stay := query && stayRunning() +// background and stay says work must keep going, it first starts a background +// task and returns its hold's token. +func (c *Controller) PushWindowEnd(token int64, stay bool, deps BackgroundTaskDeps) int64 { c.mu.Lock() defer c.mu.Unlock() var task int64 - if stay && c.ui == UIBackground { - task = c.acquireLocked(ReasonBackgroundTask).id - } if h, ok := c.holds[token]; ok && h.reason == ReasonPushWindow { - c.dropLocked(token) + if stay && c.ui == UIBackground { + task = c.startTaskLocked(deps) + } + c.dropLocked(func(o *Hold) bool { return o == h }) } - c.applyLocked(false) - c.debugLocked(EventPushWindowEnd, "hold %d ended, background task hold %d", token, task) + c.applyLocked() + c.debugLocked("pushWindowEnd", "hold %d ended, background task hold %d", token, task) return task } @@ -425,50 +376,38 @@ 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(EventBackgroundSyncBegin, "%s", msg) + c.debugLocked("backgroundSyncBegin", "%s", msg) c.mu.Unlock() return msg } h := c.acquireLocked(ReasonBackgroundSync) - c.applyLocked(false) - c.debugLocked(EventBackgroundSyncBegin, "hold %d", h.id) + c.applyLocked() + c.debugLocked("backgroundSyncBegin", "hold %d", h.id) c.mu.Unlock() var msg string select { - case <-h.Done(): + 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.mu.Lock() - c.debugLocked(EventBackgroundSyncEnd, "%s", msg) - c.mu.Unlock() + c.cfg.Debug("lifecycle: backgroundSyncEnd: hold %d: %s", h.id, msg) return msg } -// RunBackgroundTask keeps the background task hold at token until outgoing -// messages are delivered, one fails, time runs out, the hold is ended (the UI -// left the background, expiration, termination) or ctx is done. -func (c *Controller) RunBackgroundTask(ctx context.Context, token int64, deps BackgroundTaskDeps) { - c.mu.Lock() - // Task holds exist only while the UI is in the background: leaving it ends them. - h, ok := c.holds[token] - if !ok || h.reason != ReasonBackgroundTask { - c.debugLocked(EventBackgroundTaskBegin, "hold %d not open, early out", token) - c.mu.Unlock() - return - } - c.debugLocked(EventBackgroundTaskBegin, "hold %d", token) - c.mu.Unlock() +// runBackgroundTask keeps the background task hold h 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) { 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(ctx) + g, ctx := errgroup.WithContext(c.ctx) g.Go(func() error { select { - case <-h.Done(): + case <-h.done: return errors.New("hold ended") case <-ctx.Done(): return ctx.Err() @@ -514,7 +453,5 @@ func (c *Controller) RunBackgroundTask(ctx context.Context, token int64, deps Ba }) err := g.Wait() released := h.Release() - c.mu.Lock() - c.debugLocked(EventBackgroundTaskEnd, "hold %d done because: %v, released: %v", token, err, released) - c.mu.Unlock() + c.cfg.Debug("lifecycle: backgroundTaskEnd: hold %d done because: %v, released: %v", h.id, err, released) } diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index b355c2c967ac..271ecbeafe06 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -48,7 +48,9 @@ const ( // Native lifecycle events, as native reports them: willEnterForeground and // willResignActive are UIInactive, didBecomeActive is UIActive, - // didEnterBackground is UIBackground. + // didEnterBackground is UIBackground. When DidEnterBackground or + // PushWindowEnd starts a background task, they wait until it is polling + // and return true. WillEnterForeground DidBecomeActive WillResignActive @@ -57,7 +59,7 @@ const ( BackgroundTaskExpired PushWindowBegin PushWindowEnd - LiveLocationClaim + LiveLocationAcquire LiveLocationRelease // BackgroundSyncStart starts the blocking BackgroundSync call and waits @@ -70,9 +72,6 @@ const ( // BackgroundSyncWait waits for a BackgroundSync that bails out on its own. BackgroundSyncWait - // BackgroundTaskStart starts the blocking RunBackgroundTask call and - // waits until it is polling (returns true) or has exited early (false). - BackgroundTaskStart // BackgroundTaskDelivered finishes pending deliveries and polls until // the task returns. BackgroundTaskDelivered @@ -101,12 +100,11 @@ var actionNames = map[Action]string{ BackgroundTaskExpired: "BackgroundTaskExpired", PushWindowBegin: "PushWindowBegin", PushWindowEnd: "PushWindowEnd", - LiveLocationClaim: "LiveLocationClaim", + LiveLocationAcquire: "LiveLocationAcquire", LiveLocationRelease: "LiveLocationRelease", BackgroundSyncStart: "BackgroundSyncStart", BackgroundSyncTimerFires: "BackgroundSyncTimerFires", BackgroundSyncWait: "BackgroundSyncWait", - BackgroundTaskStart: "BackgroundTaskStart", BackgroundTaskDelivered: "BackgroundTaskDelivered", BackgroundTaskFails: "BackgroundTaskFails", BackgroundTaskTimesUp: "BackgroundTaskTimesUp", @@ -162,19 +160,14 @@ type Harness struct { 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 - // taskToken is the background task hold the last UIBackground or - // PushWindowEnd opened, for BackgroundTaskStart. - taskToken int64 + flushes atomic.Int32 + warnings atomic.Int32 + stay atomic.Bool + pending atomic.Int32 + failures chan []chat1.OutboxRecord + tokens map[int]int64 liveLocation *lifecycle.Hold - cancel context.CancelFunc - ctx context.Context syncDone chan struct{} taskDone chan struct{} running sync.WaitGroup @@ -199,7 +192,6 @@ func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *H syncDone: closedChan(), taskDone: closedChan(), } - h.ctx, h.cancel = context.WithCancel(context.Background()) h.Controller = lifecycle.New(appState, lifecycle.Config{ Clock: h.Clock, BackgroundSyncWindow: syncWindow, @@ -220,7 +212,7 @@ func closedChan() chan struct{} { // Close ends any background task or sync still running, and the recorder. func (h *Harness) Close() { - h.cancel() + h.Controller.Close() h.Clock.Advance(maxDuration) h.running.Wait() h.Recorder.Stop() @@ -231,8 +223,6 @@ func (h *Harness) Warnings() int { return int(h.warnings.Load()) } func (h *Harness) warn() { h.warnings.Add(1) } -func (h *Harness) stayRunning() bool { return h.stay.Load() } - func (h *Harness) deps() lifecycle.BackgroundTaskDeps { return lifecycle.BackgroundTaskDeps{ ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { @@ -244,7 +234,6 @@ func (h *Harness) deps() lifecycle.BackgroundTaskDeps { } func (h *Harness) goRun(f func()) chan struct{} { - h.Clock.ForgetAfters() done := make(chan struct{}) h.running.Add(1) go func() { @@ -303,8 +292,7 @@ func (h *Harness) perform(step Step) bool { case DidBecomeActive: c.UIActive() case DidEnterBackground: - h.taskToken = c.UIBackground(h.stayRunning) - return h.taskToken > 0 + return h.startsTask(func() int64 { return c.UIBackground(h.stay.Load(), h.deps()) }) case WillTerminate: c.WillTerminate(h.warn) case BackgroundTaskExpired: @@ -313,21 +301,13 @@ func (h *Harness) perform(step Step) bool { h.tokens[step.Slot] = c.PushWindowBegin() return h.tokens[step.Slot] > 0 case PushWindowEnd: - if task := c.PushWindowEnd(h.tokens[step.Slot], h.stayRunning); task > 0 { - h.taskToken = task - return true - } - return false - case LiveLocationClaim: - if h.liveLocation == nil || h.liveLocation.Released() { - h.liveLocation = c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) - } + return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], h.stay.Load(), h.deps()) }) + case LiveLocationAcquire: + h.liveLocation = c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) case LiveLocationRelease: - if h.liveLocation != nil { - h.liveLocation.Release() - h.liveLocation = nil - } + 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: @@ -335,10 +315,6 @@ func (h *Harness) perform(step Step) bool { h.wait(h.syncDone, "BackgroundSync") case BackgroundSyncWait: h.wait(h.syncDone, "BackgroundSync") - case BackgroundTaskStart: - token := h.taskToken - h.taskDone = h.goRun(func() { c.RunBackgroundTask(h.ctx, token, h.deps()) }) - return h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone) case BackgroundTaskDelivered: h.pending.Store(0) for { @@ -347,15 +323,15 @@ func (h *Harness) perform(step Step) bool { break } } - h.wait(h.taskDone, "RunBackgroundTask") + h.wait(h.taskDone, "background task") case BackgroundTaskFails: h.failures <- make([]chat1.OutboxRecord, 1) - h.wait(h.taskDone, "RunBackgroundTask") + h.wait(h.taskDone, "background task") case BackgroundTaskTimesUp: h.Clock.Advance(maxDuration) - h.wait(h.taskDone, "RunBackgroundTask") + h.wait(h.taskDone, "background task") case BackgroundTaskWait: - h.wait(h.taskDone, "RunBackgroundTask") + h.wait(h.taskDone, "background task") case WorkStarts: h.stay.Store(true) h.pending.Store(1) @@ -368,6 +344,18 @@ func (h *Harness) perform(step Step) bool { return false } +// startsTask runs a call that may start a background task and, if it did, +// waits until the task is polling. It reports whether the task is running. +func (h *Harness) startsTask(call func() int64) bool { + h.Clock.ForgetAfters() + token := call() + if token == 0 { + return false + } + h.taskDone = h.goRun(func() { h.Controller.WaitBackgroundTask(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. diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 1ca611bc1f65..38d01f25f1de 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -48,39 +48,29 @@ func steps(parts ...[]Step) []Step { func states(s ...keybase1.MobileAppState) []keybase1.MobileAppState { return s } -// iosLaunch brings a freshly started iOS service (BACKGROUND) to the -// foreground: the scene connects and becomes active. -var iosLaunch = []Step{ +// 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, and starts the background task. +// sending, which starts the background task. var iosToBackgroundTask = []Step{ step(WorkStarts, fg), step(WillResignActive, ina), step(DidEnterBackground, bga).flush().returns(true), - step(BackgroundTaskStart, bga).returns(true), } -// androidStart is the process lifecycle's start and resume. -var androidStart = []Step{ - step(WillEnterForeground, ina), - step(DidBecomeActive, fg), -} - -// androidLaunch starts the UI in a fresh process (BACKGROUNDACTIVE until the first report). -var androidLaunch = androidStart - // 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: iosLaunch, Observed: states(bg, ina, fg)}, + {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, { Name: "ios background launch by silent push stays in the background, then foreground", Platform: IOS, - Steps: steps([]Step{step(Nothing, bg), step(BackgroundTaskExpired, bg)}, iosLaunch), + Steps: steps([]Step{step(Nothing, bg), step(BackgroundTaskExpired, bg)}, toForeground), Observed: states(bg, ina, fg), }, { @@ -89,13 +79,13 @@ var Scenarios = []Scenario{ Steps: steps([]Step{ step(BackgroundSyncStart, bga).returns(true), step(BackgroundSyncTimerFires, bg).flush(), - }, iosLaunch), + }, toForeground), Observed: states(bg, bga, bg, ina, fg), }, { Name: "ios home and return", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(DidEnterBackground, bg).flush().returns(false), step(WillEnterForeground, ina), @@ -106,7 +96,7 @@ var Scenarios = []Scenario{ { Name: "ios quick background and foreground cycles with duplicate events", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(WillResignActive, ina), step(DidEnterBackground, bg).flush().returns(false), @@ -128,7 +118,7 @@ var Scenarios = []Scenario{ { Name: "ios control center or system alert keeps things up", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(DidBecomeActive, fg), step(WillResignActive, ina), step(DidBecomeActive, fg), }), @@ -137,7 +127,7 @@ var Scenarios = []Scenario{ { Name: "ipad focus loss keeps things up", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(WillResignActive, ina), step(DidBecomeActive, fg), step(WillResignActive, ina), step(DidBecomeActive, fg), step(DidBecomeActive, fg), }), @@ -146,7 +136,7 @@ var Scenarios = []Scenario{ { Name: "ios lock and unlock", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(DidEnterBackground, bg).flush().returns(false), step(WillEnterForeground, ina), @@ -180,7 +170,7 @@ var Scenarios = []Scenario{ { Name: "ios BackgroundSync skips outside the background", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(BackgroundSyncStart, fg).returns(false), step(WillResignActive, ina), step(BackgroundSyncStart, ina).returns(false), @@ -190,28 +180,28 @@ var Scenarios = []Scenario{ { Name: "ios background task completes", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(BackgroundTaskDelivered, bg).flush(), step(BackgroundTaskExpired, bg), - }, iosLaunch), + }, toForeground), Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), }, { Name: "ios background task fails", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task runs out of time", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}), + Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}), Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background task expires", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(BackgroundTaskExpired, bg).flush().warn(), step(BackgroundTaskWait, bg), step(BackgroundTaskExpired, bg), @@ -221,7 +211,7 @@ var Scenarios = []Scenario{ { Name: "ios background task expires after return to foreground", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), @@ -232,7 +222,7 @@ var Scenarios = []Scenario{ { Name: "ios background task expires between willEnterForeground and didBecomeActive", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(WillEnterForeground, ina), step(BackgroundTaskExpired, ina), step(BackgroundTaskDelivered, ina), @@ -244,44 +234,28 @@ var Scenarios = []Scenario{ // Leaving the background ended the task's hold; finishing later changes nothing. Name: "ios background task finishes after willEnterForeground", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + 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 background task superseded before it starts", - Platform: IOS, - Steps: steps(iosLaunch, []Step{ - step(WorkStarts, fg), - step(WillResignActive, ina), - step(DidEnterBackground, bga).flush().returns(true), - step(WillEnterForeground, ina), - // Returning false means it exited without polling deliveries. - step(BackgroundTaskStart, ina).returns(false), - step(DidBecomeActive, fg), - }), - Observed: states(bg, ina, fg, ina, bga, ina, fg), - }, { Name: "ios live location across background", Platform: IOS, - Steps: steps(iosLaunch, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(BackgroundTaskDelivered, bg).flush(), // A location update wakes the app while tracking. - step(LiveLocationClaim, bga), - step(LiveLocationClaim, bga), + step(LiveLocationAcquire, bga), // Tracking ends. step(LiveLocationRelease, bg).flush(), - step(LiveLocationRelease, bg), - step(LiveLocationClaim, bga), + step(LiveLocationAcquire, bga), step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(LiveLocationRelease, fg), - // A claim in the foreground keeps the app running once it backgrounds. - step(LiveLocationClaim, 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), @@ -292,7 +266,7 @@ var Scenarios = []Scenario{ { Name: "ios background task expiration keeps live location running", Platform: IOS, - Steps: steps(iosLaunch, []Step{step(LiveLocationClaim, fg)}, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{ step(BackgroundTaskExpired, bga).warn(), step(BackgroundTaskWait, bga), step(LiveLocationRelease, bg).flush(), @@ -302,7 +276,7 @@ var Scenarios = []Scenario{ { Name: "ios termination from the background", Platform: IOS, - Steps: steps(iosLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(DidEnterBackground, bg).flush().returns(false), step(WillTerminate, bg).warn(), @@ -312,13 +286,13 @@ var Scenarios = []Scenario{ { Name: "ios termination from the foreground", Platform: IOS, - Steps: steps(iosLaunch, []Step{step(WillTerminate, bg).flush().warn()}), + 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(iosLaunch, iosToBackgroundTask, []Step{ + Steps: steps(toForeground, iosToBackgroundTask, []Step{ step(WillTerminate, bg).flush().warn(), step(BackgroundTaskWait, bg), step(BackgroundTaskExpired, bg), @@ -328,8 +302,8 @@ var Scenarios = []Scenario{ { Name: "ios termination ends live location's hold", Platform: IOS, - Steps: steps(iosLaunch, []Step{ - step(LiveLocationClaim, fg), + Steps: steps(toForeground, []Step{ + step(LiveLocationAcquire, fg), step(WillResignActive, ina), step(DidEnterBackground, bga).flush().returns(false), step(WillTerminate, bg).flush().warn(), @@ -337,16 +311,22 @@ var Scenarios = []Scenario{ }), Observed: states(bg, ina, fg, ina, bga, bg), }, - {Name: "android cold launch", Platform: Android, Steps: androidLaunch, Observed: states(bga, ina, fg)}, + {Name: "android cold launch", Platform: Android, Steps: toForeground, Observed: states(bga, ina, fg)}, + { + // Any first UI report ends the launch hold. + Name: "android cold launch straight to active", + Platform: Android, + Steps: []Step{step(DidBecomeActive, fg)}, + Observed: states(bga, fg), + }, { Name: "android process stop and start", Platform: Android, - Steps: steps(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().returns(false), - }, androidStart, []Step{ + }, toForeground, []Step{ step(WorkStarts, fg), step(DidEnterBackground, bga).flush().returns(true), - step(BackgroundTaskStart, bga).returns(true), step(WillEnterForeground, ina), step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), @@ -356,7 +336,7 @@ var Scenarios = []Scenario{ { Name: "android dialog, permission prompt or picker pause keeps the foreground", Platform: Android, - Steps: steps(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(Nothing, fg), step(PushWindowBegin, fg).returns(false), step(PushWindowEnd, fg).returns(false), @@ -365,20 +345,10 @@ var Scenarios = []Scenario{ }), Observed: states(bga, ina, fg), }, - { - Name: "android background task without a window", - Platform: Android, - Steps: []Step{ - step(WorkStarts, bga), - // Cold start holds the app up, but no background task hold was opened. - step(BackgroundTaskStart, bga).returns(false), - }, - Observed: states(bga), - }, { Name: "android push window in the background", Platform: Android, - Steps: steps(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), @@ -400,17 +370,20 @@ var Scenarios = []Scenario{ // 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(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().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).flush().returns(false), step(PushWindowBegin, bga).returns(true), - }, androidStart, []Step{ + }, toForeground, []Step{ step(DidEnterBackground, bga).flush().returns(false), step(PushWindowEnd, bg).flush().returns(false), }), @@ -419,12 +392,11 @@ var Scenarios = []Scenario{ { Name: "android push window hands over to a background task", Platform: Android, - Steps: steps(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(WorkStarts, bga), step(PushWindowEnd, bga).returns(true), - step(BackgroundTaskStart, bga).returns(true), step(BackgroundTaskDelivered, bg).flush(), }), Observed: states(bga, ina, fg, bg, bga, bg), @@ -432,7 +404,7 @@ var Scenarios = []Scenario{ { Name: "android overlapping push windows", Platform: Android, - Steps: steps(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), @@ -474,7 +446,7 @@ var Scenarios = []Scenario{ // The sync keeps its hold after the push window ends. Name: "android WorkManager BackgroundSync racing a push window", Platform: Android, - Steps: steps(androidLaunch, []Step{ + Steps: steps(toForeground, []Step{ step(DidEnterBackground, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), step(PushWindowBegin, bga).returns(true), @@ -484,9 +456,18 @@ var Scenarios = []Scenario{ Observed: states(bga, ina, fg, 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(androidLaunch, []Step{step(WillTerminate, bg).flush().warn()}), - Observed: states(bga, ina, fg, bg), + 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(bga, ina, fg, bg, bga, bg), }, } diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go index f57be385eb24..50e73e90b0b4 100644 --- a/go/libkb/lifecycle/scenario_test.go +++ b/go/libkb/lifecycle/scenario_test.go @@ -77,7 +77,6 @@ func TestHarnessCloseEndsRunningWork(t *testing.T) { "background task": { {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_BACKGROUND}, {Do: lifecycletest.DidEnterBackground, Want: bga, Returns: lifecycletest.ReturnTrue}, - {Do: lifecycletest.BackgroundTaskStart, Want: bga, Returns: lifecycletest.ReturnTrue}, }, } for name, steps := range cases { 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 index 797663082917..6960a319ebff 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -12,11 +12,10 @@ import java.util.concurrent.TimeUnit internal interface LifecycleBind { fun uiActive() fun uiInactive() - fun uiBackground(): Long + fun uiBackground() fun willExit() fun pushWindowBegin(): Long - fun pushWindowEnd(token: Long): Long - fun beginBackgroundTask(token: Long) + fun pushWindowEnd(token: Long) } internal interface LifecycleExecutor { @@ -94,12 +93,7 @@ internal class AppLifecycleReporter( private fun reportBackground(why: String) { reported = true - enqueue("uiBackground: $why") { - val token = bind.uiBackground() - if (token > 0) { - bind.beginBackgroundTask(token) - } - } + enqueue("uiBackground: $why") { bind.uiBackground() } } // Callers hold the lock, so tasks are queued in the order events happen. @@ -135,10 +129,7 @@ internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, inForegro } finally { // Negative: Go isn't initialized, so no window opened. if (token > 0) { - val task = bind.pushWindowEnd(token) - if (task > 0) { - bind.beginBackgroundTask(task) - } + bind.pushWindowEnd(token) } } return true 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 index 95f70da58a70..cf60f64fda1e 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt @@ -9,14 +9,13 @@ internal class KeybaseLifecycleBind(private val context: Context) : LifecycleBin override fun uiInactive() = Keybase.appUIInactive() - override fun uiBackground(): Long = Keybase.appUIBackground() + override fun uiBackground() { + Keybase.appUIBackground(KBPushNotifier(context, Bundle())) + } override fun willExit() = Keybase.appWillExit(KBPushNotifier(context, Bundle())) override fun pushWindowBegin(): Long = Keybase.appPushWindowBegin() - override fun pushWindowEnd(token: Long): Long = Keybase.appPushWindowEnd(token) - - override fun beginBackgroundTask(token: Long) = - Keybase.appBeginBackgroundTaskNonblock(token, KBPushNotifier(context, Bundle())) + override fun pushWindowEnd(token: Long) = Keybase.appPushWindowEnd(token, KBPushNotifier(context, Bundle())) } 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 index fd29519b1c76..c49dcce94adc 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -17,9 +17,7 @@ import org.junit.Test private class FakeBind : LifecycleBind { val calls: MutableList = Collections.synchronizedList(mutableListOf()) - var backgroundToken = 0L var token = 7L - var endTaskToken = 0L var onUiBackground: () -> Unit = {} override fun uiActive() { @@ -30,10 +28,9 @@ private class FakeBind : LifecycleBind { calls.add("uiInactive") } - override fun uiBackground(): Long { + override fun uiBackground() { onUiBackground() calls.add("uiBackground") - return backgroundToken } override fun willExit() { @@ -45,13 +42,8 @@ private class FakeBind : LifecycleBind { return token } - override fun pushWindowEnd(token: Long): Long { + override fun pushWindowEnd(token: Long) { calls.add("pushWindowEnd($token)") - return endTaskToken - } - - override fun beginBackgroundTask(token: Long) { - calls.add("beginBackgroundTask($token)") } } @@ -115,17 +107,6 @@ class AppLifecycleReporterTest { ) } - @Test - fun processStopWithWorkStartsTheBackgroundTask() { - launch() - bind.backgroundToken = 9L - stop() - assertEquals( - listOf("uiInactive", "uiActive", "uiBackground", "beginBackgroundTask(9)"), - calls(), - ) - } - @Test fun dialogOrPermissionPromptPauseNeverBackgrounds() { launch() @@ -218,11 +199,11 @@ class AppLifecycleReporterTest { bind.uiActive() } - override fun uiBackground(): Long { + override fun uiBackground() { threads.add(Thread.currentThread()) // Slow, like the outbox query, so later events queue behind it. Thread.sleep(5) - return bind.uiBackground() + bind.uiBackground() } } val ordered = AppLifecycleReporter(record, SingleThreadLifecycleExecutor()) {} @@ -273,13 +254,6 @@ class RunPushWindowTest { assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)"), bind.calls) } - @Test - fun windowHandedOverStartsTheBackgroundTask() { - bind.endTaskToken = 11L - run() - assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)", "beginBackgroundTask(11)"), bind.calls) - } - @Test fun windowEndsWhenTheTaskThrows() { try { diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index dd905477a3b7..cd526cd4081f 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -21,7 +21,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi var resignImageView: UIImageView? var fsPaths: [String: String] = [:] - private let lifecycle = AppLifecycleForwarder(events: KeybaseLifecycleEvents()) + private let lifecycle = AppLifecycleForwarder() private var locationWatcher: LocationWatcher? private var lastNotificationResponseKey: String? var iph: ItemProviderHelper? @@ -394,7 +394,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } completion: { finished in log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") } - lifecycle.willResignActive() + lifecycle.uiInactive() } override func applicationDidEnterBackground(_ application: UIApplication) { @@ -418,7 +418,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationWillEnterForeground: hiding keyz screen.") PerfFPSMonitor.appWillEnterForeground() hideCover() - lifecycle.willEnterForeground() + lifecycle.uiInactive() } func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { @@ -427,53 +427,28 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } -// The Go lifecycle entry points, one bind call each. Native reports only UI -// state and background task tokens; Go derives the app state -// (go/libkb/lifecycle). Nothing here may derive state, and -// UIApplication.applicationState lags inside the scene-forwarded callbacks -// anyway. -protocol AppLifecycleEvents { - func uiActive() - func uiInactive() - // A background task token when Go wants to keep running, 0 otherwise; runBackgroundTask then does that work. - func uiBackground() -> Int64 - func runBackgroundTask(_ token: Int64) - func backgroundTaskExpired() - func willTerminate() -} - -struct KeybaseLifecycleEvents: AppLifecycleEvents { - func uiActive() { Keybasego.KeybaseAppUIActive() } - func uiInactive() { Keybasego.KeybaseAppUIInactive() } - func uiBackground() -> Int64 { Keybasego.KeybaseAppUIBackground() } - func runBackgroundTask(_ token: Int64) { Keybasego.KeybaseAppBeginBackgroundTask(token, PushNotifier()) } - func backgroundTaskExpired() { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } - func willTerminate() { Keybasego.KeybaseAppWillExit(PushNotifier()) } -} - // Hands lifecycle events to Go on one serial queue, so Go sees them in callback // order without the main thread waiting on Go (didEnterBackground queries the // chat outbox). Also owns the UIKit background task that keeps the app alive // while Go decides and 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 - private let events: AppLifecycleEvents private let queue = DispatchQueue(label: "com.keybase.app.lifecycle", qos: .userInitiated) private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - init(events: AppLifecycleEvents) { - self.events = events - } - - func willEnterForeground() { queue.async { self.events.uiInactive() } } - func didBecomeActive() { queue.async { self.events.uiActive() } } - func willResignActive() { queue.async { self.events.uiInactive() } } + // willEnterForeground and willResignActive. + func uiInactive() { queue.async { Keybasego.KeybaseAppUIInactive() } } + func didBecomeActive() { queue.async { Keybasego.KeybaseAppUIActive() } } func willTerminate() { - runBounded { $0.willTerminate() } + runBounded { Keybasego.KeybaseAppWillExit(PushNotifier()) } } // Every background entry starts its own task before asking Go, so the app @@ -492,13 +467,14 @@ final class AppLifecycleForwarder { application.endBackgroundTask(previous) } queue.async { - let token = self.events.uiBackground() + // A token when Go started a background task, 0 otherwise. + let token = Keybasego.KeybaseAppUIBackground(PushNotifier()) guard token > 0 else { DispatchQueue.main.async { self.endBackgroundTask(task) } return } DispatchQueue.global(qos: .default).async { - self.events.runBackgroundTask(token) + Keybasego.KeybaseAppWaitBackgroundTask(token) DispatchQueue.main.async { self.endBackgroundTask(task) } } } @@ -507,7 +483,7 @@ final class AppLifecycleForwarder { private func backgroundTaskExpired(_ task: UIBackgroundTaskIdentifier) { guard task != .invalid, task == backgroundTask else { return } log.info("background task expired") - runBounded { $0.backgroundTaskExpired() } + runBounded { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } endBackgroundTask(task) } @@ -519,10 +495,10 @@ final class AppLifecycleForwarder { // Queued behind earlier events to keep the order; the wait only bounds how // long the app stays alive for it. - private func runBounded(_ work: @escaping (AppLifecycleEvents) -> Void) { + private func runBounded(_ work: @escaping () -> Void) { let done = DispatchSemaphore(value: 0) queue.async { - work(self.events) + work() done.signal() } _ = done.wait(timeout: .now() + Self.exitWorkTimeout) From 8eda1525a0e7393422690ab8a833b46b60a6e659 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 13:44:34 -0400 Subject: [PATCH 048/127] refactor(service): drop the UpdateAppState handler Its RPC left the protocol long ago, so the method was unreachable and the only non-controller writer of MobileAppState in the service. --- go/service/appstate.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/go/service/appstate.go b/go/service/appstate.go index c6e0e44b2b94..97d05a86a66c 100644 --- a/go/service/appstate.go +++ b/go/service/appstate.go @@ -5,7 +5,6 @@ package service import ( "context" - "fmt" "strings" "github.com/keybase/client/go/libkb" @@ -25,14 +24,6 @@ func newAppStateHandler(xp rpc.Transporter, g *libkb.GlobalContext) *appStateHan } } -func (a *appStateHandler) UpdateAppState(ctx context.Context, state keybase1.MobileAppState) (err error) { - a.G().Trace(fmt.Sprintf("UpdateAppState(%v)", state), &err)() - - // Update app state - a.G().MobileAppState.Update(state) - return nil -} - func (a *appStateHandler) UpdateMobileNetState(ctx context.Context, stateStr string) (err error) { a.G().Log.CDebugf(ctx, "UpdateMobileNetState(%v)", stateStr) From 014db211428d9531272b77f65eb56aef0d15e9f8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 13:44:34 -0400 Subject: [PATCH 049/127] test(appstate): table the keygen transition and drop the CAS-era stress test keygenOnTransition is a pure function, so a table covers every transition; the MobileAppState stress test only exercised generations and CAS, which are gone. --- go/ephemeral/keygen_loop_test.go | 112 +++++++++++++------------------ go/libkb/appstate_test.go | 69 ------------------- 2 files changed, 45 insertions(+), 136 deletions(-) diff --git a/go/ephemeral/keygen_loop_test.go b/go/ephemeral/keygen_loop_test.go index df9f500017ac..a457f4d354ca 100644 --- a/go/ephemeral/keygen_loop_test.go +++ b/go/ephemeral/keygen_loop_test.go @@ -10,10 +10,14 @@ import ( "github.com/stretchr/testify/require" ) -// startKeygenLoop runs keygenLoop without ticks or jitter. next waits until the -// loop is about to wait in the given state; stop ends the loop. -func startKeygenLoop(t *testing.T, mctx libkb.MetaContext) (runs *atomic.Int32, next func(keybase1.MobileAppState), stop func()) { - runs = new(atomic.Int32) +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{}) @@ -25,7 +29,7 @@ func startKeygenLoop(t *testing.T, mctx libkb.MetaContext) (runs *atomic.Int32, func(state keybase1.MobileAppState) { waiting <- state }) }() - next = func(want keybase1.MobileAppState) { + next := func(want keybase1.MobileAppState) { t.Helper() select { case got := <-waiting: @@ -34,27 +38,6 @@ func startKeygenLoop(t *testing.T, mctx libkb.MetaContext) (runs *atomic.Int32, t.Fatal("keygen loop did not wait") } } - stop = func() { - t.Helper() - close(stopCh) - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatal("keygen loop did not stop") - } - } - return runs, next, stop -} - -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) - - runs, next, stop := startKeygenLoop(t, mctx) - defer stop() // A background-active launch is not a transition into BACKGROUNDACTIVE. next(keybase1.MobileAppState_BACKGROUNDACTIVE) @@ -67,49 +50,44 @@ func TestKeygenLoopSeedsFromState(t *testing.T) { 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 TestKeygenLoopRunsWhenLeavingTheBackground(t *testing.T) { - tc := libkb.SetupTest(t, "ephemeral", 2) - defer tc.Cleanup() - mctx := libkb.NewMetaContextForTest(tc) - appState := tc.G.MobileAppState - appState.Update(keybase1.MobileAppState_BACKGROUND) - - runs, next, stop := startKeygenLoop(t, mctx) - defer stop() - - next(keybase1.MobileAppState_BACKGROUND) - require.Zero(t, runs.Load()) - appState.Update(keybase1.MobileAppState_INACTIVE) - next(keybase1.MobileAppState_INACTIVE) - require.EqualValues(t, 1, runs.Load()) - appState.Update(keybase1.MobileAppState_FOREGROUND) - next(keybase1.MobileAppState_FOREGROUND) - require.EqualValues(t, 1, runs.Load()) - // INACTIVE from the foreground. - appState.Update(keybase1.MobileAppState_INACTIVE) - next(keybase1.MobileAppState_INACTIVE) - require.EqualValues(t, 1, runs.Load()) - appState.Update(keybase1.MobileAppState_BACKGROUND) - next(keybase1.MobileAppState_BACKGROUND) - require.EqualValues(t, 1, runs.Load()) - appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - next(keybase1.MobileAppState_BACKGROUNDACTIVE) - require.EqualValues(t, 2, runs.Load()) - // INACTIVE from BACKGROUNDACTIVE. - appState.Update(keybase1.MobileAppState_INACTIVE) - next(keybase1.MobileAppState_INACTIVE) - require.EqualValues(t, 2, runs.Load()) - appState.Update(keybase1.MobileAppState_BACKGROUND) - next(keybase1.MobileAppState_BACKGROUND) - require.EqualValues(t, 2, runs.Load()) - // NextUpdate collapses willEnterForeground's INACTIVE and didBecomeActive's - // FOREGROUND when they land together; the loop then sees BACKGROUND to - // FOREGROUND. - appState.Update(keybase1.MobileAppState_FOREGROUND) - next(keybase1.MobileAppState_FOREGROUND) - require.EqualValues(t, 3, runs.Load()) +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/libkb/appstate_test.go b/go/libkb/appstate_test.go index 73834f35fe8b..7217b878abd8 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -5,9 +5,7 @@ package libkb import ( "context" - "sync" "testing" - "time" "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" @@ -78,70 +76,3 @@ func TestMobileAppStateBackgroundCancelsRPCsOnlyOnChange(t *testing.T) { require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) requireOpen(t, second.Done()) } - -func TestMobileAppStateStress(t *testing.T) { - tc := SetupTest(t, "MobileAppStateStress", 0) - defer tc.Cleanup() - a := NewMobileAppState(tc.G) - - // Writers never set BACKGROUNDACTIVE; it marks the end for waiters. - states := []keybase1.MobileAppState{ - keybase1.MobileAppState_FOREGROUND, - keybase1.MobileAppState_BACKGROUND, - keybase1.MobileAppState_INACTIVE, - } - const ( - writers = 8 - waiters = 8 - iterations = 300 - ) - - var ( - waitersWG sync.WaitGroup - writersWG sync.WaitGroup - ) - - for i := 0; i < waiters; i++ { - waitersWG.Add(1) - go func() { - defer waitersWG.Done() - for { - s := a.State() - if s == keybase1.MobileAppState_BACKGROUNDACTIVE { - return - } - <-a.NextUpdate(s) - } - }() - } - - for i := 0; i < writers; i++ { - writersWG.Add(1) - go func(i int) { - defer writersWG.Done() - for j := 0; j < iterations; j++ { - a.Update(states[(i+j)%len(states)]) - } - }(i) - } - - requireDoneWithin(t, &writersWG, 30*time.Second, "writers deadlocked") - - require.True(t, a.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)) - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, a.State()) - requireDoneWithin(t, &waitersWG, 30*time.Second, "a NextUpdate waiter missed the final change") -} - -func requireDoneWithin(t *testing.T, wg *sync.WaitGroup, timeout time.Duration, msg string) { - t.Helper() - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() - select { - case <-done: - case <-time.After(timeout): - require.Fail(t, msg) - } -} From 6e3418e2b5387024f19fd9c0e2a2d33dc50b38ad Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 13:59:58 -0400 Subject: [PATCH 050/127] fix(lifecycle): close the controller on shutdown and read the retry count while the watch is parked The failing-watch test read the attempt count after Advance released the retry goroutine, so the count it compared against could already include the attempt it was waiting for. GlobalContext.Shutdown now ends the controller's background tasks, and the harness says which step is missing its acquire. --- go/chat/maps/livelocation_watch_test.go | 4 +++- go/libkb/globals.go | 6 ++++++ go/libkb/lifecycle/lifecycle.go | 2 +- go/libkb/lifecycle/lifecycletest/harness.go | 2 ++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go index 9ad83b9827f4..483c8efef446 100644 --- a/go/chat/maps/livelocation_watch_test.go +++ b/go/chat/maps/livelocation_watch_test.go @@ -285,9 +285,11 @@ func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { 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) - clock.Advance(time.Second) 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) diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 8348e7df879b..48b622bbd4cc 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -842,6 +842,12 @@ func (g *GlobalContext) Shutdown(mctx MetaContext) error { g.hiddenTeamChainManager.Shutdown(mctx) } + // Ends the background tasks the controller runs before the chat + // services they poll go away. + if g.MobileLifecycle != nil { + g.MobileLifecycle.Close() + } + if g.NotifyRouter != nil { g.NotifyRouter.Shutdown() } diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 9a28db54cb0f..01e5b3f03ec7 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -354,7 +354,7 @@ func (c *Controller) PushWindowBegin() int64 { // PushWindowEnd ends the push window's hold. If the UI is still in the // background and stay says work must keep going, it first starts a background -// task and returns its hold's token. +// task. The token it returns is for the test harness; native ignores it. func (c *Controller) PushWindowEnd(token int64, stay bool, deps BackgroundTaskDeps) int64 { c.mu.Lock() defer c.mu.Unlock() diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 271ecbeafe06..b1d9192deeb1 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -14,6 +14,7 @@ import ( "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 @@ -305,6 +306,7 @@ func (h *Harness) perform(step Step) bool { case LiveLocationAcquire: h.liveLocation = c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) case LiveLocationRelease: + require.NotNil(h.T, h.liveLocation, "LiveLocationRelease without LiveLocationAcquire") h.liveLocation.Release() case BackgroundSyncStart: h.Clock.ForgetAfters() From 204103544f15c5c7e182dbbc83c54e1b9795b59f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 14:19:33 -0400 Subject: [PATCH 051/127] fix(gregor): serialize the connect tail with shutdown on one mutex OnConnect's irreversible steps ran under connTailMu while every connect, reset, reconnect and reconcile ran under the gate's mu, so two mutexes had to agree for a badge push never to land after a logout. Run those steps on the gate instead: gregorConnGate.do takes mu, and onGateIfCurrent checks the current connection and runs the step inside it. Shutdown and Reset are only ever reached under the gate, so a disconnect now lands entirely before a step or entirely after it, under one lock. Move the test-only beforeConnect hook out of production: the gate takes a gregorAppState, and the test wraps the real one to act between a connect's state read and what it does with it. --- go/service/gregor.go | 67 +++++++-------- go/service/gregor_conn.go | 35 +++++--- go/service/gregor_conn_test.go | 151 ++++++++++++++++++++++++++++++--- 3 files changed, 198 insertions(+), 55 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 46236c66efa8..48a411269dcf 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -200,13 +200,11 @@ type gregorHandler struct { reachability *reachability chatLog utils.DebugLabeler - // connGate decides when to connect and disconnect + // 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 - // connTailMu serializes Shutdown with the steps OnConnect applies after - // syncing that can't be undone (badge pushes), so none lands after a - // Shutdown for the connection it came from. Taken before connMutex. - connTailMu sync.Mutex // syncerConn is the connection that last marked the chat syncer // connected, under connMutex. syncerConn *rpc.Connection @@ -286,7 +284,8 @@ func newGregorHandler(g *globals.Context) *gregorHandler { pushStateCh: make(chan struct{}, 100), forcePingCh: make(chan struct{}, 5), } - gh.connGate = newGregorConnGate(g.ExternalG(), gh, gh.chatLog.Debug, gh.forcePing) + eg := g.ExternalG() + gh.connGate = newGregorConnGate(eg.MobileAppState, eg.DesktopAppState, gh, gh.chatLog.Debug, gh.forcePing) return gh } @@ -888,23 +887,26 @@ func (g *gregorHandler) isCurrentConn(conn *rpc.Connection) bool { return conn == g.conn } -// ifCurrentConnTail runs f and reports true if conn is still the current -// connection, holding connTailMu so a Shutdown can't land while f runs. -func (g *gregorHandler) ifCurrentConnTail(conn *rpc.Connection, f func()) bool { - g.connTailMu.Lock() - defer g.connTailMu.Unlock() - current := g.isCurrentConn(conn) - if current { - f() - } - return current +// onGateIfCurrent runs f under the connection gate if conn is still the +// current connection, and reports whether it ran. Every Shutdown and Reset is +// made under the gate too, so a disconnect lands entirely before f, and f is +// then skipped, or entirely after it. +func (g *gregorHandler) onGateIfCurrent(conn *rpc.Connection, f func()) bool { + ran := false + g.connGate.do(func() { + if g.isCurrentConn(conn) { + f() + ran = true + } + }) + return ran } // connectSyncer marks the chat syncer connected for conn and syncs it. -// Syncer.Connected can't run under a lock Shutdown takes, since the sync -// calls the server through this handler, so a Shutdown can land while it -// runs; conn then undoes its own mark, unless a newer connection has marked -// the syncer since. +// Syncer.Connected can't run under the connection gate, since the sync calls +// the server through this handler and may ask the gate to reconnect, so a +// Shutdown can land while it runs; conn then undoes its own mark, unless a +// newer connection has marked the syncer since. func (g *gregorHandler) connectSyncer(ctx context.Context, conn *rpc.Connection, chatCli chat1.RemoteInterface, uid gregor1.UID, syncRes *chat1.SyncChatRes, ) error { @@ -948,7 +950,7 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio // could be received. // See: https://github.com/keybase/client/pull/12651 g.runOnConnectStep(onConnectStepChatBadges) - if !g.ifCurrentConnTail(conn, func() { + if !g.onGateIfCurrent(conn, func() { if g.badger != nil { g.badger.PushChatFullUpdate(ctx, syncAllRes.Badge) } @@ -977,7 +979,7 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio // Update badging from gregor, and call out to reachability module if we // have one. g.runOnConnectStep(onConnectStepGregorBadges) - if !g.ifCurrentConnTail(conn, func() { + if !g.onGateIfCurrent(conn, func() { if g.badger != nil { state, err := gcli.StateMachineState(ctx, nil, false) if err != nil { @@ -1008,19 +1010,15 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio } }(g.makeReconnectOobm()) - // No longer first connect if we are now connected. Checked and written - // under connMutex: Reset sets first connect back to true after its - // Shutdown, so a logout either lands first and this is skipped, or - // overwrites this. + // No longer first connect if we are now connected. g.runOnConnectStep(onConnectStepConnected) - g.connMutex.Lock() - defer g.connMutex.Unlock() - if conn != g.conn { + if !g.onGateIfCurrent(conn, func() { + g.chatLog.Debug(ctx, "setting first connect to false") + g.setFirstConnect(false) + g.setConnectedAt(time.Now()) + }) { return chat.ErrDuplicateConnection } - g.chatLog.Debug(ctx, "setting first connect to false") - g.setFirstConnect(false) - g.setConnectedAt(time.Now()) g.chatLog.Debug(ctx, "OnConnect complete") return nil } @@ -1476,10 +1474,11 @@ func (g *gregorHandler) handleOutOfBandMessage(ctx context.Context, obm gregor.O } } +// Shutdown disconnects. 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. func (g *gregorHandler) Shutdown(ctx context.Context) { defer g.chatLog.Trace(ctx, nil, "Shutdown")() - g.connTailMu.Lock() - defer g.connTailMu.Unlock() g.connMutex.Lock() defer g.connMutex.Unlock() diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index 3caff162c047..5544a0957ead 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -20,14 +20,23 @@ type gregorConnector interface { IsConnected() bool } +// gregorAppState is the mobile app state the gate follows. Tests wrap the +// real one to act between a connect's state read and what it 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. +// which then waits for that connect before taking the connection down. mu +// also runs the steps OnConnect applies after syncing (see do), so none of +// them interleaves with a disconnect. type gregorConnGate struct { - mobile *libkb.MobileAppState + mobile gregorAppState desktop *libkb.DesktopAppState conn gregorConnector debug func(ctx context.Context, format string, args ...any) @@ -38,9 +47,6 @@ type gregorConnGate struct { // held back in BACKGROUND, so the monitor connects once the app leaves // BACKGROUND. uri *rpc.FMPURI - // beforeConnect, if set, runs in connect between reading the app state - // and acting on it. Tests only. - beforeConnect func() // 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 @@ -54,12 +60,12 @@ type gregorConnGate struct { monitorDone chan struct{} } -func newGregorConnGate(g *libkb.GlobalContext, conn gregorConnector, +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: g.MobileAppState, - desktop: g.DesktopAppState, + mobile: mobile, + desktop: desktop, conn: conn, debug: debug, onForeground: onForeground, @@ -68,6 +74,16 @@ func newGregorConnGate(g *libkb.GlobalContext, conn gregorConnector, } } +// do runs f under the gate, so it cannot interleave with a connect, a reset, +// a reconnect or a reconcile, and so with none of the Shutdowns and Resets +// those make. f must not call back into the gate: mu is not reentrant. The +// lock order is mu, then the handler's connMutex. +func (c *gregorConnGate) do(f func()) { + c.mu.Lock() + defer c.mu.Unlock() + f() +} + func (c *gregorConnGate) canConnect(state keybase1.MobileAppState) bool { return state != keybase1.MobileAppState_BACKGROUND } @@ -103,9 +119,6 @@ func (c *gregorConnGate) connect(ctx context.Context, uri *rpc.FMPURI, reset boo } } state := c.mobile.State() - if c.beforeConnect != nil { - c.beforeConnect() - } if !c.canConnect(state) { c.debug(ctx, "connect: not connecting in %v", state) return nil diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index 1a840bd6309c..17f9934609d8 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -96,11 +96,39 @@ func (f *fakeGregorConn) lastURI() *rpc.FMPURI { 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 - conn *fakeGregorConn - pings *atomic.Int64 + tc libkb.TestContext + gate *gregorConnGate + mobile *gregorTestAppState + conn *fakeGregorConn + pings *atomic.Int64 } func testGregorURI(t testing.TB, host string) *rpc.FMPURI { @@ -117,7 +145,8 @@ func setupGregorConn(t *testing.T, state keybase1.MobileAppState) *gregorConnTes tc.G.MobileAppState.Update(state) conn := &fakeGregorConn{} pings := &atomic.Int64{} - gate := newGregorConnGate(tc.G, conn, + 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() @@ -129,7 +158,7 @@ func setupGregorConn(t *testing.T, state keybase1.MobileAppState) *gregorConnTes t.Error("monitor did not exit on stop") } }) - return &gregorConnTest{tc: tc, gate: gate, conn: conn, pings: pings} + 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 @@ -324,7 +353,7 @@ func TestGregorConnLoginReplacesStaleConn(t *testing.T) { func TestGregorConnBackgroundRacingConnect(t *testing.T) { c := setupGregorConn(t, keybase1.MobileAppState_FOREGROUND) c.waitMonitor(t) - c.gate.beforeConnect = func() { + 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. @@ -336,7 +365,7 @@ func TestGregorConnBackgroundRacingConnect(t *testing.T) { 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()) @@ -439,9 +468,11 @@ func TestGregorConnStress(t *testing.T) { baseline := runtime.NumGoroutine() conn := &fakeGregorConn{} - gate := newGregorConnGate(tc.G, conn, func(context.Context, string, ...any) {}, func(context.Context) {}) + 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, conn: conn} + c := &gregorConnTest{tc: tc, gate: gate, mobile: mobile, conn: conn} uri := testGregorURI(t, "gregord.test") states := []keybase1.MobileAppState{ keybase1.MobileAppState_FOREGROUND, @@ -600,6 +631,25 @@ func TestGregorHandlerDisconnectStaysDown(t *testing.T) { 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() @@ -987,6 +1037,87 @@ func TestGregorOnConnectBadgePushHoldsOffLogout(t *testing.T) { require.Equal(t, 1, pushes) } +// reinstall makes conn the current connection again, 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() { + c.h.connMutex.Lock() + defer c.h.connMutex.Unlock() + c.h.conn = c.conn + c.h.shutdownCh = make(chan struct{}) +} + +// 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()) + c.conn = &rpc.Connection{} + c.reinstall() + 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() + _ = c.run() + // 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 300 { + c.h.G().MobileAppState.Update(allAppStates[rng.Intn(len(allAppStates))]) + runtime.Gosched() + } + }() + } + + done := make(chan struct{}) + go func() { + writers.Wait() + close(stop) + tails.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(60 * time.Second): + t.Fatal("deadlock: the connect tail, logouts and transitions did not finish") + } + + 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 { From 4a8fe5c02d701978b3b56857815254db9c923eb4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 14:49:32 -0400 Subject: [PATCH 052/127] test(gregor): widen the tail stress window and report its deadlock guard The stress test drove 300 transitions with no sleeps, so its racing window was a couple of milliseconds; use the sibling gate stress test's 500 iterations with random sub-millisecond sleeps. A deadlock also leaves the racers holding the handler's locks, so the cleanup that shuts the handler down never returns and the guard's t.Fatal (or a panic) is swallowed by the hung unwind. Print the message on stderr before failing, so the guard names the failure and go test's timeout then dumps every stack. Also say in Shutdown's doc that it is production that only ever calls it under the gate, since tests call it directly, and lead the gregorAppState comment with the abstraction rather than its test use. --- go/service/gregor.go | 7 ++++--- go/service/gregor_conn.go | 5 +++-- go/service/gregor_conn_test.go | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 48a411269dcf..4eedf7b469ca 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -1474,9 +1474,10 @@ func (g *gregorHandler) handleOutOfBandMessage(ctx context.Context, obm gregor.O } } -// Shutdown disconnects. 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. +// 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() diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index 5544a0957ead..827ce1a38798 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -20,8 +20,9 @@ type gregorConnector interface { IsConnected() bool } -// gregorAppState is the mobile app state the gate follows. Tests wrap the -// real one to act between a connect's state read and what it does with it. +// 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{} diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index 17f9934609d8..d3b16313f761 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -6,6 +6,7 @@ import ( "fmt" "math/rand" "net" + "os" "runtime" "sync" "sync/atomic" @@ -1091,9 +1092,11 @@ func TestGregorOnConnectTailStress(t *testing.T) { go func() { defer writers.Done() rng := rand.New(rand.NewSource(int64(w))) - for range 300 { + for range 500 { c.h.G().MobileAppState.Update(allAppStates[rng.Intn(len(allAppStates))]) - runtime.Gosched() + if rng.Intn(4) == 0 { + time.Sleep(time.Duration(rng.Intn(200)) * time.Microsecond) + } } }() } @@ -1108,7 +1111,14 @@ func TestGregorOnConnectTailStress(t *testing.T) { select { case <-done: case <-time.After(60 * time.Second): - t.Fatal("deadlock: the connect tail, logouts and transitions did not finish") + // 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()) From 0e5c4b177c3db52990e356cfc74c6eb0108ace89 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 14:51:47 -0400 Subject: [PATCH 053/127] test(gregor): let the gate stress test report its own deadlock guard Same fix as the connect tail stress test: a deadlock leaves the workers holding the gate, so the cleanup that stops the monitor never returns and t.Fatal's message never reaches the output. Print it on stderr first, so the guard names the failure instead of leaving an opaque go test timeout. --- go/service/gregor_conn_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index d3b16313f761..db5b5bcd600f 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -533,7 +533,14 @@ func TestGregorConnStress(t *testing.T) { select { case <-done: case <-time.After(60 * time.Second): - t.Fatal("deadlock: transitions and connects did not finish") + // 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())) From 2dae407ec98eea51c013f5a340860628b1c5bd00 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 15:50:31 -0400 Subject: [PATCH 054/127] refactor(push): hand tapped pushes to JS through a native tap slot and keep non-tap pushes out of JS Native keeps the payload of the last tapped notification in a one-value slot until JS takes it, and only the notification-tap handlers fill it. A navigation intent's targetUid therefore marks a real tap, and only a tap can switch accounts: links from other apps, web pages or simctl reach JS through Linking and never carry an account. Non-tap pushes no longer reach JS at all. iOS handles the read-receipt cleanup natively and ignores everything else; the Android service emits nothing. --- .../main/java/com/reactnativekb/KbModule.kt | 60 +-- rnmodules/react-native-kb/ios/Kb.h | 7 +- rnmodules/react-native-kb/ios/Kb.mm | 139 ++----- rnmodules/react-native-kb/src/NativeKb.ts | 9 +- rnmodules/react-native-kb/src/index.tsx | 21 +- .../android/app/src/main/AndroidManifest.xml | 9 + .../io/keybase/ossifrage/KBPushNotifier.kt | 28 +- .../KeybasePushNotificationListenerService.kt | 17 - .../java/io/keybase/ossifrage/MainActivity.kt | 35 +- .../io/keybase/ossifrage/PushTapActivity.kt | 22 ++ .../java/io/keybase/ossifrage/PushTapData.kt | 21 + .../io/keybase/ossifrage/PushTapDataTest.kt | 39 ++ shared/constants/deeplinks.test.ts | 21 + shared/constants/deeplinks.tsx | 13 +- shared/constants/init/index.tsx | 40 +- .../init/push-listener.native.test.ts | 372 +++--------------- .../constants/init/push-listener.native.tsx | 333 +--------------- shared/constants/types/index.tsx | 1 - shared/constants/types/push.tsx | 54 --- shared/ios/Keybase/AppDelegate.swift | 51 +-- shared/ios/Keybase/SceneDelegate.swift | 3 - shared/router-v2/account-link-switch.test.ts | 131 ++++++ shared/router-v2/account-link-switch.tsx | 62 +++ shared/router-v2/deep-link-emitter.test.ts | 88 ++++- shared/router-v2/deep-link-emitter.tsx | 78 +++- shared/router-v2/intent-consumption.test.ts | 4 +- shared/router-v2/linking-initial-url.test.ts | 20 +- shared/router-v2/linking.test.ts | 10 +- shared/router-v2/linking.tsx | 29 +- shared/stores/config.tsx | 4 - shared/stores/push.tsx | 169 +------- shared/stores/tests/config.test.ts | 4 - shared/stores/tests/push.desktop.test.ts | 1 - .../flows/lifecycle-links-push.test.ts | 55 ++- shared/tools/sim-push-chat.sh | 3 +- 35 files changed, 752 insertions(+), 1201 deletions(-) create mode 100644 shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt create mode 100644 shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt create mode 100644 shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt create mode 100644 shared/constants/deeplinks.test.ts delete mode 100644 shared/constants/types/push.tsx create mode 100644 shared/router-v2/account-link-switch.test.ts create mode 100644 shared/router-v2/account-link-switch.tsx 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 860c0a36e15e..cc9df39d6edb 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 @@ -34,6 +33,7 @@ import java.io.FileReader import java.io.IOException import java.lang.reflect.Method import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import keybase.Keybase import keybase.Keybase.readArr import keybase.Keybase.version @@ -187,11 +187,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } - // Only iOS queues pushes until JS listens. - @ReactMethod - override fun pushListenerRegistered() { - } - // Only iOS needs a scene-based app state; JS uses RN's AppState on Android. @ReactMethod(isBlockingSynchronousMethod = true) override fun getAppState(): String = "" @@ -385,34 +380,12 @@ 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) - } - } + @ReactMethod(isBlockingSynchronousMethod = true) + override fun takePushTap(): String = pushTapSlot.getAndSet(null) ?: "" - private fun emitPushNotificationInternal(notification: Bundle) { + private fun emitPushTapInternal() { 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") + emitOnPushTap("") } } @@ -789,17 +762,21 @@ 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 + // The payload of the last tapped notification, until JS takes it. Only + // io.keybase.ossifrage.PushTapActivity, which is not exported, fills it, so it alone + // may carry an account switch. + private val pushTapSlot = AtomicReference(null) @JvmStatic fun keyPressed(keyName: String) { instance?.sendHardwareKeyEvent(keyName) } + // Called only by io.keybase.ossifrage.PushTapActivity. @JvmStatic - fun setInitialNotification(bundle: Bundle?) { - initialNotificationBundle = bundle + fun deliverPushTap(payload: String) { + pushTapSlot.set(payload) + instance?.emitPushTapInternal() } @JvmStatic @@ -807,17 +784,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T 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 f313e088131f..cfd210a2c80d 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -22,8 +22,5 @@ // Push notification helpers - can be called from AppDelegate FOUNDATION_EXPORT void KbSetDeviceToken(NSString *token); -// Emits to JS when its push listener is ready. Otherwise a tap -// (userInteraction) is kept for getInitialNotification and anything else is -// queued until JS is ready, so a push that arrives while React Native isn't -// running (a background launch never starts it) is not lost. -FOUNDATION_EXPORT void KbDeliverPushNotification(NSDictionary *notification); +// Hands a tapped notification's payload to JS (the tap slot; see Kb.mm). +FOUNDATION_EXPORT void KbDeliverPushTap(NSString *payload); diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 5f5575a9c2dd..77a4e5a8c598 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -49,32 +49,11 @@ + (id)sharedFsPathsHolder { static std::mutex kbSharedInstanceMutex; static BOOL kbPasteImageEnabled = NO; static NSString *kbStoredDeviceToken = nil; -// Push notifications that arrive before JS can take them. A tap waits in -// kbInitialNotification for getInitialNotification (the startup path); anything -// else waits in kbPendingNotifications and is emitted once JS is ready. -static std::mutex kbNotificationMutex; -static NSDictionary *kbInitialNotification = nil; -static NSMutableArray *kbPendingNotifications = nil; -static const NSUInteger kbMaxPendingNotifications = 50; -// A background-launched process can sit suspended for hours before the user -// opens the app. The only thing a queued non-tap push still does in JS is -// badge upkeep (chat.readmessage), and JS reloads badge state from the service -// at startup, so anything older than this is superseded rather than useful. -static const uint64_t kbMaxPendingNotificationAgeNs = 10 * 60 * NSEC_PER_SEC; -static NSString *const kbPendingPayloadKey = @"payload"; -static NSString *const kbPendingQueuedAtKey = @"queuedAt"; - -// Continues while the device sleeps, unlike mach_absolute_time, so a push -// queued before a long sleep reads as old. -static uint64_t kbMonotonicNowNs(void) { - return clock_gettime_nsec_np(CLOCK_MONOTONIC); -} - -// Pushes whose only effect in JS is navigation. Without a tap they must not -// navigate, so they are never queued for a later JS. -static BOOL kbIsNavigationOnlyPush(NSDictionary *notification) { - return [notification[@"type"] isEqual:@"chat.extension"]; -} +// The payload of the last tapped notification, until JS takes it. Only the native +// notification-tap handler writes it (never a URL another app opens), so it alone may +// carry an account switch. +static std::mutex kbPushTapMutex; +static NSString *kbPushTapPayload = 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 @@ -262,41 +241,6 @@ @implementation Kb { // lock) keeps this ivar and kbCurrentBridge consistent with each other // without ever nesting the two critical sections. std::shared_ptr myBridge_; - // Guarded by kbNotificationMutex. Set once this instance's JS has registered - // its onPushNotification listener (pushListenerRegistered, or the - // getInitialNotification fallback). canEmit alone is not enough, since the - // emitter callback exists as soon as JS creates the module, well before the - // listener. - BOOL pushListenerReady_; -} - -// REQUIRES kbNotificationMutex. Marks this instance's JS as listening and emits -// the queued pushes that are still fresh. Emitting under the lock keeps a push -// delivered concurrently from overtaking the ones queued before it. -- (void)pushListenerReadyLocked { - pushListenerReady_ = YES; - if (kbPendingNotifications.count == 0) { - return; - } - if (![self canEmit]) { - NSLog(@"Kb.pushListenerReady: emitter not ready, keeping %lu queued pushes", - (unsigned long)kbPendingNotifications.count); - return; - } - uint64_t now = kbMonotonicNowNs(); - NSUInteger stale = 0; - for (NSDictionary *pending in kbPendingNotifications) { - uint64_t queuedAt = [pending[kbPendingQueuedAtKey] unsignedLongLongValue]; - if (now - queuedAt > kbMaxPendingNotificationAgeNs) { - stale++; - continue; - } - [self emitOnPushNotification:pending[kbPendingPayloadKey]]; - } - if (stale > 0) { - NSLog(@"Kb.pushListenerReady: dropped %lu stale queued pushes", (unsigned long)stale); - } - kbPendingNotifications = nil; } RCT_EXPORT_MODULE() @@ -375,6 +319,13 @@ - (NSString *)getAppState { return kbAppState; } +- (NSString *)takePushTap { + std::lock_guard lock(kbPushTapMutex); + NSString *payload = kbPushTapPayload ?: @""; + kbPushTapPayload = nil; + return payload; +} + + (BOOL)requiresMainQueueSetup { return YES; } @@ -630,11 +581,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime RCT_EXPORT_METHOD(shareListenersRegistered) { } -RCT_EXPORT_METHOD(pushListenerRegistered) { - std::lock_guard lock(kbNotificationMutex); - [self pushListenerReadyLocked]; -} - // No current caller (kept for future use). RCT_EXPORT_METHOD(engineReset) { NSError *error = nil; @@ -937,21 +883,6 @@ - (void)installJSIBindingsWithRuntime:(jsi::Runtime &)runtime }); } -RCT_EXPORT_METHOD(getInitialNotification: (RCTPromiseResolveBlock)resolve reject: (RCTPromiseRejectBlock)reject) { - NSDictionary *notification = nil; - { - std::lock_guard lock(kbNotificationMutex); - notification = kbInitialNotification; - kbInitialNotification = nil; - // Fallback until JS calls pushListenerRegistered: today JS registers its - // listener before it asks for the initial notification. - if (!pushListenerReady_) { - [self pushListenerReadyLocked]; - } - } - resolve(notification ?: [NSNull null]); -} - RCT_EXPORT_METHOD(removeAllPendingNotificationRequests) { UNUserNotificationCenter *current = UNUserNotificationCenter.currentNotificationCenter; [current removeAllPendingNotificationRequests]; @@ -1036,36 +967,20 @@ + (void)setDeviceToken:(NSString *)token { }); } -+ (void)deliverPushNotification:(NSDictionary *)notification { - std::lock_guard lock(kbNotificationMutex); - Kb *instance = kbSharedInstance; - if (instance && instance->pushListenerReady_ && [instance canEmit]) { - if (kbPendingNotifications.count > 0) { - [instance pushListenerReadyLocked]; - } - [instance emitOnPushNotification:notification]; - return; - } - if ([notification[@"userInteraction"] boolValue]) { - kbInitialNotification = notification; - NSLog(@"Kb.deliverPushNotification: JS not ready, stored tap for getInitialNotification"); - return; - } - if (kbIsNavigationOnlyPush(notification)) { - NSLog(@"Kb.deliverPushNotification: JS not ready, dropped a navigation-only push without a tap"); - return; - } - if (!kbPendingNotifications) { - kbPendingNotifications = [NSMutableArray array]; - } - if (kbPendingNotifications.count >= kbMaxPendingNotifications) { - [kbPendingNotifications removeObjectAtIndex:0]; - NSLog(@"Kb.deliverPushNotification: pending queue full, dropped the oldest"); +// Keeps the latest tap for JS and tells JS if it is listening. The event carries nothing: +// JS takes the payload from the slot, at startup and on the event, so a tap is taken +// exactly once. ++ (void)deliverPushTap:(NSString *)payload { + { + std::lock_guard lock(kbPushTapMutex); + kbPushTapPayload = payload; } - [kbPendingNotifications addObject:@{ - kbPendingPayloadKey : notification, - kbPendingQueuedAtKey : @(kbMonotonicNowNs()), - }]; + dispatch_async(dispatch_get_main_queue(), ^{ + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnPushTap:@""]; + } + }); } - (void)handleHardwareKeyPressed:(NSNotification *)notification { @@ -1114,6 +1029,6 @@ void KbSetDeviceToken(NSString *token) { [Kb setDeviceToken:token]; } -void KbDeliverPushNotification(NSDictionary *notification) { - [Kb deliverPushNotification:notification]; +void KbDeliverPushTap(NSString *payload) { + [Kb deliverPushTap:payload]; } diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index be557d67baaf..3d3748c927e7 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -1,11 +1,12 @@ 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 + // A tapped notification's payload is waiting in native's tap slot; call takePushTap. Carries nothing. + readonly onPushTap: EventEmitter readonly onPushToken: EventEmitter readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> // iOS only: 'active' | 'inactive' | 'background', from the scene activation notifications @@ -62,13 +63,13 @@ export interface Spec extends TurboModule { requestPushPermissions(): Promise getRegistrationToken(): Promise setApplicationIconBadgeNumber(n: number): void - getInitialNotification(): Promise + // Returns the waiting tap payload and clears it, or '' when there is none. + takePushTap(): string removeAllPendingNotificationRequests(): void addNotificationRequest(config: {body: string; id: string}): Promise engineReset(): void notifyJSReady(): void shareListenersRegistered(): void - pushListenerRegistered(): void // iOS only: the current value onAppStateChange reports; '' on Android getAppState(): string setEnablePasteImage(enabled: boolean): void diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index 4a0499e638d9..1289e4c5423f 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,8 +139,15 @@ export const onMetaEvent = (callback: (payload: string) => void): EventSubscript } // Push events -export const onPushNotification = (callback: (notification: object) => void): EventSubscription => { - return Kb.onPushNotification(n => callback(n)) + +// A tapped notification's payload waits in native until takePushTap reads it; subscribe first, +// then take, and take again on every event. +export const onPushTap = (callback: () => void): EventSubscription => { + return Kb.onPushTap(() => callback()) +} + +export const takePushTap = (): string => { + return Kb.takePushTap() } export const onPushToken = (callback: (token: string) => void): EventSubscription => { @@ -177,12 +180,6 @@ export const iosGetAppState = (): string => { return Kb.getAppState() } -// iOS: call once onPushNotification is subscribed; pushes queued while JS -// wasn't listening are emitted then. -export const pushListenerRegistered = (): void => { - return Kb.pushListenerRegistered() -} - export const clearLocalLogs = (): Promise => { return Kb.clearLocalLogs() } 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/KBPushNotifier.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt index 1eed346fdc0b..e91a1744c34c 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 @@ -20,12 +20,22 @@ import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.graphics.drawable.IconCompat import keybase.ChatNotification +import org.json.JSONObject import keybase.PushNotifier import java.io.BufferedInputStream import java.io.IOException import java.net.HttpURLConnection import java.net.URL +internal fun bundleJSON(bundle: Bundle): String { + val json = JSONObject() + for (key in bundle.keySet()) { + @Suppress("DEPRECATION") + json.put(key, JSONObject.wrap(bundle.get(key))) + } + return json.toString() +} + class KBPushNotifier internal constructor(private val context: Context, private val bundle: Bundle) : PushNotifier { private var convMsgCache: SmallMsgRingBuffer? = null private fun buildStyle(person: Person): NotificationCompat.MessagingStyle { @@ -38,15 +48,14 @@ class KBPushNotifier internal constructor(private val context: Context, private this.convMsgCache = convMsgCache } - // Controls the Intent that gets built + // A tap goes through PushTapActivity, which hands the push's payload to JS. The payload is + // the Intent's data so each notification gets its own PendingIntent (see PushTapData), and + // immutable so whoever holds this PendingIntent can't substitute another payload. 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) - - // 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) + val intent = Intent(context, PushTapActivity::class.java) + intent.setData(Uri.parse(PushTapData.encode(bundleJSON(bundle)))) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } private fun getKeybaseAvatar(avatarUri: String): IconCompat? { @@ -105,7 +114,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 +187,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 +210,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/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt index 5bf92b04cd1b..00ca83b89ab2 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 @@ -15,7 +15,6 @@ 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() { @@ -211,11 +210,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { } - if (type == "chat.newmessage") { - val emitBundle = bundle.clone() as Bundle - emitBundle.putBoolean("userInteraction", false) - KbModule.emitPushNotification(emitBundle) - } } "follow" -> { @@ -223,18 +217,12 @@ 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" -> { @@ -251,15 +239,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) { 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 3842796e78b5..737ed89cfac6 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 @@ -172,9 +172,9 @@ class MainActivity : ReactActivity() { 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 consume them until much + // later (see tryHandleIntentWithRetry). private fun captureIntent(intent: Intent) { cachedIntent = intent if (Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action) { @@ -182,17 +182,13 @@ class MainActivity : ReactActivity() { 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) - } } 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 @@ -202,8 +198,6 @@ class MainActivity : ReactActivity() { tryHandleIntentWithRetry() } - 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) { @@ -268,27 +262,6 @@ class MainActivity : ReactActivity() { } 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) - } - - intent.removeExtra("notification") - } - val action = intent.action if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) { val uris = pendingShareUris.orEmpty().also { pendingShareUris = null } 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..5b365c420539 --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt @@ -0,0 +1,22 @@ +package io.keybase.ossifrage + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.reactnativekb.KbModule + +// Opens the app for a tapped notification. Not exported, so only this app's own notification +// PendingIntents can start it: the tap payload it hands to JS, which may switch accounts, 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) + PushTapData.decode(intent.dataString).takeIf { it.isNotEmpty() }?.let { KbModule.deliverPushTap(it) } + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + ) + // Theme.NoDisplay requires finishing before onResume. + finish() + } +} diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt new file mode 100644 index 000000000000..245da4515ffe --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt @@ -0,0 +1,21 @@ +package io.keybase.ossifrage + +import java.net.URLDecoder +import java.net.URLEncoder + +// A tapped notification's payload rides in the tap Intent's data URI rather than only in an +// extra. PendingIntent.getActivity hands back an existing PendingIntent for any Intent that +// filterEquals the new one, and extras are not part of filterEquals, so without distinct data +// two notifications built with the same request code would share the first one's target. +object PushTapData { + private const val SCHEME = "kbpushtap:" + + fun encode(payloadJSON: String): String = SCHEME + URLEncoder.encode(payloadJSON, "UTF-8") + + fun decode(dataString: String?): String = + if (dataString != null && dataString.startsWith(SCHEME)) { + URLDecoder.decode(dataString.substring(SCHEME.length), "UTF-8") + } else { + "" + } +} diff --git a/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt b/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt new file mode 100644 index 000000000000..7513be92b469 --- /dev/null +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt @@ -0,0 +1,39 @@ +package io.keybase.ossifrage + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class PushTapDataTest { + @Test + fun aPayloadRoundTrips() { + val payload = """{"type":"chat.newmessage","convID":"0000ab","uid":"u 1&x/+","message":"hi ünïcode"}""" + assertEquals(payload, PushTapData.decode(PushTapData.encode(payload))) + } + + // W7: buildPendingIntent reused one request code per second, and extras are not part of + // filterEquals, so two notifications built in the same second shared a PendingIntent and + // the second one's tap opened the first one's target. Distinct data makes them non-equal. + @Test + fun twoNotificationsInTheSameSecondGetDistinctTapTargets() { + val first = PushTapData.encode("""{"type":"chat.newmessage","convID":"conv-a","uid":"u1"}""") + val second = PushTapData.encode("""{"type":"chat.newmessage","convID":"conv-b","uid":"u1"}""") + + assertNotEquals(first, second) + assertEquals("""{"type":"chat.newmessage","convID":"conv-a","uid":"u1"}""", PushTapData.decode(first)) + assertEquals("""{"type":"chat.newmessage","convID":"conv-b","uid":"u1"}""", PushTapData.decode(second)) + } + + @Test + fun twoNotificationsWithTheSamePayloadShareOneTapTarget() { + val payload = """{"type":"follow","username":"testuser"}""" + assertEquals(PushTapData.encode(payload), PushTapData.encode(payload)) + } + + @Test + fun aDataUriFromAnywhereElseDecodesToNothing() { + assertEquals("", PushTapData.decode(null)) + assertEquals("", PushTapData.decode("keybase://convid/0000ab")) + assertEquals("", PushTapData.decode("")) + } +} diff --git a/shared/constants/deeplinks.test.ts b/shared/constants/deeplinks.test.ts new file mode 100644 index 000000000000..fb52256347cf --- /dev/null +++ b/shared/constants/deeplinks.test.ts @@ -0,0 +1,21 @@ +/// +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 {handleAppLink} from './deeplinks' + +test('a devices link opens the devices list in settings', () => { + handleAppLink('keybase://devices') + + expect(Router.switchTab).toHaveBeenCalledWith(Tabs.settingsTab) + expect(Router.navUpToScreen).toHaveBeenCalledWith('devicesRoot') + expect(Router.navigateAppend).not.toHaveBeenCalled() +}) diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index df62b988a6c1..118bba13deb9 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -1,6 +1,13 @@ 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 {showTeamByName} from '@/teams/team-page-actions' @@ -75,6 +82,10 @@ const handleKeybaseLink = (link: string) => { return } break + case 'devices': + switchTab(Tabs.settingsTab) + navUpToScreen('devicesRoot') + return case 'private': case 'public': try { diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index a3e5d036574b..db2204892dbb 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -135,7 +135,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 { @@ -143,35 +142,26 @@ 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: subscribePushTaps queues 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 + if (initialUrl) { link = initialUrl } else if (routeState) { // Last priority, saved from last session @@ -206,7 +196,6 @@ const loadStartupDetails = async () => { useConfigState.getState().dispatch.setStartupDetails({ conversation: conversation ?? noConversationIDKey, conversationUid, - followUser, link, tab: tab as Tabs.Tab, }) @@ -601,7 +590,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/push-listener.native.test.ts b/shared/constants/init/push-listener.native.test.ts index 5e1c50842791..ec798ea7a495 100644 --- a/shared/constants/init/push-listener.native.test.ts +++ b/shared/constants/init/push-listener.native.test.ts @@ -1,334 +1,90 @@ /// -import type * as PushListener from './push-listener.native' -import type * as PushStore from '@/stores/push' -import type * as ConfigStore from '@/stores/config' -import type * as CurrentUserStore from '@/stores/current-user' -import type * as DaemonStore from '@/stores/daemon' -import type * as T from '@/constants/types' - -// push-listener and the push store pick their mobile behavior when they load, so each test loads -// them fresh with the mobile globals set and the native module mocked. - -type Loaded = { - configStore: typeof ConfigStore - currentUserStore: typeof CurrentUserStore - daemonStore: typeof DaemonStore - pushListener: typeof PushListener - pushStore: typeof PushStore -} - -const calls = new Array() -const emitDeepLink = jest.fn() -const switchTab = jest.fn() -const navUpToScreen = jest.fn() -let onNotification: ((n: object) => void) | undefined -const pushSubRemove = jest.fn() -let getInitialNotification: () => Promise = async () => Promise.resolve(null) - -const currentUid = 'uid-testuser' -const otherUid = 'uid-testuser-mac' -const convID = 'aabbccdd' - -const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} +let mockSlot = '' +const mockCalls = new Array() +let mockFire: (() => void) | undefined + +jest.mock('react-native-kb', () => ({ + onPushTap: (cb: () => void) => { + mockCalls.push('onPushTap') + mockFire = cb + return { + remove: () => { + mockCalls.push('remove') + mockFire = undefined + }, + } + }, + takePushTap: () => { + mockCalls.push('takePushTap') + const payload = mockSlot + mockSlot = '' + return payload + }, +})) -const load = (): Loaded => { - global.isMobile = true - global.isIOS = true - global.isAndroid = false - jest.resetModules() - jest.doMock('react-native-kb', () => ({ - checkPushPermissions: async () => Promise.resolve(true), - getInitialNotification: async () => getInitialNotification(), - getRegistrationToken: async () => Promise.resolve(''), - iosGetHasShownPushPrompt: async () => Promise.resolve(true), - onPushNotification: (cb: (n: object) => void) => { - calls.push('onPushNotification') - onNotification = cb - return {remove: pushSubRemove} - }, - onPushToken: () => ({remove: () => {}}), - onShareData: () => ({remove: () => {}}), - pushListenerRegistered: () => { - calls.push('pushListenerRegistered') - }, - removeAllPendingNotificationRequests: () => {}, - requestPushPermissions: async () => Promise.resolve(true), - setApplicationIconBadgeNumber: () => {}, - })) - jest.doMock('@/router-v2/deep-link-emitter', () => ({ - emitDeepLink, - normalizeUrl: (url: string) => url, - setInitialURLOnce: (url: string) => url, - })) - jest.doMock('@/constants/router', () => ({ - ...jest.requireActual('@/constants/router'), - getRootState: () => undefined, - navUpToScreen, - switchTab, - })) - const loaded = { - configStore: require('@/stores/config') as typeof ConfigStore, - currentUserStore: require('@/stores/current-user') as typeof CurrentUserStore, - daemonStore: require('@/stores/daemon') as typeof DaemonStore, - pushListener: require('./push-listener.native') as typeof PushListener, - pushStore: require('@/stores/push') as typeof PushStore, - } - const T_ = require('@/constants/types') as typeof T - jest.spyOn(T_.RPCGen, 'configGuiGetValueRpcPromise').mockResolvedValue({b: true, isNull: false}) - loaded.currentUserStore.useCurrentUserState.setState({uid: currentUid, username: 'testuser'}) - loaded.configStore.useConfigState.setState({ - configuredAccounts: [{hasStoredSecret: true, uid: currentUid, username: 'testuser'}], - loggedIn: true, - }) - return loaded -} +import {useNavigationIntentsState} from '@/stores/navigation-intents' +import {subscribePushTaps} from './push-listener.native' -const flush = async () => { - for (let i = 0; i < 5; i++) { - await Promise.resolve() - } -} +const chatTap = '{"type":"chat.newmessage","convID":"0000ab","uid":"uid-other"}' -afterEach(() => { - calls.length = 0 - onNotification = undefined - getInitialNotification = async () => Promise.resolve(null) - jest.useRealTimers() - jest.restoreAllMocks() - jest.clearAllMocks() - jest.dontMock('react-native-kb') - jest.dontMock('@/router-v2/deep-link-emitter') - jest.dontMock('@/constants/router') - jest.resetModules() - global.isMobile = originalGlobals.isMobile - global.isIOS = originalGlobals.isIOS - global.isAndroid = originalGlobals.isAndroid +beforeEach(() => { + mockSlot = '' + mockCalls.length = 0 + mockFire = undefined }) -// every raw push type whose handling can navigate -const navigatingPushes = (userInteraction: boolean) => ({ - 'chat.extension': {convID, type: 'chat.extension', userInteraction}, - 'chat.newmessage': {convID, m: '', t: 2, type: 'chat.newmessage', userInteraction}, - 'chat.newmessage for another account': { - convID, - m: '', - t: 2, - type: 'chat.newmessage', - uid: otherUid, - userInteraction, - }, - 'device.new': {type: 'device.new', uid: currentUid, userInteraction}, - 'device.revoked': {type: 'device.revoked', uid: currentUid, userInteraction}, - follow: {type: 'follow', username: 'testuser-mac', userInteraction}, - 'settings.contacts': {message: 'Your contact testuser-mac joined Keybase', userInteraction}, +afterEach(() => { + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) + useNavigationIntentsState.setState({lastHandledIntent: undefined}) }) -describe('live pushes', () => { - test('the native readiness signal comes after the push listener is registered', () => { - const {pushListener} = load() - const unsubs = pushListener.initPushListener() - expect(calls).toEqual(['onPushNotification', 'pushListenerRegistered']) - - for (const unsub of unsubs) unsub() - expect(pushSubRemove).toHaveBeenCalled() - }) +test('subscribes before it takes the startup tap', () => { + const unsub = subscribePushTaps() - test.each(Object.entries(navigatingPushes(false)))('%s without a tap never navigates', async (_, raw) => { - const {pushListener, pushStore} = load() - pushListener.initPushListener() - onNotification?.(raw) - await flush() + expect(mockCalls).toEqual(['onPushTap', 'takePushTap']) - expect(emitDeepLink).not.toHaveBeenCalled() - expect(switchTab).not.toHaveBeenCalled() - expect(navUpToScreen).not.toHaveBeenCalled() - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - }) - - test.each(Object.entries(navigatingPushes(true)))('%s with a tap navigates', async (name, raw) => { - const {pushListener, pushStore} = load() - pushListener.initPushListener() - onNotification?.(raw) - await flush() - - if (name === 'chat.newmessage for another account') { - // not a configured account yet: kept until the account list catches up - expect(pushStore.usePushState.getState().pendingPushNotification?.type).toBe('chat.newmessage') - } else if (name.startsWith('device.')) { - expect(switchTab).toHaveBeenCalled() - } else { - expect(emitDeepLink).toHaveBeenCalledTimes(1) - } - }) + unsub() }) -describe('startup push', () => { - const tapped = { - 'chat.newmessage': {convID, m: 'payload', t: 2, type: 'chat.newmessage'}, - follow: {type: 'follow', username: 'testuser-mac'}, - } - - // the read races a timer; fake timers keep it from outliving the test - beforeEach(() => { - jest.useFakeTimers() - }) - - test.each(Object.entries(tapped))('%s without a tap does not pick the startup screen', async (_, raw) => { - const {pushListener, pushStore} = load() - getInitialNotification = async () => Promise.resolve({...raw, userInteraction: false}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - }) - - test('chat.newmessage for another account without a tap is not kept pending', async () => { - const {pushListener, pushStore} = load() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: false}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - }) - - test('a tapped chat.newmessage for another account is kept pending for the account switch', async () => { - const {pushListener, pushStore} = load() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() - const pending = pushStore.usePushState.getState().pendingPushNotification - expect(pending?.type).toBe('chat.newmessage') - expect(pending && 'forUid' in pending && pending.forUid).toBe(otherUid) - }) +test('a startup tap queues a tap intent', () => { + mockSlot = chatTap + const unsub = subscribePushTaps() - test('a tapped chat.newmessage for the account already current picks the startup screen', async () => { - const {pushListener, pushStore} = load() - pushListener.initPushListener() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: currentUid, userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ - startupConversation: convID, - startupPushPayload: 'payload', - }) - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - await flush() - expect(emitDeepLink).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent).toMatchObject({ + targetUid: 'uid-other', + url: 'keybase://convid/0000ab', }) - test('a tapped chat.newmessage read before bootstrap names the account picks the startup screen once it does', async () => { - const {currentUserStore, daemonStore, pushListener, pushStore} = load() - currentUserStore.useCurrentUserState.setState({uid: '', username: ''}) - pushListener.initPushListener() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: currentUid, userInteraction: true}) - let settled = false - const read = pushListener.getStartupDetailsFromInitialPush().finally(() => { - settled = true - }) - await jest.advanceTimersByTimeAsync(100) - expect(settled).toBe(false) + unsub() +}) - // the order bootstrap applies it in: the status, then the current user it names - daemonStore.useDaemonState.setState({ - bootstrapStatus: {deviceID: '', loggedIn: true, uid: currentUid, username: 'testuser'} as T.RPCGen.BootstrapStatus, - }) - currentUserStore.useCurrentUserState.getState().dispatch.setBootstrap({ - deviceID: '', - deviceName: '', - uid: currentUid, - username: 'testuser', - }) - await expect(read).resolves.toEqual({startupConversation: convID, startupPushPayload: 'payload'}) - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - await flush() - expect(emitDeepLink).not.toHaveBeenCalled() - }) +test('a tap that arrives later is taken on its event', () => { + const unsub = subscribePushTaps() + expect(useNavigationIntentsState.getState().intent).toBeUndefined() - test('a tapped chat.newmessage for another account replays once that account is current', async () => { - const {currentUserStore, pushListener, pushStore} = load() - pushListener.initPushListener() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() - expect(pushStore.usePushState.getState().pendingPushNotification?.type).toBe('chat.newmessage') + mockSlot = '{"type":"device.new","uid":"uid-other"}' + mockFire?.() - currentUserStore.useCurrentUserState.getState().dispatch.setBootstrap({ - deviceID: '', - deviceName: '', - uid: otherUid, - username: 'testuser-mac', - }) - await flush() - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - expect(emitDeepLink).toHaveBeenCalledWith(`keybase://convid/${convID}`, {targetUid: otherUid}) + expect(useNavigationIntentsState.getState().intent).toMatchObject({ + targetUid: 'uid-other', + url: 'keybase://devices', }) - test('a tapped chat.newmessage for a stored account already listed switches to it', async () => { - const {configStore, pushListener, pushStore} = load() - const login = jest.fn() - const config = configStore.useConfigState.getState() - configStore.useConfigState.setState({ - configuredAccounts: [ - {hasStoredSecret: true, uid: currentUid, username: 'testuser'}, - {hasStoredSecret: true, uid: otherUid, username: 'testuser-mac'}, - ], - dispatch: {...config.dispatch, login}, - }) - pushListener.initPushListener() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: otherUid, userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() - await flush() - expect(login).toHaveBeenCalledWith('testuser-mac', '') - expect(pushStore.usePushState.getState().pendingPushNotification?.type).toBe('chat.newmessage') - expect(emitDeepLink).not.toHaveBeenCalled() - }) + unsub() +}) - test('an untapped chat.newmessage for the current account neither picks the startup screen nor navigates', async () => { - const {pushListener, pushStore} = load() - pushListener.initPushListener() - getInitialNotification = async () => - Promise.resolve({...tapped['chat.newmessage'], uid: currentUid, userInteraction: false}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toBeUndefined() - await flush() - expect(pushStore.usePushState.getState().pendingPushNotification).toBeUndefined() - expect(emitDeepLink).not.toHaveBeenCalled() - }) +test('no tap queues nothing', () => { + const unsub = subscribePushTaps() - test('tapped pushes pick the startup screen', async () => { - const {pushListener} = load() - getInitialNotification = async () => Promise.resolve({...tapped['chat.newmessage'], userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ - startupConversation: convID, - startupPushPayload: 'payload', - }) - getInitialNotification = async () => Promise.resolve({...tapped.follow, userInteraction: true}) - await expect(pushListener.getStartupDetailsFromInitialPush()).resolves.toEqual({ - startupFollowUser: 'testuser-mac', - }) - }) + expect(useNavigationIntentsState.getState().intent).toBeUndefined() - test('a tap that native takes a while to hand over is not lost', async () => { - const {pushListener} = load() - getInitialNotification = async () => - new Promise(resolve => { - setTimeout(() => resolve({...tapped['chat.newmessage'], userInteraction: true}), 50) - }) - const details = pushListener.getStartupDetailsFromInitialPush() - await jest.advanceTimersByTimeAsync(50) - await expect(details).resolves.toEqual({startupConversation: convID, startupPushPayload: 'payload'}) - }) + unsub() +}) - test('startup does not wait forever on native, and a tap that lands later still navigates', async () => { - const {pushListener} = load() - getInitialNotification = async () => - new Promise(resolve => { - setTimeout(() => resolve({...tapped['chat.newmessage'], userInteraction: true}), 60_000) - }) - const details = pushListener.getStartupDetailsFromInitialPush() - await jest.advanceTimersByTimeAsync(10_000) - await expect(details).resolves.toBeUndefined() - expect(emitDeepLink).not.toHaveBeenCalled() +test('unsubscribing removes the listener', () => { + const unsub = subscribePushTaps() + unsub() - await jest.advanceTimersByTimeAsync(50_000) - await flush() - expect(emitDeepLink).toHaveBeenCalledWith(`keybase://convid/${convID}`, {targetUid: undefined}) - }) + expect(mockCalls.at(-1)).toBe('remove') }) diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index b2d982270e5b..a4fab529c7ea 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -1,272 +1,34 @@ 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, enqueuePushTap} from '@/router-v2/deep-link-emitter' +import {subscribeIntentAccountSwitch} from '@/router-v2/account-link-switch' import { getRegistrationToken, setApplicationIconBadgeNumber, - onPushNotification, + onPushTap, onPushToken, onShareData, - getInitialNotification, - pushListenerRegistered, removeAllPendingNotificationRequests, + takePushTap, } from 'react-native-kb' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' -import {useDaemonState} from '@/stores/daemon' 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 +// Native keeps a tapped notification's payload in a slot until it is taken. Subscribe first, then +// take: a tap from before the subscription is read now, a later one on its event, and the slot's +// clear-on-read keeps one tap from being taken twice. +export const subscribePushTaps = () => { + const take = () => { + const payload = takePushTap() + if (!payload) return + enqueuePushTap(payload) } -} -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', - userInteraction, - } - : undefined - default: - { - const unk = data as any - if (typeof unk.message === 'string' && unk.message.startsWith('Your contact')) { - return { - type: 'settings.contacts', - userInteraction, - } - } - } - - 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 isTap = (notification: T.Push.PushNotification) => - 'userInteraction' in notification && notification.userInteraction - -// Native clears the initial notification when it is read, so a read that loses a race is a lost -// tap. Both platforms resolve it right away; the timeout only keeps a misbehaving native module -// from holding startup, and a tap that still shows up after it is handled like a live one. -const initialPushTimeoutMs = 3000 - -// The account startup opens in. On a cold start the read can finish before bootstrap names it, so -// wait for the bootstrap status: the router mounts only after the handshake has loaded it, so the -// wait never holds back the first screen. -const getStartupUid = async () => { - const {uid} = useCurrentUserState.getState() - if (uid) return uid - const loaded = useDaemonState.getState().bootstrapStatus - if (loaded) return loaded.uid - return new Promise(resolve => { - const unsub = useDaemonState.subscribe(s => { - if (!s.bootstrapStatus) return - unsub() - resolve(s.bootstrapStatus.uid) - }) - }) -} - -const getStartupDetailsFromInitialPush = async () => { - const initialPush = getInitialPush() - const timedOut = 'timedOut' as const - const notification = await Promise.race([ - initialPush, - timeoutPromise(initialPushTimeoutMs).then(() => timedOut), - ]) - if (notification === timedOut) { - logger.warn('[Push] initial notification read timed out') - initialPush - .then(n => { - if (n) { - usePushState.getState().dispatch.handlePush(n) - } - }) - .catch(() => {}) - return - } - // only a tap on a visible notification may pick where the app opens - if (!notification || !isTap(notification)) { - return - } - - if (notification.type === 'follow') { - if (notification.username) { - return {startupFollowUser: notification.username} - } - } else if (notification.type === 'chat.newmessage') { - if (notification.conversationIDKey) { - // A tap for another account can't open here: it would show that conversation under the - // wrong account. handlePush switches accounts, or keeps it pending until the account list - // lists that account, and replays it once the switch lands. - if (notification.forUid && notification.forUid !== (await getStartupUid())) { - usePushState.getState().dispatch.handlePush(notification) - return - } - return { - startupConversation: notification.conversationIDKey, - startupPushPayload: notification.unboxPayload, - } - } - } - - return + const sub = onPushTap(take) + take() + return () => sub.remove() } export const initPushListener = () => { @@ -319,66 +81,11 @@ export const initPushListener = () => { usePushState.getState().dispatch.initialPermissionsCheck() - // When current-user.uid changes, run pending push if it was for this account. - unsubs.push( - 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) - }) - ) - - unsubs.push( - 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) - }) - ) - - unsubs.push( - useConfigState.subscribe((s, old) => { - if (s.loggedIn === old.loggedIn) return - if (!s.loggedIn && !s.userSwitching) { - usePushState.getState().dispatch.clearPendingPushNotification() - } - }) - ) - - // 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) - } + // The switch subscriber goes first, so a tap taken right below already sees it. + unsubs.push(subscribeIntentAccountSwitch(), subscribePushTaps()) 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 - const pushSub = onPushNotification(onNotification) - unsubs.push(() => pushSub.remove()) - // iOS holds pushes that arrive before this; they are emitted once it's called - pushListenerRegistered() - + // Token and share listeners if (isIOS) { const tokenSub = onPushToken(token => { logger.debug('[PushToken] received token via onPushToken event: ', token) @@ -426,5 +133,3 @@ export const initPushListener = () => { return unsubs } - -export {getStartupDetailsFromInitialPush} 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 fc4129c4473a..000000000000 --- a/shared/constants/types/push.tsx +++ /dev/null @@ -1,54 +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' - userInteraction: boolean - } - | { - type: 'settings.contacts' - userInteraction: boolean - } diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index cd526cd4081f..e6665775d8b9 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -23,7 +23,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi var fsPaths: [String: String] = [:] private let lifecycle = AppLifecycleForwarder() private var locationWatcher: LocationWatcher? - private var lastNotificationResponseKey: String? var iph: ItemProviderHelper? private var startupLogFileHandle: FileHandle? private let logQueue = DispatchQueue(label: "kb.startup.log", qos: .utility) @@ -313,11 +312,8 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } 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 @@ -340,36 +336,41 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi completionHandler(.newData) log.info("Remote notification handle finished...") } - } else { - KbDeliverPushNotification(Self.pushPayload(notification, userInteraction: false)) + case "chat.readmessage": + Self.clearPendingNotificationsIfAllRead(notification) completionHandler(.newData) + default: + completionHandler(.noData) } } - private static func pushPayload(_ userInfo: [AnyHashable: Any], userInteraction: Bool) -> [String: Any] { - var payload = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) - payload["userInteraction"] = userInteraction - return payload - } - - // A tap that cold-starts the app can arrive both here (via the scene's - // connection options) and through userNotificationCenter(_:didReceive:), so - // deliver each response once. - func handleNotificationResponse(_ response: UNNotificationResponse) { - let notification = response.notification - let key = "\(notification.request.identifier)|\(notification.date.timeIntervalSince1970)" - guard key != lastNotificationResponseKey else { return } - lastNotificationResponseKey = key - KbDeliverPushNotification(Self.pushPayload(notification.request.content.userInfo, userInteraction: true)) + // 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 JS. 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. public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - handleNotificationResponse(response) + let userInfo = response.notification.request.content.userInfo + let payload = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) + if JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) { + KbDeliverPushTap(json) + } completionHandler() } public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - KbDeliverPushNotification(Self.pushPayload(notification.request.content.userInfo, userInteraction: false)) completionHandler([]) } diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift index fec33c493d06..59b34b40f2f1 100644 --- a/shared/ios/Keybase/SceneDelegate.swift +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -11,9 +11,6 @@ class SceneDelegate: ExpoAppSceneDelegate { super.scene(scene, willConnectTo: session, options: connectionOptions) guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } - if let response = connectionOptions.notificationResponse { - appDelegate.handleNotificationResponse(response) - } guard let window = self.window else { return } appDelegate.didStartReactNative(in: window) } 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..cfd2b598f5f1 --- /dev/null +++ b/shared/router-v2/account-link-switch.test.ts @@ -0,0 +1,131 @@ +/// +import RPCError from '@/util/rpcerror' +import {resetAllStores} from '@/util/zustand' +import {subscribeIntentAccountSwitch} from './account-link-switch' +import {enqueuePushTap, 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] + +const tapFor = (uid: string) => enqueuePushTap(`{"type":"chat.newmessage","convID":"0000ab","uid":"${uid}"}`) + +let login = jest.fn() +let unsub: (() => void) | undefined + +const setAccounts = (configuredAccounts: typeof allAccounts) => { + useConfigState.setState({configuredAccounts}) +} + +beforeEach(() => { + login = jest.fn() + // navigation-intents' resetState deliberately keeps account-targeted intents. + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) + 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 + resetAllStores() +}) + +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() +}) + +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('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..b982889a8578 --- /dev/null +++ b/shared/router-v2/account-link-switch.tsx @@ -0,0 +1,62 @@ +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 enqueuePushTap sets targetUid, so no link +// another app opens can switch accounts. +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..ea3bc56cbdbc 100644 --- a/shared/router-v2/deep-link-emitter.test.ts +++ b/shared/router-v2/deep-link-emitter.test.ts @@ -1,6 +1,6 @@ /// import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {emitDeepLink, setInitialURLOnce} from './deep-link-emitter' +import {emitDeepLink, enqueuePushTap, pushTapTarget, setInitialURLOnce} from './deep-link-emitter' const resetNavigationIntents = () => { const {intent, dispatch} = useNavigationIntentsState.getState() @@ -54,3 +54,89 @@ test('removes a queued deep link when the initial URL handles it', () => { expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) + +describe('pushTapTarget', () => { + const cases: Array<[string, string, ReturnType]> = [ + [ + 'chat with account', + '{"type":"chat.newmessage","convID":"0000ab","uid":"u1"}', + {targetUid: 'u1', url: 'keybase://convid/0000ab'}, + ], + [ + 'chat without account', + '{"type":"chat.newmessage","convID":"0000ab"}', + {url: 'keybase://convid/0000ab'}, + ], + ['chat without conversation', '{"type":"chat.newmessage"}', undefined], + [ + 'apns chat with numbers and aps', + '{"type":"chat.newmessage","convID":"0000ab","uid":"u1","t":1,"aps":{"alert":{"body":"hi"}}}', + {targetUid: 'u1', url: 'keybase://convid/0000ab'}, + ], + [ + 'a numeric convID becomes a string', + '{"type":"chat.newmessage","convID":1234}', + {url: 'keybase://convid/1234'}, + ], + [ + 'the uid is kept verbatim', + '{"type":"chat.newmessage","convID":"0000ab","uid":"u 1&x"}', + {targetUid: 'u 1&x', url: 'keybase://convid/0000ab'}, + ], + [ + 'follow with uid', + '{"type":"follow","username":"testuser","uid":"u1"}', + {targetUid: 'u1', url: 'keybase://profile/show/testuser'}, + ], + [ + 'follow with targetUID', + '{"type":"follow","username":"testuser","targetUID":"u2"}', + {targetUid: 'u2', url: 'keybase://profile/show/testuser'}, + ], + ['follow without username', '{"type":"follow","uid":"u1"}', undefined], + ['new device', '{"type":"device.new","uid":"u1","device_id":"d1"}', {targetUid: 'u1', url: 'keybase://devices'}], + ['revoked device without account', '{"type":"device.revoked","device_id":"d1"}', undefined], + ['contacts joined', '{"message":"Your contact testuser joined Keybase"}', {url: 'keybase://tabs.peopleTab'}], + ['read receipt', '{"type":"chat.readmessage","b":0,"message":"Your contact x"}', undefined], + ['silent chat', '{"type":"chat.newmessageSilent_2","c":"0000ab"}', undefined], + ['extension', '{"type":"chat.extension","convID":"0000ab"}', undefined], + ['autoreset', '{"type":"autoreset","uid":"u1"}', undefined], + ['failed pending', '{"type":"chat.failedpending","convID":"0000ab","uid":""}', undefined], + ['an unknown type opens nothing', '{"type":"something.new","uid":"u1"}', undefined], + ['not json', 'not json', undefined], + ['json that is not an object', '"just a string"', undefined], + ] + + test.each(cases)('%s', (_name, payload, want) => { + expect(pushTapTarget(payload)).toEqual(want) + }) +}) + +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', () => { + enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"uid-other"}') + + 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') + enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"uid-other"}') + + expect(useNavigationIntentsState.getState().intent?.targetUid).toBe('uid-other') +}) + +test('a tap with nothing to open queues nothing', () => { + enqueuePushTap('{"type":"autoreset","uid":"uid-other"}') + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index e3a39b62f4f2..5708ca5d75ca 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -1,7 +1,5 @@ -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 @@ -66,8 +64,76 @@ 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 enqueuePushTap 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 ---- + +// Where a tap on a notification with this payload opens, or undefined when the +// tap only opens the app. `targetUid` names the account the notification is for. +export const pushTapTarget = (payload: string): {url: string; targetUid?: string} | undefined => { + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const fields = parsed as Record + const get = (key: string): string => { + const value = fields[key] + if (typeof value === 'string') return value + if (typeof value === 'number') return String(value) + return '' + } + const forAccount = (url: string, uid: string) => (uid ? {targetUid: uid, url} : {url}) + + switch (get('type')) { + case 'chat.newmessage': { + const convID = get('convID') + return convID ? forAccount(`keybase://convid/${encodeURIComponent(convID)}`, get('uid')) : undefined + } + case 'follow': { + const username = get('username') + return username + ? forAccount( + `keybase://profile/show/${encodeURIComponent(username)}`, + get('uid') || get('targetUID') + ) + : undefined + } + case 'device.new': + case 'device.revoked': { + const uid = get('uid') + return uid ? forAccount('keybase://devices', uid) : undefined + } + // Nothing to open: these are handled natively and in Go. + case 'chat.readmessage': + case 'chat.newmessageSilent_2': + case 'autoreset': + case 'chat.extension': + case 'chat.failedpending': + return undefined + default: + return get('message').startsWith('Your contact') ? {url: 'keybase://tabs.peopleTab'} : undefined + } +} + +// For payloads from native's notification-tap channel only (see +// constants/init/push-listener.native). A targetUid marks an intent as a tap, +// and nothing else can set one, so no link another app opens can switch accounts. +export const enqueuePushTap = (payload: string) => { + const target = pushTapTarget(payload) + if (!target) { + logger.info('[PushTap] took a tap with nothing to open') + return + } + logger.info('[PushTap] took a tap link:', target.url) + useNavigationIntentsState.getState().dispatch.enqueue(target.url, {targetUid: target.targetUid}) } diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index 4223f19e0926..aabc92f8b28d 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -3,7 +3,7 @@ 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, enqueuePushTap} from './deep-link-emitter' import {subscribeNavigationIntents} from './linking' const setCurrentUser = (uid: string) => { @@ -139,7 +139,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'}) + enqueuePushTap('{"type":"chat.newmessage","convID":"switch-target-conversation","uid":"target-uid"}') 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..1e59951e051a 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 {enqueuePushTap} from './deep-link-emitter' const setCurrentUser = (uid: string) => { useCurrentUserState.getState().dispatch.setBootstrap({ @@ -20,7 +21,6 @@ const setCurrentUser = (uid: string) => { type Startup = { conversation: T.Chat.ConversationIDKey conversationUid?: string - followUser: string link: string tab?: Tabs.Tab } @@ -31,7 +31,6 @@ const setStartup = (st: Partial) => { useConfigState.setState({ startup: { conversation: T.Chat.noConversationIDKey, - followUser: '', link: '', loaded: true, ...st, @@ -53,6 +52,9 @@ beforeEach(() => { afterEach(() => { handleAppLink.mockReset() + // resetAllStores deliberately keeps account-targeted intents; drop them here. + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) resetAllStores() }) @@ -93,16 +95,20 @@ 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'}) + enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"current-uid"}') - 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('a cold tap for another account opens saved state and waits for the switch', async () => { + setStartup({conversation: 'conv-1'}) + enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"other-uid"}') 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 () => { diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts index cb61c28fc083..95c5cde87432 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -2,7 +2,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {emitDeepLink} from './deep-link-emitter' +import {emitDeepLink, enqueuePushTap} from './deep-link-emitter' import {subscribeNavigationIntents} from './linking' const setCurrentUser = (uid: string) => { @@ -64,7 +64,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'}) + enqueuePushTap('{"type":"chat.newmessage","convID":"target-account-conversation","uid":"target-uid"}') expect(listener).not.toHaveBeenCalled() setCurrentUser('target-uid') @@ -84,7 +84,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'}) + enqueuePushTap('{"type":"chat.newmessage","convID":"account-switch-conversation","uid":"current-uid"}') expect(listener).not.toHaveBeenCalled() useConfigState.getState().dispatch.setUserSwitching(false) @@ -100,9 +100,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', - }) + enqueuePushTap('{"type":"chat.newmessage","convID":"replacement-router-conversation","uid":"target-uid"}') setCurrentUser('target-uid') // The bootstrap UID can change before React commits the keyed router remount. diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 6f3442f00728..2d21c405586f 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -1,4 +1,5 @@ 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' @@ -247,6 +248,14 @@ const customGetStateFromPath = ( // ---- Linking config ---- +// Known URLs become launch state; the rest open imperatively once the router is up. +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 +264,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 +277,13 @@ 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. + const {intent} = useNavigationIntentsState.getState() + if (intent && (!intent.targetUid || intent.targetUid === currentUid)) { + return openInitialLink(intent.url, handleAppLink) + } + const pushState = usePushState.getState() const showMonster = !pushState.justSignedUp && pushState.showPushPrompt && !pushState.hasPermissions @@ -284,11 +300,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 +312,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 +338,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/stores/config.tsx b/shared/stores/config.tsx index 525bd0239cf7..0b716f1be09b 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -45,7 +45,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 } @@ -83,7 +82,6 @@ const initialStore: Store = { revokedTrigger: 0, startup: { conversation: noConversationIDKey, - followUser: '', link: '', loaded: false, }, @@ -569,8 +567,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/push.tsx b/shared/stores/push.tsx index 5c6151f506ab..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,9 +19,7 @@ type Store = { type State = Store & { dispatch: { checkPermissions: () => Promise - clearPendingPushNotification: () => void deleteTokenForLogout: () => Promise - handlePush: (notification: T.Push.PushNotification) => void initialPermissionsCheck: () => void rejectPermissions: () => void requestPermissions: () => void @@ -34,7 +29,7 @@ type State = Store & { } } 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' @@ -50,7 +45,6 @@ const desktopInitialStore: Store = { const mobileInitialStore: Store = { hasPermissions: true, justSignedUp: false, - pendingPushNotification: undefined, showPushPrompt: false, token: '', } @@ -63,9 +57,7 @@ export const usePushState = Z.createZustand('push', (set, get) => { checkPermissions: async () => { return Promise.resolve(false) }, - clearPendingPushNotification: () => {}, deleteTokenForLogout: async () => {}, - handlePush: () => {}, initialPermissionsCheck: () => {}, rejectPermissions: () => {}, requestPermissions: () => {}, @@ -108,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() @@ -166,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 @@ -190,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': - if (notification.userInteraction) { - const {conversationIDKey} = notification - emitDeepLink(`keybase://convid/${conversationIDKey}`, navigationIntentOptions) - } - break - case 'settings.contacts': - if (notification.userInteraction && 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() @@ -354,14 +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, - })) + set(s => ({...initialStore, dispatch: s.dispatch})) }, setPushToken: (token: string) => { set(s => { @@ -424,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/tests/config.test.ts b/shared/stores/tests/config.test.ts index 1df0b762284f..2a870b66f21a 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -16,7 +16,6 @@ const resetConfigState = () => { }, startup: { conversation: noConversationIDKey, - followUser: '', link: '', loaded: false, }, @@ -38,20 +37,17 @@ test('setStartupDetails only records the first startup payload', () => { dispatch.setStartupDetails({ conversation: 'first-convo' as any, - followUser: 'alice', link: 'keybase://first', tab: undefined, }) dispatch.setStartupDetails({ conversation: 'second-convo' as any, - followUser: 'bob', link: 'keybase://second', tab: undefined, }) expect(useConfigState.getState().startup).toEqual({ conversation: 'first-convo', - followUser: 'alice', link: 'keybase://first', loaded: true, tab: undefined, 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/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts index 1787dcee596f..2aa0657a74c1 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -64,9 +64,10 @@ const onProfile = (s: Awaited>['screen']) => // Log lines these flows rely on: // - Metro (JS): "[Startup] loadStartupDetails: Linking.getInitialURL returned in ms: " for -// a cold deep link; "[onNotification]: " for each push JS receives, whose payload -// carries native's "userInteraction"; "[Push] handleLoudMessage: ignore non userInteraction" -// when JS declines to navigate for an untapped push. +// 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', () => { @@ -95,6 +96,24 @@ describe('app lifecycle: deep links', () => { 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', () => { @@ -109,8 +128,9 @@ describe('app lifecycle: push notifications', () => { type: 'chat.newmessage', uid, }) - // The payload JS logs for a push, found by its unique body. - const jsPushes = (lines: Array, body: string) => findLines(lines, /\[onNotification\]/).filter(l => l.includes(body)) + 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() @@ -128,14 +148,8 @@ describe('app lifecycle: push notifications', () => { const body = `e2e-push-foreground-${Date.now()}` sendPush(pushFor(body)) - const [delivered] = await waitForLinesInOrder('JS to receive the push', () => jsPushes(metroClientLogSince(metroMark), body), [ - /\[onNotification\]/, - ]) - expect(delivered).toContain('"userInteraction": false') - await waitForLinesInOrder('JS to decline to navigate', () => metroClientLogSince(metroMark), [ - /\[Push\] handleLoudMessage: ignore non userInteraction/, - ]) - await browser.pause(3000) + await browser.pause(5000) + expect(tapLines(metroClientLogSince(metroMark))).toEqual([]) expect((await appSnapshot()).screen?.name).not.toBe('chatConversation') }) @@ -157,7 +171,7 @@ describe('app lifecycle: push notifications', () => { await waitForAppState('active') await browser.pause(3000) expect((await appSnapshot()).screen?.name).not.toBe('chatConversation') - expect(jsPushes(metroClientLogSince(metroMark), body)).toEqual([]) + expect(tapLines(metroClientLogSince(metroMark))).toEqual([]) }) it('tapping a push shown in the background opens its conversation', async () => { @@ -173,8 +187,10 @@ describe('app lifecycle: push notifications', () => { await waitForAppState('active') await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) - const [delivered] = jsPushes(metroClientLogSince(metroMark), body) - expect(delivered).toContain('"userInteraction": true') + 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 () => { @@ -187,13 +203,12 @@ describe('app lifecycle: push notifications', () => { await waitForAppState('active', undefined, 90000) await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID) - // The tap reaches JS as the initial notification and picks the startup conversation. + // The tap reaches JS through the native tap slot and picks the startup route. const lines = metroClientLogSince(metroMark) - const startup = findLines(lines, /initialState: push /) - expect(startup).toEqual([expect.stringContaining(`initialState: push ${convID}`)]) + 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), /\[Push\] handleLoudMessage: ignore non userInteraction/)).toEqual([]) + expect(findLines(metroClientLogSince(metroMark), /\[AccountLink\]/)).toEqual([]) }) }) 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 < Date: Thu, 17 Sep 2026 15:51:22 -0400 Subject: [PATCH 055/127] chore(android): drop dead imports from the push files --- .../app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt | 3 --- .../ossifrage/KeybasePushNotificationListenerService.kt | 2 -- .../app/src/main/java/io/keybase/ossifrage/MainActivity.kt | 1 - 3 files changed, 6 deletions(-) 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 e91a1744c34c..b2b2b66f8894 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 @@ -23,7 +21,6 @@ import keybase.ChatNotification import org.json.JSONObject import keybase.PushNotifier import java.io.BufferedInputStream -import java.io.IOException import java.net.HttpURLConnection import java.net.URL 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 00ca83b89ab2..8b7f7d71a46b 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,8 +5,6 @@ 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 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 737ed89cfac6..d3976e6f190a 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 @@ -17,7 +17,6 @@ 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 From 4b524f279d1fd614d86558c6c89f427f31774dad Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 16:11:07 -0400 Subject: [PATCH 056/127] fix(push): test the tap intent's identity and cover the devices link with the linking config The payload the tap Intent carries is now built by a pure helper next to the codec, so the Bundle-to-payload step and the distinctness two same-second notifications depend on are both under test. It also carries only the fields the tap target needs, keeping the rest of a push out of a data URI that dumpsys prints. keybase://devices builds launch state like every other known link instead of riding the imperative fallback's timer, a queued intent has to be within the intent lifetime to pick the startup route, a malformed tap URI can no longer keep the app from opening, and a tap whose payload won't serialize is logged. --- .../io/keybase/ossifrage/KBPushNotifier.kt | 30 +++----- .../io/keybase/ossifrage/PushTapActivity.kt | 6 +- .../java/io/keybase/ossifrage/PushTapData.kt | 58 +++++++++++++- .../io/keybase/ossifrage/PushTapDataTest.kt | 77 +++++++++++++++---- shared/ios/Keybase/AppDelegate.swift | 2 + shared/router-v2/linking-initial-url.test.ts | 9 +++ shared/router-v2/linking.test.ts | 57 +++++++++++++- shared/router-v2/linking.tsx | 17 +++- 8 files changed, 216 insertions(+), 40 deletions(-) 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 b2b2b66f8894..6ffcd5ed00f0 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 @@ -18,21 +18,11 @@ import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.graphics.drawable.IconCompat import keybase.ChatNotification -import org.json.JSONObject import keybase.PushNotifier import java.io.BufferedInputStream import java.net.HttpURLConnection import java.net.URL -internal fun bundleJSON(bundle: Bundle): String { - val json = JSONObject() - for (key in bundle.keySet()) { - @Suppress("DEPRECATION") - json.put(key, JSONObject.wrap(bundle.get(key))) - } - return json.toString() -} - class KBPushNotifier internal constructor(private val context: Context, private val bundle: Bundle) : PushNotifier { private var convMsgCache: SmallMsgRingBuffer? = null private fun buildStyle(person: Person): NotificationCompat.MessagingStyle { @@ -45,15 +35,17 @@ class KBPushNotifier internal constructor(private val context: Context, private this.convMsgCache = convMsgCache } - // A tap goes through PushTapActivity, which hands the push's payload to JS. The payload is - // the Intent's data so each notification gets its own PendingIntent (see PushTapData), and - // immutable so whoever holds this PendingIntent can't substitute another payload. - private fun buildPendingIntent(bundle: Bundle): PendingIntent { - val intent = Intent(context, PushTapActivity::class.java) - intent.setData(Uri.parse(PushTapData.encode(bundleJSON(bundle)))) - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - } + // A tap goes through PushTapActivity, which hands the push's payload to JS. The payload must + // be the Intent's data for each notification to get its own PendingIntent (see PushTapData), + // so the Intent is never built without it. Immutable, so whoever holds this PendingIntent + // can't substitute another payload. + private fun tapIntent(bundle: Bundle): Intent = + Intent(context, PushTapActivity::class.java) + .setData(Uri.parse(PushTapData.tapIntentData(bundleTapFields(bundle)))) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + 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 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 index 5b365c420539..6ad4fe3e9a57 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt @@ -11,7 +11,11 @@ import com.reactnativekb.KbModule class PushTapActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - PushTapData.decode(intent.dataString).takeIf { it.isNotEmpty() }?.let { KbModule.deliverPushTap(it) } + // A malformed data URI must still open the app, so a failed decode can't escape here. + runCatching { PushTapData.decode(intent.dataString) } + .getOrDefault("") + .takeIf { it.isNotEmpty() } + ?.let { KbModule.deliverPushTap(it) } startActivity( Intent(this, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt index 245da4515ffe..2b8fa8c3d3ba 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt @@ -1,15 +1,44 @@ package io.keybase.ossifrage +import android.os.Bundle import java.net.URLDecoder import java.net.URLEncoder -// A tapped notification's payload rides in the tap Intent's data URI rather than only in an -// extra. PendingIntent.getActivity hands back an existing PendingIntent for any Intent that -// filterEquals the new one, and extras are not part of filterEquals, so without distinct data -// two notifications built with the same request code would share the first one's target. +// A push's Bundle reduced to the payload a tap carries. Kept beside PushTapData so both halves +// of the encode have one home. +internal fun bundleTapFields(bundle: Bundle): Map = + PushTapData.tapFields { key -> + @Suppress("DEPRECATION") + bundle.get(key)?.toString() + } + object PushTapData { private const val SCHEME = "kbpushtap:" + // The only fields pushTapTarget (shared/router-v2/deep-link-emitter.tsx) reads. The rest of a + // push payload stays out of the tap Intent: its data URI is printed by `dumpsys activity`, + // where an extra was not. + private val TAP_FIELDS = listOf("type", "convID", "uid", "targetUID", "username") + + // pushTapTarget only tests this prefix, so the rest of a contact message never leaves the app. + private const val CONTACT_PREFIX = "Your contact" + + fun tapFields(read: (String) -> String?): Map { + val fields = LinkedHashMap() + for (key in TAP_FIELDS) { + read(key)?.takeIf { it.isNotEmpty() }?.let { fields[key] = it } + } + if (read("message")?.startsWith(CONTACT_PREFIX) == true) { + fields["message"] = CONTACT_PREFIX + } + return fields + } + + // The tap Intent's data. Two notifications opening different targets must produce different + // data: PendingIntent.getActivity hands back an existing PendingIntent for any Intent that + // filterEquals the new one, and extras are not part of filterEquals. + fun tapIntentData(fields: Map): String = encode(json(fields)) + fun encode(payloadJSON: String): String = SCHEME + URLEncoder.encode(payloadJSON, "UTF-8") fun decode(dataString: String?): String = @@ -18,4 +47,25 @@ object PushTapData { } else { "" } + + // Hand-rolled rather than org.json: these values are all plain strings, the field order stays + // deterministic, and org.json is an android.jar stub that throws in JVM unit tests. + private fun json(fields: Map): String = + fields.entries.joinToString(",", "{", "}") { quoted(it.key) + ":" + quoted(it.value) } + + private fun quoted(value: String): String { + val out = StringBuilder("\"") + for (c in value) { + when { + c == '"' -> out.append("\\\"") + c == '\\' -> out.append("\\\\") + c == '\n' -> out.append("\\n") + c == '\r' -> out.append("\\r") + c == '\t' -> out.append("\\t") + c < ' ' -> out.append(String.format("\\u%04x", c.code)) + else -> out.append(c) + } + } + return out.append('"').toString() + } } diff --git a/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt b/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt index 7513be92b469..307993bc3796 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt @@ -5,29 +5,80 @@ import org.junit.Assert.assertNotEquals import org.junit.Test class PushTapDataTest { - @Test - fun aPayloadRoundTrips() { - val payload = """{"type":"chat.newmessage","convID":"0000ab","uid":"u 1&x/+","message":"hi ünïcode"}""" - assertEquals(payload, PushTapData.decode(PushTapData.encode(payload))) - } + // A chat push's data fields, as the FCM Bundle carries them. + private fun chatPush(convID: String, messageID: String) = + mapOf( + "type" to "chat.newmessage", + "convID" to convID, + "uid" to "u1", + "d" to messageID, + "m" to "encrypted payload", + "t" to "1", + "badge" to "3" + ) + + private fun fieldsOf(push: Map) = PushTapData.tapFields { push[it] } - // W7: buildPendingIntent reused one request code per second, and extras are not part of - // filterEquals, so two notifications built in the same second shared a PendingIntent and - // the second one's tap opened the first one's target. Distinct data makes them non-equal. + private fun dataFor(push: Map) = PushTapData.tapIntentData(fieldsOf(push)) + + // W7: buildPendingIntent reused one PendingIntent per second, and extras are not part of + // filterEquals, so two notifications built together shared one and the second one's tap + // opened the first one's conversation. The payload rides in the Intent's data instead. @Test fun twoNotificationsInTheSameSecondGetDistinctTapTargets() { - val first = PushTapData.encode("""{"type":"chat.newmessage","convID":"conv-a","uid":"u1"}""") - val second = PushTapData.encode("""{"type":"chat.newmessage","convID":"conv-b","uid":"u1"}""") + val first = dataFor(chatPush("conv-a", "1")) + val second = dataFor(chatPush("conv-b", "2")) assertNotEquals(first, second) assertEquals("""{"type":"chat.newmessage","convID":"conv-a","uid":"u1"}""", PushTapData.decode(first)) assertEquals("""{"type":"chat.newmessage","convID":"conv-b","uid":"u1"}""", PushTapData.decode(second)) } + // Deliberate: both notifications open the same conversation, so sharing a PendingIntent is + // correct. Only the target has to be distinct, not the message. + @Test + fun twoMessagesInOneConversationShareOneTapTarget() { + assertEquals(dataFor(chatPush("conv-a", "1")), dataFor(chatPush("conv-a", "2"))) + } + + @Test + fun onlyTheFieldsATapNeedsAreEncoded() { + assertEquals( + mapOf("type" to "chat.newmessage", "convID" to "conv-a", "uid" to "u1"), + fieldsOf(chatPush("conv-a", "1")) + ) + assertEquals( + mapOf("type" to "follow", "targetUID" to "u2", "username" to "testuser"), + PushTapData.tapFields( + mapOf("type" to "follow", "targetUID" to "u2", "username" to "testuser", "message" to "x")::get + ) + ) + } + + // pushTapTarget only tests the prefix, so the contact's name never reaches the data URI. + @Test + fun aContactMessageIsTruncatedToItsPrefix() { + val fields = PushTapData.tapFields(mapOf("message" to "Your contact testuser joined Keybase")::get) + + assertEquals(mapOf("message" to "Your contact"), fields) + } + @Test - fun twoNotificationsWithTheSamePayloadShareOneTapTarget() { - val payload = """{"type":"follow","username":"testuser"}""" - assertEquals(PushTapData.encode(payload), PushTapData.encode(payload)) + fun anEmptyFieldIsLeftOut() { + assertEquals( + mapOf("type" to "device.new"), + PushTapData.tapFields(mapOf("type" to "device.new", "uid" to "", "username" to "")::get) + ) + } + + @Test + fun aPayloadRoundTripsThroughTheDataUri() { + val fields = mapOf("type" to "follow", "username" to """a"b\c ü+%/&""") + + assertEquals( + """{"type":"follow","username":"a\"b\\c ü+%/&"}""", + PushTapData.decode(PushTapData.tapIntentData(fields)) + ) } @Test diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index e6665775d8b9..292e90f831d8 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -366,6 +366,8 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) { KbDeliverPushTap(json) + } else { + log.error("Dropped a notification tap: its payload could not be serialized") } completionHandler() } diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts index 1e59951e051a..8e7ebc291bba 100644 --- a/shared/router-v2/linking-initial-url.test.ts +++ b/shared/router-v2/linking-initial-url.test.ts @@ -163,3 +163,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'}) + enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"current-uid"}') + 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.test.ts b/shared/router-v2/linking.test.ts index 95c5cde87432..d2bfcf67534b 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -3,7 +3,9 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' import {emitDeepLink, enqueuePushTap} from './deep-link-emitter' -import {subscribeNavigationIntents} from './linking' +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({ @@ -146,3 +148,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 2d21c405586f..f5eb9e3909ea 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -1,3 +1,4 @@ +import * as Settings from '@/constants/settings' import * as Tabs from '@/constants/tabs' import logger from '@/logger' import {isSplit} from '@/constants/chat/layout' @@ -184,6 +185,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': { @@ -278,9 +286,14 @@ export const createLinkingConfig = ( } // 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. + // 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 && (!intent.targetUid || intent.targetUid === currentUid)) { + if ( + intent && + Date.now() - intent.createdAt <= navigationIntentLifetimeMs && + (!intent.targetUid || intent.targetUid === currentUid) + ) { return openInitialLink(intent.url, handleAppLink) } From 8ad9fd6e3969083642758f91912a4727b6d8edb4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 16:57:55 -0400 Subject: [PATCH 057/127] feat(protocol): version the bootstrap status and the session and http server notifications GlobalContext hands out a monotonic state version. NotifyRouter stamps each change it announces (login, logout, http server address) with the next one, and GetBootstrapStatus reports the version it read before building the status. A client can then tell whether its status or a notification it already applied describes the newer state, instead of guessing from local timing. The invariant this rests on: the change is observable before the notify call. The kbhttp loop publishes its status before notifying, logout notifies after ConfigReload, and the login engines notify after their run completes. --- go/client/cmd_show_notifications.go | 2 +- go/kbfs/libkbfs/init_test.go | 2 +- go/kbfs/libkbfs/keybase_daemon_rpc.go | 2 +- go/kbfs/libkbfs/keybase_daemon_rpc_test.go | 2 +- go/kbfs/libkbfs/keybase_service_base.go | 2 +- go/libkb/globals.go | 11 +++++++ go/libkb/notify_router.go | 8 +++-- go/libkb/state_version_test.go | 38 ++++++++++++++++++++++ go/protocol/keybase1/config.go | 2 ++ go/protocol/keybase1/notify_service.go | 10 +++--- go/protocol/keybase1/notify_session.go | 16 ++++++--- go/service/config.go | 5 +++ go/systests/user_test.go | 2 +- protocol/avdl/keybase1/config.avdl | 1 + protocol/avdl/keybase1/notify_service.avdl | 2 +- protocol/avdl/keybase1/notify_session.avdl | 4 +-- protocol/json/keybase1/config.json | 4 +++ protocol/json/keybase1/notify_service.json | 4 +++ protocol/json/keybase1/notify_session.json | 11 ++++++- shared/constants/rpc/rpc-gen.tsx | 8 ++--- 20 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 go/libkb/state_version_test.go diff --git a/go/client/cmd_show_notifications.go b/go/client/cmd_show_notifications.go index fc27f3ff2a42..b4921c79d9e9 100644 --- a/go/client/cmd_show_notifications.go +++ b/go/client/cmd_show_notifications.go @@ -99,7 +99,7 @@ func (d *notificationDisplay) printf(fmt string, args ...any) error { return err } -func (d *notificationDisplay) LoggedOut(_ context.Context) error { +func (d *notificationDisplay) LoggedOut(_ context.Context, _ int64) error { return d.printf("Logged out\n") } diff --git a/go/kbfs/libkbfs/init_test.go b/go/kbfs/libkbfs/init_test.go index 3ba53d7949dd..4ea226d9a115 100644 --- a/go/kbfs/libkbfs/init_test.go +++ b/go/kbfs/libkbfs/init_test.go @@ -155,7 +155,7 @@ func (c *initOrderCn) callIntoKBFS() { require.NoError(t, err) require.NoError(t, c.daemon.PaperKeyCached( ctx, keybase1.PaperKeyCachedArg{Uid: session.UID})) - require.NoError(t, c.daemon.LoggedOut(ctx)) + require.NoError(t, c.daemon.LoggedOut(ctx, 0)) // Until init is ready, requests get an error or wait. _, err = c.daemon.GetTLFCryptKeys(ctx, keybase1.TLFQuery{TlfName: "testuser"}) diff --git a/go/kbfs/libkbfs/keybase_daemon_rpc.go b/go/kbfs/libkbfs/keybase_daemon_rpc.go index f03592688725..bea31082b540 100644 --- a/go/kbfs/libkbfs/keybase_daemon_rpc.go +++ b/go/kbfs/libkbfs/keybase_daemon_rpc.go @@ -492,7 +492,7 @@ func (s *notifyServiceHandler) Shutdown(_ context.Context, code int) error { return nil } -func (s *notifyServiceHandler) HTTPSrvInfoUpdate(_ context.Context, info keybase1.HttpSrvInfo) error { +func (s *notifyServiceHandler) HTTPSrvInfoUpdate(_ context.Context, _ keybase1.HTTPSrvInfoUpdateArg) error { return nil } diff --git a/go/kbfs/libkbfs/keybase_daemon_rpc_test.go b/go/kbfs/libkbfs/keybase_daemon_rpc_test.go index 0dc390e5e60b..b5f2e502c1e3 100644 --- a/go/kbfs/libkbfs/keybase_daemon_rpc_test.go +++ b/go/kbfs/libkbfs/keybase_daemon_rpc_test.go @@ -251,7 +251,7 @@ func TestKeybaseDaemonSessionCache(t *testing.T) { testCurrentSession(t, client, c, session, expectCached) // Should invalidate cache. - err := c.LoggedOut(context.Background()) + err := c.LoggedOut(context.Background(), 0) require.NoError(t, err) // Should fill cache again. diff --git a/go/kbfs/libkbfs/keybase_service_base.go b/go/kbfs/libkbfs/keybase_service_base.go index b60a05f45104..3dcd2c74ff13 100644 --- a/go/kbfs/libkbfs/keybase_service_base.go +++ b/go/kbfs/libkbfs/keybase_service_base.go @@ -384,7 +384,7 @@ func (k *KeybaseServiceBase) LoggedIn(ctx context.Context, arg keybase1.LoggedIn } // LoggedOut implements keybase1.NotifySessionInterface. -func (k *KeybaseServiceBase) LoggedOut(ctx context.Context) error { +func (k *KeybaseServiceBase) LoggedOut(ctx context.Context, _ int64) error { k.log.CDebugf(ctx, "Current session logged out") k.setCachedCurrentSession(idutil.SessionInfo{}) if k.config != nil { diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 48b622bbd4cc..74f24f8f2fea 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -25,6 +25,7 @@ import ( "os" "runtime" "sync" + "sync/atomic" "time" "github.com/keybase/client/go/libkb/lifecycle" @@ -78,6 +79,7 @@ type GlobalContext struct { Identify3State *Identify3State // keep track of Identify3 sessions vidMu *sync.Mutex // protect VID RuntimeStats RuntimeStats // performance runtime stats + stateVersion atomic.Int64 // see StateVersion cacheMu *sync.RWMutex // protects all caches ProofCache *ProofCache // where to cache proof results @@ -328,6 +330,15 @@ func NewGlobalContextInit() *GlobalContext { return NewGlobalContext().Init() } +// StateVersion is the version of the last change a notification announced (the +// http server address, login, logout). The bootstrap status reads it before the +// state, so a client can tell whether the status or a notification is newer. +func (g *GlobalContext) StateVersion() int64 { return g.stateVersion.Load() } + +// NextStateVersion stamps a change about to be announced. Call it after the +// change is readable, so nothing carrying this version is still invisible. +func (g *GlobalContext) NextStateVersion() int64 { return g.stateVersion.Add(1) } + func (g *GlobalContext) SetService() { g.Service = true g.ConnectionManager = NewConnectionManager() diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index fcf789e563b2..cca473c045bb 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -406,6 +406,7 @@ func (n *NotifyRouter) HandleLogout(ctx context.Context) { } defer n.G().CTrace(ctx, "NotifyRouter#HandleLogout", nil)() ctx = CopyTagsToBackground(ctx) + version := n.G().NextStateVersion() // 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 @@ -417,7 +418,7 @@ func (n *NotifyRouter) HandleLogout(ctx context.Context) { // A send of a `LoggedOut` RPC _ = (keybase1.NotifySessionClient{ Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).LoggedOut(ctx) + }).LoggedOut(ctx, version) }() } desc := "" @@ -461,6 +462,7 @@ func (n *NotifyRouter) SendLogin(ctx context.Context, u string, signedUp bool) { 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) + version := n.G().NextStateVersion() n.cm.ApplyAll(func(id ConnectionID, xp rpc.Transporter) bool { // If the connection wants the `Session` notification type if n.getNotificationChannels(id).Session { @@ -472,6 +474,7 @@ func (n *NotifyRouter) SendLogin(ctx context.Context, u string, signedUp bool) { }).LoggedIn(ctx, keybase1.LoggedInArg{ Username: u, SignedUp: signedUp, + Version: version, }) }() } @@ -2823,12 +2826,13 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase if n == nil { return } + version := n.G().NextStateVersion() 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) + }).HTTPSrvInfoUpdate(ctx, keybase1.HTTPSrvInfoUpdateArg{Info: info, Version: version}) }() } return true diff --git a/go/libkb/state_version_test.go b/go/libkb/state_version_test.go new file mode 100644 index 000000000000..d29a07c06a19 --- /dev/null +++ b/go/libkb/state_version_test.go @@ -0,0 +1,38 @@ +// 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" +) + +// Every announced change gets its own version, and the version is readable +// through StateVersion by the time the notification is on its way out. A client +// compares the version on a bootstrap status against the versions on the +// notifications it got, so a change that stamped nothing would look older than a +// status read before it and be dropped. +func TestNotifyRouterStampsEachAnnouncedChange(t *testing.T) { + tc := SetupTest(t, "StateVersion", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + ctx := context.Background() + + require.EqualValues(t, 0, g.StateVersion(), "nothing announced yet") + + g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "token"}) + afterHTTP := g.StateVersion() + require.EqualValues(t, 1, afterHTTP) + + g.NotifyRouter.SendLogin(ctx, "testuser", false) + afterLogin := g.StateVersion() + require.Greater(t, afterLogin, afterHTTP) + + g.NotifyRouter.HandleLogout(ctx) + require.Greater(t, g.StateVersion(), afterLogin) +} diff --git a/go/protocol/keybase1/config.go b/go/protocol/keybase1/config.go index 093d5a2eaa99..5d5fc8bf52dc 100644 --- a/go/protocol/keybase1/config.go +++ b/go/protocol/keybase1/config.go @@ -667,6 +667,7 @@ type BootstrapStatus struct { Fullname FullName `codec:"fullname" json:"fullname"` UserReacjis UserReacjis `codec:"userReacjis" json:"userReacjis"` HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` + Version int64 `codec:"version" json:"version"` } func (o BootstrapStatus) DeepCopy() BootstrapStatus { @@ -686,6 +687,7 @@ func (o BootstrapStatus) DeepCopy() BootstrapStatus { tmp := x.DeepCopy() return &tmp })(o.HttpSrvInfo), + Version: o.Version, } } diff --git a/go/protocol/keybase1/notify_service.go b/go/protocol/keybase1/notify_service.go index c9eb27588491..ce70b7941479 100644 --- a/go/protocol/keybase1/notify_service.go +++ b/go/protocol/keybase1/notify_service.go @@ -23,7 +23,8 @@ func (o HttpSrvInfo) DeepCopy() HttpSrvInfo { } type HTTPSrvInfoUpdateArg struct { - Info HttpSrvInfo `codec:"info" json:"info"` + Info HttpSrvInfo `codec:"info" json:"info"` + Version int64 `codec:"version" json:"version"` } type HandleKeybaseLinkArg struct { @@ -36,7 +37,7 @@ type ShutdownArg struct { } type NotifyServiceInterface interface { - HTTPSrvInfoUpdate(context.Context, HttpSrvInfo) error + HTTPSrvInfoUpdate(context.Context, HTTPSrvInfoUpdateArg) error HandleKeybaseLink(context.Context, HandleKeybaseLinkArg) error Shutdown(context.Context, int) error } @@ -56,7 +57,7 @@ func NotifyServiceProtocol(i NotifyServiceInterface) rpc.Protocol { err = rpc.NewTypeError((*[1]HTTPSrvInfoUpdateArg)(nil), args) return } - err = i.HTTPSrvInfoUpdate(ctx, typedArgs[0].Info) + err = i.HTTPSrvInfoUpdate(ctx, typedArgs[0]) return }, }, @@ -98,8 +99,7 @@ type NotifyServiceClient struct { Cli rpc.GenericClient } -func (c NotifyServiceClient) HTTPSrvInfoUpdate(ctx context.Context, info HttpSrvInfo) (err error) { - __arg := HTTPSrvInfoUpdateArg{Info: info} +func (c NotifyServiceClient) HTTPSrvInfoUpdate(ctx context.Context, __arg HTTPSrvInfoUpdateArg) (err error) { err = c.Cli.Notify(ctx, "keybase.1.NotifyService.HTTPSrvInfoUpdate", []any{__arg}, 0*time.Millisecond) return } diff --git a/go/protocol/keybase1/notify_session.go b/go/protocol/keybase1/notify_session.go index 0aca0ca39fcc..7862b8e7ef9e 100644 --- a/go/protocol/keybase1/notify_session.go +++ b/go/protocol/keybase1/notify_session.go @@ -11,11 +11,13 @@ import ( ) type LoggedOutArg struct { + Version int64 `codec:"version" json:"version"` } type LoggedInArg struct { Username string `codec:"username" json:"username"` SignedUp bool `codec:"signedUp" json:"signedUp"` + Version int64 `codec:"version" json:"version"` } type ClientOutOfDateArg struct { @@ -25,7 +27,7 @@ type ClientOutOfDateArg struct { } type NotifySessionInterface interface { - LoggedOut(context.Context) error + LoggedOut(context.Context, int64) error LoggedIn(context.Context, LoggedInArg) error ClientOutOfDate(context.Context, ClientOutOfDateArg) error } @@ -40,7 +42,12 @@ func NotifySessionProtocol(i NotifySessionInterface) rpc.Protocol { return &ret }, Handler: func(ctx context.Context, args any) (ret any, err error) { - err = i.LoggedOut(ctx) + typedArgs, ok := args.(*[1]LoggedOutArg) + if !ok { + err = rpc.NewTypeError((*[1]LoggedOutArg)(nil), args) + return + } + err = i.LoggedOut(ctx, typedArgs[0].Version) return }, }, @@ -82,8 +89,9 @@ type NotifySessionClient struct { Cli rpc.GenericClient } -func (c NotifySessionClient) LoggedOut(ctx context.Context) (err error) { - err = c.Cli.Notify(ctx, "keybase.1.NotifySession.loggedOut", []any{LoggedOutArg{}}, 0*time.Millisecond) +func (c NotifySessionClient) LoggedOut(ctx context.Context, version int64) (err error) { + __arg := LoggedOutArg{Version: version} + err = c.Cli.Notify(ctx, "keybase.1.NotifySession.loggedOut", []any{__arg}, 0*time.Millisecond) return } diff --git a/go/service/config.go b/go/service/config.go index 902ca1f1967f..09f3f0008001 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -356,11 +356,16 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r // attempt (which can be slow: leveldb open/recovery, keychain reads) so // we don't report loggedIn=false while it is still in flight. h.svc.awaitInitialLoginAttempt(m, 30*time.Second) + // Read the version after that wait but before the state it describes. A login, + // logout or http server change that lands from here on stamps its notification + // with a newer version, so the client keeps the notification over this status. + version := h.G().StateVersion() eng := engine.NewBootstrap(h.G()) if err = engine.RunEngine2(m, eng); err != nil { return res, err } res = eng.Status() + res.Version = version m.Debug("GetBootstrapStatus: attempting to get HTTP server address") for range 40 { // wait at most 2 seconds info, infoErr := h.svc.httpSrv.Info() diff --git a/go/systests/user_test.go b/go/systests/user_test.go index 3c2c511730b4..20ebb6345aaf 100644 --- a/go/systests/user_test.go +++ b/go/systests/user_test.go @@ -247,7 +247,7 @@ func newNotifyHandler() *notifyHandler { } } -func (h *notifyHandler) LoggedOut(_ context.Context) error { +func (h *notifyHandler) LoggedOut(_ context.Context, _ int64) error { h.logoutCh <- struct{}{} return nil } diff --git a/protocol/avdl/keybase1/config.avdl b/protocol/avdl/keybase1/config.avdl index 8d434d8d31e5..d541a926744b 100644 --- a/protocol/avdl/keybase1/config.avdl +++ b/protocol/avdl/keybase1/config.avdl @@ -274,6 +274,7 @@ protocol config { FullName fullname; // current user's fullname UserReacjis userReacjis; // reacjis preferences for current logged in user union { null, HttpSrvInfo } httpSrvInfo; // info about the service http server + long version; // state version read before the rest of this status; compare against the versions on the login, logout and http server notifications } BootstrapStatus getBootstrapStatus(int sessionID); diff --git a/protocol/avdl/keybase1/notify_service.avdl b/protocol/avdl/keybase1/notify_service.avdl index f0bc1b003729..eaa875c5cc98 100644 --- a/protocol/avdl/keybase1/notify_service.avdl +++ b/protocol/avdl/keybase1/notify_service.avdl @@ -7,7 +7,7 @@ protocol NotifyService { string token; } @lint("ignore") - void HTTPSrvInfoUpdate(HttpSrvInfo info) oneway; + void HTTPSrvInfoUpdate(HttpSrvInfo info, long version) oneway; void handleKeybaseLink(string link, boolean deferred) oneway; diff --git a/protocol/avdl/keybase1/notify_session.avdl b/protocol/avdl/keybase1/notify_session.avdl index e9bc17cc3dfa..e5d8a5f43c5b 100644 --- a/protocol/avdl/keybase1/notify_session.avdl +++ b/protocol/avdl/keybase1/notify_session.avdl @@ -3,7 +3,7 @@ protocol NotifySession { @notify("") - void loggedOut(); - void loggedIn(string username, boolean signedUp); // signedUp if this is due to a signup + void loggedOut(long version); + void loggedIn(string username, boolean signedUp, long version); // signedUp if this is due to a signup void clientOutOfDate(string upgradeTo, string upgradeURI, string upgradeMsg); } diff --git a/protocol/json/keybase1/config.json b/protocol/json/keybase1/config.json index 1c4f6eb8df23..98feceee4d9b 100644 --- a/protocol/json/keybase1/config.json +++ b/protocol/json/keybase1/config.json @@ -738,6 +738,10 @@ "HttpSrvInfo" ], "name": "httpSrvInfo" + }, + { + "type": "long", + "name": "version" } ] }, diff --git a/protocol/json/keybase1/notify_service.json b/protocol/json/keybase1/notify_service.json index 967a5b2d2046..c61a8764e76a 100644 --- a/protocol/json/keybase1/notify_service.json +++ b/protocol/json/keybase1/notify_service.json @@ -28,6 +28,10 @@ { "name": "info", "type": "HttpSrvInfo" + }, + { + "name": "version", + "type": "long" } ], "response": null, diff --git a/protocol/json/keybase1/notify_session.json b/protocol/json/keybase1/notify_session.json index afd0e01b1cfa..098aeaf4efbd 100644 --- a/protocol/json/keybase1/notify_session.json +++ b/protocol/json/keybase1/notify_session.json @@ -4,7 +4,12 @@ "types": [], "messages": { "loggedOut": { - "request": [], + "request": [ + { + "name": "version", + "type": "long" + } + ], "response": null, "notify": "" }, @@ -17,6 +22,10 @@ { "name": "signedUp", "type": "boolean" + }, + { + "name": "version", + "type": "long" } ], "response": null diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index c1792d8d0bad..0342e69aac99 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -72,7 +72,7 @@ export type MessageTypes = { outParam: void, }, 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - inParam: {readonly info: HttpSrvInfo}, + inParam: {readonly info: HttpSrvInfo,readonly version: number}, outParam: void, }, 'keybase.1.NotifyService.handleKeybaseLink': { @@ -88,11 +88,11 @@ export type MessageTypes = { outParam: void, }, 'keybase.1.NotifySession.loggedIn': { - inParam: {readonly username: string,readonly signedUp: boolean}, + inParam: {readonly username: string,readonly signedUp: boolean,readonly version: number}, outParam: void, }, 'keybase.1.NotifySession.loggedOut': { - inParam: undefined, + inParam: {readonly version: number}, outParam: void, }, 'keybase.1.NotifySimpleFS.simpleFSArchiveStatusChanged': { @@ -2527,7 +2527,7 @@ export type BlockQuotaInfo = {readonly folders?: ReadonlyArray export type BlockRefNonce = string | null export type BlockReference = {readonly bid: BlockIdCombo,readonly nonce: BlockRefNonce,readonly chargedTo: UserOrTeamID,} export type BlockReferenceCount = {readonly ref: BlockReference,readonly liveCount: number,} -export type BootstrapStatus = {readonly registered: boolean,readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,readonly fullname: FullName,readonly userReacjis: UserReacjis,readonly httpSrvInfo?: HttpSrvInfo | null,} +export type BootstrapStatus = {readonly registered: boolean,readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,readonly fullname: FullName,readonly userReacjis: UserReacjis,readonly httpSrvInfo?: HttpSrvInfo | null,readonly version: number,} export type BotToken = string export type BotTokenInfo = {readonly token: BotToken,readonly ctime: Time,} export type BoxAuditAttempt = {readonly ctime: UnixTime,readonly error?: string | null,readonly result: BoxAuditAttemptResult,readonly generation?: PerTeamKeyGeneration | null,readonly rotated: boolean,} From f35c43eed6d7d727530b73531a7d8f537ea8a87b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 16:58:01 -0400 Subject: [PATCH 058/127] fix(startup): subscribe before the bootstrap read and apply whichever is newer onEngineConnected now starts the handshake only once notifyCtlSetNotifications settles, so a login, logout or http server change announced during startup can no longer land before anyone is listening. Both the http server address and the session are ordered by the version the service stamps rather than by local timing: a notification wins only if its version is newer, and a bootstrap status wins unless a login or logout notification is newer, in which case it is read again (3 reads, then a warning). A new engine connection may be a restarted service, so the applied versions start over; they start below 0 because the http server starts before the notify router exists and its first update stamps nothing. Deletes the Lamport clock (httpSrvClock, httpSrvAppliedAt, startHTTPSrvInfoRead) and the second bootstrap read (refreshHTTPSrvInfo). --- shared/constants/init/shared.test.ts | 39 ++++--- shared/constants/init/shared.tsx | 25 +--- shared/stores/config.tsx | 59 +++++++--- shared/stores/daemon.tsx | 43 ++++--- shared/stores/tests/daemon.test.ts | 165 ++++++++++++++++++++------- 5 files changed, 224 insertions(+), 107 deletions(-) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 03a23010c8fd..12be60ec77d3 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -77,12 +77,7 @@ describe('onEngineConnected', () => { resetAllStores() }) - test('reads the http server address again once the service subscription is in place', async () => { - useConfigState.setState(s => { - s.httpSrv = {address: '127.0.0.1:1000', token: 'token'} - s.dispatch = {...originalConfigDispatch, onEngineConnected: () => {}} - }) - useDaemonState.setState({dispatch: {...originalDaemonDispatch, startHandshake: () => {}}}) + const stubRegistrations = () => { for (const rpc of [ 'delegateUiCtlRegisterChatUIRpcPromise', 'delegateUiCtlRegisterLogUIRpcPromise', @@ -93,25 +88,43 @@ describe('onEngineConnected', () => { ] as const) { jest.spyOn(T.RPCGen, rpc).mockResolvedValue(undefined) } + useConfigState.setState(s => { + s.dispatch = {...originalConfigDispatch, onEngineConnected: () => {}} + }) + } + + test('the handshake starts only once the notification subscription resolves', async () => { + stubRegistrations() + const startHandshake = jest.fn() + useDaemonState.setState({dispatch: {...originalDaemonDispatch, startHandshake}}) let subscribed!: () => void jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockReturnValue( new Promise(resolve => { subscribed = resolve }) ) - const bootstrap = jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ - httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, - loggedIn: true, - } as T.RPCGen.BootstrapStatus) onEngineConnected() await Promise.resolve() - expect(bootstrap).not.toHaveBeenCalled() + expect(startHandshake).not.toHaveBeenCalled() subscribed() await new Promise(resolve => setImmediate(resolve)) - expect(bootstrap).toHaveBeenCalledTimes(1) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') + expect(startHandshake).toHaveBeenCalledTimes(1) + }) + + test('the handshake still starts when the subscription fails', async () => { + stubRegistrations() + const startHandshake = jest.fn() + useDaemonState.setState({dispatch: {...originalDaemonDispatch, startHandshake}}) + jest + .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') + .mockRejectedValue(new Error('no notifications')) + + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + + expect(startHandshake).toHaveBeenCalledTimes(1) }) }) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 9be531530873..a35b079bcab7 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -242,21 +242,6 @@ const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavStat onChatRouteChanged(prev, next) } -// An HTTPSrvInfoUpdate sent before the service subscription took effect never reached us, and -// the handshake's bootstrap read may have started before it too, so read the address once more. -const refreshHTTPSrvInfo = async () => { - const {dispatch} = useConfigState.getState() - const readStartedAt = dispatch.startHTTPSrvInfoRead() - try { - const {httpSrvInfo} = await T.RPCGen.configGetBootstrapStatusRpcPromise() - if (httpSrvInfo) { - dispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token, readStartedAt) - } - } catch (error) { - logger.warn('[HTTPSrv] refresh failed: ', error) - } -} - export const onEngineConnected = () => { { const registerUIs = async () => { @@ -279,9 +264,8 @@ export const onEngineConnected = () => { ignorePromise(registerUIs()) } useConfigState.getState().dispatch.onEngineConnected() - useDaemonState.getState().dispatch.startHandshake() { - const notifyCtl = async () => { + const subscribeThenHandshake = async () => { try { // prettier-ignore await T.RPCGen.notifyCtlSetNotificationsRpcPromise({ @@ -298,11 +282,12 @@ export const onEngineConnected = () => { if (error) { logger.warn('error in toggling notifications: ', error) } - return } - await refreshHTTPSrvInfo() + // The handshake's bootstrap read must come after the subscription: a login, logout or + // http server change announced between them would reach nobody. + useDaemonState.getState().dispatch.startHandshake() } - ignorePromise(notifyCtl()) + ignorePromise(subscribeThenHandshake()) } } diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 0b716f1be09b..6f2fe7bd7403 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -91,6 +91,10 @@ const initialStore: Store = { export type State = Store & { dispatch: { + // a login or logout notification: applied only if it is newer than the last applied one + acceptSessionVersion: (version: number) => boolean + // a bootstrap status: applied unless a login or logout notification is newer + acceptSessionSnapshot: (version: number) => boolean checkForUpdate: () => void initAppUpdateLoop: () => void installerRan: () => void @@ -113,28 +117,30 @@ export type State = Store & { setDefaultUsername: (u: string) => void setGlobalError: (e?: unknown) => void setGregorReachable: (r: Store['gregorReachable']) => void - // readStartedAt: from startHTTPSrvInfoRead, for a value read through an RPC; omit for a live notification - setHTTPSrvInfo: (address: string, token: string, readStartedAt?: number) => void + setHTTPSrvInfo: (address: string, token: string, version: number) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void setOutOfDate: (outOfDate: T.Config.OutOfDate) => void setUpdating: () => void setUserSwitching: (sw: boolean) => void - startHTTPSrvInfoRead: () => number toggleRuntimeStats: () => void updateGregorCategory: (category: string, body: string, dtime?: {offset: number; time: number}) => void } } +// Below every version the service can hand out: it can hand out 0, because the http server +// starts before the notify router exists and its first update stamps nothing. +const noVersionApplied = -1 + export const useConfigState = Z.createZustand('config', (set, get) => { let inflightRefreshAccounts: Promise | undefined - // The http server can move (new port) at any time and says so with HTTPSrvInfoUpdate, while - // a bootstrap status read can take seconds. Every source is stamped with when its value was - // observed (a notification when it arrives, an RPC read when it starts) and only a stamp newer - // than the applied one wins, so a read that started before a notification can't undo it. - let httpSrvClock = 0 - let httpSrvAppliedAt = 0 + // The http server address and the session change at any time and say so with versioned + // notifications, while a bootstrap status read can take seconds. The service stamps both from + // one counter, so only a newer version wins. A new engine connection may be a restarted + // service, so both start over. + let httpSrvVersion = noVersionApplied + let sessionVersion = noVersionApplied const _checkForUpdate = async () => { try { @@ -194,6 +200,16 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { + acceptSessionSnapshot: version => { + if (version < sessionVersion) return false + sessionVersion = version + return true + }, + acceptSessionVersion: version => { + if (version <= sessionVersion) return false + sessionVersion = version + return true + }, checkForUpdate: () => { const f = async () => { await _checkForUpdate() @@ -324,6 +340,9 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(f()) }, onEngineConnected: () => { + // this may be a restarted service, whose versions start over + httpSrvVersion = noVersionApplied + sessionVersion = noVersionApplied // An engine reset drops in-flight RPCs without settling their promises; a refresh // caught by that would poison the dedupe cache forever inflightRefreshAccounts = undefined @@ -380,13 +399,18 @@ export const useConfigState = Z.createZustand('config', (set, get) => { break } case 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - get().dispatch.setHTTPSrvInfo(action.payload.params.info.address, action.payload.params.info.token) + const {info, version} = action.payload.params + get().dispatch.setHTTPSrvInfo(info.address, info.token, version) 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 (!dispatch.acceptSessionVersion(action.payload.params.version)) { + logger.info('keybase.1.NotifySession.loggedIn: older than the applied session, ignoring') + break + } + // only send this if we think we're not logged in if (!loggedIn) { dispatch.setLoggedIn(true) } @@ -395,6 +419,10 @@ export const useConfigState = Z.createZustand('config', (set, get) => { case 'keybase.1.NotifySession.loggedOut': { logger.info('keybase.1.NotifySession.loggedOut') const {loggedIn, dispatch} = get() + if (!dispatch.acceptSessionVersion(action.payload.params.version)) { + logger.info('keybase.1.NotifySession.loggedOut: older than the applied session, ignoring') + break + } // 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) @@ -536,12 +564,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { setGregorReachable: r => { setGregorReachable(r) }, - setHTTPSrvInfo: (address, token, readStartedAt = ++httpSrvClock) => { - if (readStartedAt <= httpSrvAppliedAt) { - logger.info(`[HTTPSrv] ignoring ${address}: read before a newer value`) + setHTTPSrvInfo: (address, token, version) => { + if (version <= httpSrvVersion) { + logger.info(`[HTTPSrv] ignoring ${address}: version ${version} is not newer`) return } - httpSrvAppliedAt = readStartedAt + httpSrvVersion = version set(s => { s.httpSrv.address = address s.httpSrv.token = token @@ -595,7 +623,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.userSwitching = sw }) }, - startHTTPSrvInfoRead: () => ++httpSrvClock, toggleRuntimeStats: () => { const f = async () => { await T.RPCGen.configToggleRuntimeStatsRpcPromise() diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index f2114df55f55..b369a434ae0e 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -14,7 +14,9 @@ export type BootstrapStep = () => Promise export class FatalHandshakeError extends Error {} type Store = T.Immutable<{ - bootstrapStatus?: T.RPCGen.BootstrapStatus + // without the version: that only orders this read against the login, logout and http server + // notifications, and keeping it would make every read after one of those look like a change + bootstrapStatus?: Omit error?: Error handshakeFailedReason: string /** counts handshakes, so consumers can tell one reconnect from the next */ @@ -44,6 +46,8 @@ export type State = Store & { } const retryDelayMs = 1000 +// the initial read plus two retries; a status that keeps losing to newer logins or logouts is dropped +const maxStaleSnapshotReads = 3 export const useDaemonState = Z.createZustand('daemon', (set, get) => { let bootstrapSteps: Array = [] @@ -63,23 +67,30 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { const gen = generation const f = async () => { const configDispatch = useConfigState.getState().dispatch - const httpSrvReadStartedAt = configDispatch.startHTTPSrvInfoRead() - const bs = await T.RPCGen.configGetBootstrapStatusRpcPromise() - logger.info( - `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'}` - ) - // applied here rather than from bootstrapStatus: the address has its own ordering, and a - // status that is skipped below or later edited in place must not skip or replay it - if (bs.httpSrvInfo) { - configDispatch.setHTTPSrvInfo(bs.httpSrvInfo.address, bs.httpSrvInfo.token, httpSrvReadStartedAt) - } - // a newer handshake owns the store now; don't write a potentially older status over its load - if (gen !== generation || isEqual(bs, get().bootstrapStatus)) { + for (let read = 1; read <= maxStaleSnapshotReads; read++) { + const {version, ...bs} = await T.RPCGen.configGetBootstrapStatusRpcPromise() + logger.info( + `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'} version: ${version}` + ) + // applied here rather than from bootstrapStatus: the address has its own ordering, and a + // status that is skipped below or later edited in place must not skip or replay it + if (bs.httpSrvInfo) { + configDispatch.setHTTPSrvInfo(bs.httpSrvInfo.address, bs.httpSrvInfo.token, version) + } + if (!configDispatch.acceptSessionSnapshot(version)) { + logger.info('[Bootstrap] a login or logout is newer than this status, reading it again') + continue + } + // a newer handshake owns the store now; don't write a potentially older status over its load + if (gen !== generation || isEqual(bs, get().bootstrapStatus)) { + return + } + set(s => { + s.bootstrapStatus = T.castDraft(bs) + }) return } - set(s => { - s.bootstrapStatus = T.castDraft(bs) - }) + logger.warn('[Bootstrap] the status kept losing to newer logins or logouts, not applying it') } const p = f() inflightBootstrapStatus = p diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index 2a48a9956009..7022785d9f80 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -14,6 +14,7 @@ const bootstrapStatus = { registered: true, uid: 'u1', username: 'testuser', + version: 1, } as unknown as T.RPCGen.BootstrapStatus describe('daemon store', () => { @@ -150,25 +151,21 @@ describe('daemon store', () => { }) describe('httpSrvInfo ordering', () => { - const withHTTP = (address: string): T.RPCGen.BootstrapStatus => ({ + const withHTTP = (address: string, version: number): T.RPCGen.BootstrapStatus => ({ ...bootstrapStatus, httpSrvInfo: {address, token: 'token'}, + version, }) - const notify = (address: string) => + const notify = (address: string, version: number) => useConfigState.getState().dispatch.onEngineIncoming({ - payload: {params: {info: {address, token: 'token'}}}, + payload: {params: {info: {address, token: 'token'}, version}}, type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', } as any) - const deferredBootstrap = () => { - let resolve!: (bs: T.RPCGen.BootstrapStatus) => void - const promise = new Promise(_resolve => { - resolve = _resolve - }) - return {promise, resolve} - } beforeEach(() => { jest.useFakeTimers() + // the applied versions live outside the store; a fresh engine connection is what clears them + useConfigState.getState().dispatch.onEngineConnected() useConfigState.setState(s => { s.httpSrv = {address: '', token: ''} }) @@ -179,59 +176,143 @@ describe('httpSrvInfo ordering', () => { resetAllStores() }) - test('a bootstrap read that started before a notification does not overwrite it', async () => { - const read = deferredBootstrap() - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockReturnValue(read.promise) + test('a status older than an http server notification does not overwrite it', async () => { + notify('127.0.0.1:2', 5) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 4)) - const load = useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - notify('127.0.0.1:2000') - read.resolve(withHTTP('127.0.0.1:1000')) - await load + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') }) - test('a bootstrap read that started after a notification is applied', async () => { - notify('127.0.0.1:2000') - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:3000')) + test('a status newer than a notification is applied', async () => { + notify('127.0.0.1:2', 3) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 4)) await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3000') + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') }) - test('an older bootstrap read landing after a newer one does not overwrite it', async () => { - const older = deferredBootstrap() - jest - .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockReturnValueOnce(older.promise) - .mockResolvedValueOnce(withHTTP('127.0.0.1:3000')) - const {dispatch} = useDaemonState.getState() + test('an older notification landing after a newer status is ignored', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 6)) - const olderLoad = dispatch.loadDaemonBootstrapStatus() - // a new handshake starts its own load instead of reusing the in-flight one - dispatch.startHandshake() - await jest.advanceTimersByTimeAsync(0) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3000') + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + notify('127.0.0.1:2', 5) + + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') + }) + + test('a version-0 address from a service that never stamped one is applied', async () => { + // the http server starts before the notify router exists, so its first update stamps nothing + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 0)) + + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - older.resolve(withHTTP('127.0.0.1:1000')) - await olderLoad - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3000') + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') }) test('a status equal to the stored one still applies its newer address', async () => { - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1000')) + jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValueOnce(withHTTP('127.0.0.1:1', 1)) + .mockResolvedValueOnce(withHTTP('127.0.0.1:1', 3)) const {dispatch} = useDaemonState.getState() + await dispatch.loadDaemonBootstrapStatus() - notify('127.0.0.1:2000') + notify('127.0.0.1:2', 2) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + + // the second status is identical to the stored one, so nothing is written, but its + // address is newer than the notification's and still has to be applied await dispatch.loadDaemonBootstrapStatus() - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1000') + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') }) test('logging out keeps the http server address', () => { - notify('127.0.0.1:2000') + notify('127.0.0.1:2', 1) useConfigState.getState().dispatch.resetState() - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + }) + + test("a reconnect accepts a restarted service's lower versions", () => { + notify('127.0.0.1:2', 9) + useConfigState.getState().dispatch.onEngineConnected() + notify('127.0.0.1:3', 1) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') + }) +}) + +describe('session ordering', () => { + const notifySession = (kind: 'loggedIn' | 'loggedOut', version: number) => + useConfigState.getState().dispatch.onEngineIncoming({ + payload: { + params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser', version} : {version}, + }, + type: `keybase.1.NotifySession.${kind}`, + } as any) + + beforeEach(() => { + jest.useFakeTimers() + // the applied versions live outside the store; a fresh engine connection is what clears them + useConfigState.getState().dispatch.onEngineConnected() + }) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() + }) + + test('a status older than a session notification is read again', async () => { + notifySession('loggedIn', 7) + const spy = jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValueOnce({...bootstrapStatus, username: 'stale', version: 6}) + .mockResolvedValueOnce({...bootstrapStatus, username: 'testuser', version: 7}) + + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + + expect(spy).toHaveBeenCalledTimes(2) + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') + }) + + test('a status that keeps losing is not applied', async () => { + notifySession('loggedOut', 9) + const spy = jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValueOnce({...bootstrapStatus, version: 1}) + .mockResolvedValueOnce({...bootstrapStatus, version: 2}) + .mockResolvedValueOnce({...bootstrapStatus, version: 3}) + + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + + expect(spy).toHaveBeenCalledTimes(3) + expect(useDaemonState.getState().bootstrapStatus).toBe(undefined) + }) + + test('a status whose only change is its version does not rewrite the store', async () => { + // a login, logout or http server change bumps the version without changing this status + jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValueOnce({...bootstrapStatus, version: 1}) + .mockResolvedValueOnce({...bootstrapStatus, version: 2}) + const {dispatch} = useDaemonState.getState() + + await dispatch.loadDaemonBootstrapStatus() + const stored = useDaemonState.getState().bootstrapStatus + await dispatch.loadDaemonBootstrapStatus() + + expect(useDaemonState.getState().bootstrapStatus).toBe(stored) + }) + + test('a session notification older than the applied one is ignored', () => { + useConfigState.setState({loggedIn: true}) + + notifySession('loggedOut', 5) + expect(useConfigState.getState().loggedIn).toBe(false) + + notifySession('loggedIn', 4) + expect(useConfigState.getState().loggedIn).toBe(false) }) }) From c6fb49c282dbcb6aa5fbb02ce26ae9f88ef8fd71 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 17:16:42 -0400 Subject: [PATCH 059/127] fix(startup): keep the reconnect synchronous and stop a superseded read consuming the session version A superseded bootstrap read used to pass the session gate and then throw its status away at the generation check, so it consumed a version nothing applied and the notification carrying that version was dropped. The generation check now comes first, which also stops a superseded run firing retries it could never use. startHandshake takes the promise the bootstrap read waits on instead of being called behind it. Clearing the disconnect state and invalidating the previous handshake stay synchronous, as engine/index.platform.tsx's reset path documents; only the read waits for the notification subscription. The two version helpers and the inline http compare collapse into one gate over one {http, session} record, with one reset. A service too old to send a version gives nothing to order by, so its updates are applied in arrival order as they were before versions existed, and the bootstrap read types the field as optional. The give-up warning now says what state the app is left in. --- shared/constants/init/shared.test.ts | 45 ++++++++++++---- shared/constants/init/shared.tsx | 10 ++-- shared/engine/index.platform.tsx | 3 +- shared/login/loading.tsx | 2 +- shared/stores/config.tsx | 47 ++++++++-------- shared/stores/daemon.tsx | 27 +++++++--- shared/stores/tests/daemon.test.ts | 80 ++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 44 deletions(-) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 12be60ec77d3..66fb79ed9e69 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -93,38 +93,63 @@ describe('onEngineConnected', () => { }) } - test('the handshake starts only once the notification subscription resolves', async () => { - stubRegistrations() - const startHandshake = jest.fn() - useDaemonState.setState({dispatch: {...originalDaemonDispatch, startHandshake}}) + const deferredSubscription = () => { let subscribed!: () => void jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockReturnValue( new Promise(resolve => { subscribed = resolve }) ) + return () => subscribed() + } + // config's onEngineConnected, which resets the applied versions, is stubbed out here, so each + // test reads a version newer than the last one applied + let version = 0 + const spyOnBootstrap = () => + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, + loggedIn: true, + version: ++version, + } 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 starts only once the notification subscription resolves', async () => { + stubRegistrations() + const subscribed = deferredSubscription() + const bootstrap = spyOnBootstrap() onEngineConnected() await Promise.resolve() - expect(startHandshake).not.toHaveBeenCalled() + expect(bootstrap).not.toHaveBeenCalled() subscribed() await new Promise(resolve => setImmediate(resolve)) - expect(startHandshake).toHaveBeenCalledTimes(1) + expect(bootstrap).toHaveBeenCalledTimes(1) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') }) - test('the handshake still starts when the subscription fails', async () => { + test('the bootstrap read still runs when the subscription fails', async () => { stubRegistrations() - const startHandshake = jest.fn() - useDaemonState.setState({dispatch: {...originalDaemonDispatch, startHandshake}}) jest .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') .mockRejectedValue(new Error('no notifications')) + const bootstrap = spyOnBootstrap() onEngineConnected() await new Promise(resolve => setImmediate(resolve)) - expect(startHandshake).toHaveBeenCalledTimes(1) + expect(bootstrap).toHaveBeenCalledTimes(1) }) }) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index a35b079bcab7..6e1dfcef233c 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -265,7 +265,7 @@ export const onEngineConnected = () => { } useConfigState.getState().dispatch.onEngineConnected() { - const subscribeThenHandshake = async () => { + const subscribe = async () => { try { // prettier-ignore await T.RPCGen.notifyCtlSetNotificationsRpcPromise({ @@ -283,11 +283,11 @@ export const onEngineConnected = () => { logger.warn('error in toggling notifications: ', error) } } - // The handshake's bootstrap read must come after the subscription: a login, logout or - // http server change announced between them would reach nobody. - useDaemonState.getState().dispatch.startHandshake() } - ignorePromise(subscribeThenHandshake()) + // The handshake starts now, so the reconnect clears the disconnect state at once, but its + // bootstrap read waits for the subscription: a login, logout or http server change announced + // between the read and the subscription would reach nobody. + useDaemonState.getState().dispatch.startHandshake(subscribe()) } } diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index f0cf37f0ba68..cc7b4fd40fd1 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(); only that handshake's bootstrap read is + // deferred, so nothing here may be moved behind an await). client.transport.reset() try { disconnectCallback() 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/stores/config.tsx b/shared/stores/config.tsx index 6f2fe7bd7403..91a67b1a000d 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -92,9 +92,9 @@ const initialStore: Store = { export type State = Store & { dispatch: { // a login or logout notification: applied only if it is newer than the last applied one - acceptSessionVersion: (version: number) => boolean + acceptSessionVersion: (version: number | undefined) => boolean // a bootstrap status: applied unless a login or logout notification is newer - acceptSessionSnapshot: (version: number) => boolean + acceptSessionSnapshot: (version: number | undefined) => boolean checkForUpdate: () => void initAppUpdateLoop: () => void installerRan: () => void @@ -117,7 +117,7 @@ export type State = Store & { setDefaultUsername: (u: string) => void setGlobalError: (e?: unknown) => void setGregorReachable: (r: Store['gregorReachable']) => void - setHTTPSrvInfo: (address: string, token: string, version: number) => void + setHTTPSrvInfo: (address: string, token: string, version: number | undefined) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void @@ -132,15 +132,28 @@ export type State = Store & { // Below every version the service can hand out: it can hand out 0, because the http server // starts before the notify router exists and its first update stamps nothing. const noVersionApplied = -1 +const nothingApplied = () => ({http: noVersionApplied, session: noVersionApplied}) export const useConfigState = Z.createZustand('config', (set, get) => { let inflightRefreshAccounts: Promise | undefined // The http server address and the session change at any time and say so with versioned // notifications, while a bootstrap status read can take seconds. The service stamps both from - // one counter, so only a newer version wins. A new engine connection may be a restarted - // service, so both start over. - let httpSrvVersion = noVersionApplied - let sessionVersion = noVersionApplied + // one counter, so only a newer version wins. + let applied = nothingApplied() + // A notification must be strictly newer than what we applied. A bootstrap status may carry the + // version of a notification we already applied, since that is the same state read again. + const acceptVersion = ( + kind: 'http' | 'session', + version: number | undefined, + source: 'notification' | 'status' + ) => { + // a service too old to send a version gives us nothing to order by, so everything it sends is + // applied in the order it arrives, as it was before versions existed + if (version === undefined) return true + if (source === 'status' ? version < applied[kind] : version <= applied[kind]) return false + applied[kind] = version + return true + } const _checkForUpdate = async () => { try { @@ -200,16 +213,8 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { - acceptSessionSnapshot: version => { - if (version < sessionVersion) return false - sessionVersion = version - return true - }, - acceptSessionVersion: version => { - if (version <= sessionVersion) return false - sessionVersion = version - return true - }, + acceptSessionSnapshot: version => acceptVersion('session', version, 'status'), + acceptSessionVersion: version => acceptVersion('session', version, 'notification'), checkForUpdate: () => { const f = async () => { await _checkForUpdate() @@ -341,8 +346,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }, onEngineConnected: () => { // this may be a restarted service, whose versions start over - httpSrvVersion = noVersionApplied - sessionVersion = noVersionApplied + applied = nothingApplied() // An engine reset drops in-flight RPCs without settling their promises; a refresh // caught by that would poison the dedupe cache forever inflightRefreshAccounts = undefined @@ -565,11 +569,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { setGregorReachable(r) }, setHTTPSrvInfo: (address, token, version) => { - if (version <= httpSrvVersion) { + // the notification rule, for the status too: a status whose version ties the notification we + // applied carries that notification's address, so nothing is lost by ignoring it + if (!acceptVersion('http', version, 'notification')) { logger.info(`[HTTPSrv] ignoring ${address}: version ${version} is not newer`) return } - httpSrvVersion = version set(s => { s.httpSrv.address = address s.httpSrv.token = token diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index b369a434ae0e..c588d429e3a2 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -40,12 +40,19 @@ export type State = Store & { loadDaemonBootstrapStatus: () => Promise resetState: () => void setError: (e?: Error) => void - startHandshake: () => void + // readAfter: the bootstrap read must not start before the notification subscription is in + // place, or a login, logout or http server change announced between them reaches nobody. + // Everything else -- clearing the disconnect state, invalidating the previous handshake -- + // happens synchronously, so a reconnect is visible without waiting on an RPC. + startHandshake: (readAfter?: Promise) => void updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void } } const retryDelayMs = 1000 +// The version is missing when the service predates it; the version gates then have nothing to +// order by and apply everything, as we did before versions existed. +type MaybeVersionedStatus = Omit & {version?: number} // the initial read plus two retries; a status that keeps losing to newer logins or logouts is dropped const maxStaleSnapshotReads = 3 @@ -68,7 +75,8 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { const f = async () => { const configDispatch = useConfigState.getState().dispatch for (let read = 1; read <= maxStaleSnapshotReads; read++) { - const {version, ...bs} = await T.RPCGen.configGetBootstrapStatusRpcPromise() + const {version, ...bs}: MaybeVersionedStatus = + await T.RPCGen.configGetBootstrapStatusRpcPromise() logger.info( `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'} version: ${version}` ) @@ -77,12 +85,16 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { if (bs.httpSrvInfo) { configDispatch.setHTTPSrvInfo(bs.httpSrvInfo.address, bs.httpSrvInfo.token, version) } + // a newer handshake owns the store now; don't write a potentially older status over its + // load, and don't consume the session version it needs + if (gen !== generation) { + return + } if (!configDispatch.acceptSessionSnapshot(version)) { logger.info('[Bootstrap] a login or logout is newer than this status, reading it again') continue } - // 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 (isEqual(bs, get().bootstrapStatus)) { return } set(s => { @@ -90,7 +102,9 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { }) return } - logger.warn('[Bootstrap] the status kept losing to newer logins or logouts, not applying it') + logger.warn( + '[Bootstrap] the status kept losing to newer logins or logouts; the session is whatever the last notification said and the current user stays as it was' + ) } const p = f() inflightBootstrapStatus = p @@ -118,7 +132,7 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.error = e }) }, - startHandshake: () => { + startHandshake: readAfter => { 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 @@ -131,6 +145,7 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.handshakeState = 'loading' }) const run = async () => { + await readAfter while (gen === generation) { try { await get().dispatch.loadDaemonBootstrapStatus() diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index 7022785d9f80..f4f017ab506f 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -230,6 +230,12 @@ describe('httpSrvInfo ordering', () => { expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') }) + test('a notification with the version already applied is ignored', () => { + notify('127.0.0.1:2', 5) + notify('127.0.0.1:3', 5) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + }) + test('logging out keeps the http server address', () => { notify('127.0.0.1:2', 1) useConfigState.getState().dispatch.resetState() @@ -245,6 +251,13 @@ describe('httpSrvInfo ordering', () => { }) describe('session ordering', () => { + const deferredBootstrap = () => { + let resolve!: (bs: T.RPCGen.BootstrapStatus) => void + const promise = new Promise(_resolve => { + resolve = _resolve + }) + return {promise, resolve} + } const notifySession = (kind: 'loggedIn' | 'loggedOut', version: number) => useConfigState.getState().dispatch.onEngineIncoming({ payload: { @@ -306,6 +319,18 @@ describe('session ordering', () => { expect(useDaemonState.getState().bootstrapStatus).toBe(stored) }) + test('a status whose version ties the applied notification is applied', async () => { + notifySession('loggedOut', 4) + const spy = jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValue({...bootstrapStatus, version: 4}) + + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + + expect(spy).toHaveBeenCalledTimes(1) + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') + }) + test('a session notification older than the applied one is ignored', () => { useConfigState.setState({loggedIn: true}) @@ -315,4 +340,59 @@ describe('session ordering', () => { notifySession('loggedIn', 4) expect(useConfigState.getState().loggedIn).toBe(false) }) + + test('a notification with the version already applied is ignored', () => { + useConfigState.setState({loggedIn: false}) + + notifySession('loggedIn', 5) + expect(useConfigState.getState().loggedIn).toBe(true) + + notifySession('loggedOut', 5) + expect(useConfigState.getState().loggedIn).toBe(true) + }) + + test('a service too old to send a version still applies its status and its notifications', async () => { + useConfigState.setState({loggedIn: true}) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + ...bootstrapStatus, + httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, + version: undefined, + } as unknown as T.RPCGen.BootstrapStatus) + + await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() + + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') + + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: {}}, + type: 'keybase.1.NotifySession.loggedOut', + } as any) + expect(useConfigState.getState().loggedIn).toBe(false) + }) + + test('a superseded read does not swallow a later session notification', async () => { + // it can read a version at least as new as the winner's; consuming it and then throwing the + // status away would leave nothing to apply that version's state + useConfigState.setState({loggedIn: true}) + const superseded = deferredBootstrap() + jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockReturnValueOnce(superseded.promise) + .mockResolvedValue({...bootstrapStatus, version: 5}) + const {dispatch} = useDaemonState.getState() + dispatch.initBootstrapSteps([]) + + const losing = dispatch.loadDaemonBootstrapStatus() + // a new handshake starts its own load instead of reusing the in-flight one + dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + superseded.resolve({...bootstrapStatus, username: 'stale', version: 9}) + await losing + await jest.advanceTimersByTimeAsync(0) + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') + + notifySession('loggedOut', 9) + expect(useConfigState.getState().loggedIn).toBe(false) + }) }) From cb21f26f2c51dc09dab2cb4577207c0cc3af3b39 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 17:28:55 -0400 Subject: [PATCH 060/127] fix(mobile): drop the legacy expo location task and prove JS never starts in the background iOS watches live location natively, but builds from before that left expo's background-location-task registered, and expo restores it on every launch into a second CLLocationManager. Unregister it from JS on iOS: a bare app registers no UMAppLoader, so expo's EXTaskService can never start JS for a restored task on a background launch, and JS is therefore early enough to clean it up. Delete KbModule.isReactNativeRunning and the Android push service probe that logged it: with the JS push path gone, nothing acts on the answer. The iOS relaunch e2e now asserts that no bundle request and no JS log line reach Metro while the app is relaunched in the background, with the next flow reading the same mark after a scene starts so the negative assertion cannot pass vacuously. --- .../main/java/com/reactnativekb/KbModule.kt | 5 ----- .../KeybasePushNotificationListenerService.kt | 8 ------- shared/constants/init/index.tsx | 20 +++++++++++++++++ shared/constants/init/location-watch.test.ts | 22 +++++++++++++++++++ shared/constants/init/platform-types.ts | 2 ++ .../flows/lifecycle-location.test.ts | 17 ++++++++++++++ .../tests/e2e/ios-appium/helpers/lifecycle.ts | 5 +++++ 7 files changed, 66 insertions(+), 13 deletions(-) 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 cc9df39d6edb..84cf9a0c8099 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 @@ -779,11 +779,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T instance?.emitPushTapInternal() } - @JvmStatic - fun isReactNativeRunning(): Boolean { - return instance != null - } - @JvmStatic fun emitShareData(data: WritableMap) { val module = instance 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 8b7f7d71a46b..24c4c21461be 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 @@ -150,14 +150,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { } - 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) { diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index db2204892dbb..4d71fea55290 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -66,6 +66,24 @@ const ensureBackgroundTask = (ExpoTaskManager: ExpoTaskManagerModule) => { }) } +// Builds from before iOS watched location natively left this expo task registered, and expo +// restores it on every launch into a second CLLocationManager (expo-location's +// EXLocationTaskConsumer didRegisterTask), so remove it. JS is early enough to do it: nothing +// here registers a UMAppLoader, so expo's EXTaskService 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], @@ -466,6 +484,8 @@ const _initNativePlatformListener = () => { _platformUnsubs.push(...initPushListener()) + ignorePromise(unregisterLegacyIOSLocationTask()) + const {NetInfo} = _getNative() _platformUnsubs.push( NetInfo.addEventListener(({type}) => { diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts index 84ee593d132d..576bfda9ffd6 100644 --- a/shared/constants/init/location-watch.test.ts +++ b/shared/constants/init/location-watch.test.ts @@ -29,6 +29,14 @@ const load = (platform: 'ios' | 'android'): typeof Init => { defineTask: () => { calls.push('defineTask') }, + isTaskRegisteredAsync: async () => { + calls.push('isTaskRegistered') + return Promise.resolve(true) + }, + unregisterTaskAsync: async () => { + calls.push('unregisterTask') + return Promise.resolve() + }, }, requestLocationPermission: async (perm: unknown) => { calls.push(`requestPermission:${String(perm)}`) @@ -101,3 +109,17 @@ test('Android asks for permission and runs the expo location task', async () => 'stopLocationUpdates', ]) }) + +test('iOS removes the legacy expo background location task', async () => { + const init = load('ios') + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual(['isTaskRegistered', 'unregisterTask']) +}) + +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/tests/e2e/ios-appium/flows/lifecycle-location.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts index ba3bcb3334ed..28c2289cc689 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -11,6 +11,9 @@ import { goLogMark, goLogSince, jsEval, + metroBundlingStartedSince, + metroClientLogSince, + metroLogMark, nativeLogSince, openSelfConversation, setLocation, @@ -31,6 +34,8 @@ import { // - "+ 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 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 @@ -61,6 +66,9 @@ const sendCommand = async (text: string) => { describe('app lifecycle: live location', () => { let convID = '' let sharing = false + // Taken when the background relaunch starts, and read again once a scene starts the app, so + // the "no JS ran" assertions can't pass because nothing reaches start.log at all. + let relaunchMetroMark: ReturnType | undefined before(() => { const udid = deviceUdid() @@ -127,6 +135,10 @@ describe('app lifecycle: live location', () => { this.timeout(420000) 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() + relaunchMetroMark = metroMark setLocation(moves[2]!.lat, moves[2]!.lon) // iOS relaunches the app in the background for the significant location change. @@ -148,6 +160,9 @@ describe('app lifecycle: live location', () => { 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([]) }) it('stops sharing, stops the OS location service, and a move no longer relaunches the app', async function () { @@ -155,6 +170,8 @@ describe('app lifecycle: live location', () => { const user = requireSmokeUser() await activateApp() await waitForAppState('active', undefined, 90000) + // The scene did start React Native, from the same mark the relaunch read as empty. + expect(metroClientLogSince(relaunchMetroMark!).length).toBeGreaterThan(0) await openSelfConversation(user) const goMark = goLogMark() const since = new Date(Date.now() - 1000) diff --git a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts index ec6d5fabf1f9..342a81b818a4 100644 --- a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts +++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts @@ -296,6 +296,11 @@ export const metroClientLogSince = (mark: LogMark): Array => }) .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. From 20a889dff765ec2fc804c5cfcfa57322405c6d51 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 17:38:58 -0400 Subject: [PATCH 061/127] test(mobile): cover the task-registered guard and control both metro readers The relaunch flow's negative proof now carries its own control: after asserting no bundle request and no JS log line reached Metro, it activates the app and requires both readers to see the JS start from the same mark, so neither can go quietly blind if an event name changes. The control lives in the flow it guards, so running that flow alone still proves the readers work. The iOS cleanup test runs the function twice against a store that stops reporting the task as registered, pinning the isTaskRegisteredAsync guard that keeps later launches from asking expo to unregister a task that is already gone. --- shared/constants/init/index.tsx | 9 ++++---- shared/constants/init/location-watch.test.ts | 10 +++++--- .../flows/lifecycle-location.test.ts | 23 +++++++++++-------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 4d71fea55290..e6051629c995 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -66,11 +66,10 @@ const ensureBackgroundTask = (ExpoTaskManager: ExpoTaskManagerModule) => { }) } -// Builds from before iOS watched location natively left this expo task registered, and expo -// restores it on every launch into a second CLLocationManager (expo-location's -// EXLocationTaskConsumer didRegisterTask), so remove it. JS is early enough to do it: nothing -// here registers a UMAppLoader, so expo's EXTaskService can never start JS for a restored task -// on a background launch (expo-task-manager EXTaskService.m `_loadAppWithId:appUrl:`). +// 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() diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts index 576bfda9ffd6..55868d1fd195 100644 --- a/shared/constants/init/location-watch.test.ts +++ b/shared/constants/init/location-watch.test.ts @@ -29,9 +29,10 @@ const load = (platform: 'ios' | 'android'): typeof Init => { defineTask: () => { calls.push('defineTask') }, + // Registered until it is unregistered, like the real task store. isTaskRegisteredAsync: async () => { calls.push('isTaskRegistered') - return Promise.resolve(true) + return Promise.resolve(!calls.includes('unregisterTask')) }, unregisterTaskAsync: async () => { calls.push('unregisterTask') @@ -110,11 +111,14 @@ test('Android asks for permission and runs the expo location task', async () => ]) }) -test('iOS removes the legacy expo background location task', async () => { +// 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']) + expect(calls).toEqual(['isTaskRegistered', 'unregisterTask', 'isTaskRegistered']) }) test('Android keeps its expo background location task', async () => { diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts index 28c2289cc689..eefecfe81d6c 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -34,8 +34,9 @@ import { // - "+ 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. +// 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 @@ -66,9 +67,6 @@ const sendCommand = async (text: string) => { describe('app lifecycle: live location', () => { let convID = '' let sharing = false - // Taken when the background relaunch starts, and read again once a scene starts the app, so - // the "no JS ran" assertions can't pass because nothing reaches start.log at all. - let relaunchMetroMark: ReturnType | undefined before(() => { const udid = deviceUdid() @@ -131,14 +129,14 @@ describe('app lifecycle: live location', () => { }) 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. - this.timeout(420000) + // 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() - relaunchMetroMark = metroMark setLocation(moves[2]!.lat, moves[2]!.lon) // iOS relaunches the app in the background for the significant location change. @@ -163,6 +161,13 @@ describe('app lifecycle: live location', () => { // 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 () { @@ -170,8 +175,6 @@ describe('app lifecycle: live location', () => { const user = requireSmokeUser() await activateApp() await waitForAppState('active', undefined, 90000) - // The scene did start React Native, from the same mark the relaunch read as empty. - expect(metroClientLogSince(relaunchMetroMark!).length).toBeGreaterThan(0) await openSelfConversation(user) const goMark = goLogMark() const since = new Date(Date.now() - 1000) From aedc3d0ce0914c7aad3f886f2b5d9c78a5367810 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 18:03:55 -0400 Subject: [PATCH 062/127] fix(deeplinks): send keybase://devices to the platform's devices screen The imperative devices case switched to the Settings tab and popped to 'devicesRoot' on every platform. On desktop handleAppLink is the linking subscription's listener as well as its fallback, and devices live in their own tab, so the pop ran against the settings stack, which has no such route: the link landed on Settings and never reached Devices. Branch it the way devices/index.tsx and device-revoke.tsx already do, and replace the test that pinned the broken mapping with per-platform assertions. --- shared/constants/deeplinks.test.ts | 39 ++++++++++++++++++++++++++---- shared/constants/deeplinks.tsx | 6 +++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/shared/constants/deeplinks.test.ts b/shared/constants/deeplinks.test.ts index fb52256347cf..c2399ebdb5e9 100644 --- a/shared/constants/deeplinks.test.ts +++ b/shared/constants/deeplinks.test.ts @@ -10,12 +10,41 @@ jest.mock('./router', () => ({ 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' -test('a devices link opens the devices list in settings', () => { - handleAppLink('keybase://devices') +const withIsMobile = (isMobile: boolean, f: () => void) => { + const was = global.isMobile + global.isMobile = isMobile + try { + f() + } finally { + global.isMobile = was + } +} - expect(Router.switchTab).toHaveBeenCalledWith(Tabs.settingsTab) - expect(Router.navUpToScreen).toHaveBeenCalledWith('devicesRoot') - expect(Router.navigateAppend).not.toHaveBeenCalled() +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() + }) }) diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index 118bba13deb9..205f959eac40 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -9,6 +9,7 @@ import { switchTab, } from './router' import * as Tabs from './tabs' +import {settingsDevicesTab} from './settings' import {showTeamByName} from '@/teams/team-page-actions' const prefix = 'keybase://' @@ -83,8 +84,9 @@ const handleKeybaseLink = (link: string) => { } break case 'devices': - switchTab(Tabs.settingsTab) - navUpToScreen('devicesRoot') + // 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 'private': case 'public': From da407714886cf465d15c5e26d1a2a993c7228815 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 18:04:04 -0400 Subject: [PATCH 063/127] fix(daemon): start the handshake even if the ordering promise rejects readAfter only orders the bootstrap read behind the notification subscription. A rejecting one propagated out of run(), which ignorePromise swallowed, leaving handshakeState at 'loading' forever: splash screen, no retry, no Reload. --- shared/stores/daemon.tsx | 4 +++- shared/stores/tests/daemon.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index c588d429e3a2..3618d13e703a 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -145,7 +145,9 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.handshakeState = 'loading' }) const run = async () => { - await readAfter + // readAfter only orders the read behind the subscription; if it rejects the handshake + // must still run, or the app sits on the splash with no retry and no Reload. + await readAfter?.catch(() => {}) while (gen === generation) { try { await get().dispatch.loadDaemonBootstrapStatus() diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index f4f017ab506f..5545d08584c6 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -41,6 +41,19 @@ describe('daemon store', () => { expect(store.getState().bootstrapStatus?.username).toBe('testuser') }) + test('a rejecting readAfter still starts the handshake', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake(Promise.reject(new Error('subscribe failed'))) + await jest.advanceTimersByTimeAsync(0) + + expect(step).toHaveBeenCalledTimes(1) + expect(store.getState().handshakeState).toBe('done') + }) + test('a failing step retries and can recover', async () => { jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) const step = jest.fn(async () => {}).mockRejectedValueOnce(new Error('flaky')) From 9056238445e98a5d241b01e4bd62219fe9cb2cf3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 18:04:04 -0400 Subject: [PATCH 064/127] fix(kbhttp): publish an empty status before the server can be observed status was nil until run's first publish, so Active() and Info() dereferenced nil. Unreachable today, but an initial-endpoints API would hand out the *Srv before then. The empty Address is already the "not serving" sentinel. --- go/kbhttp/manager/manager.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index c9e45dc78c29..e4974a842d65 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -110,6 +110,8 @@ func New(name string, log logger.Logger, appState AppState, listenerSource func( 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 = r.newHTTPSrv() ready := make(chan error) go r.run(ready) From 13cdd0f4ca67da0902f961160884310e8ae8353d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 17 Sep 2026 18:04:04 -0400 Subject: [PATCH 065/127] docs: record why the gate, the link split and the initial URL work as they do - gregorConnGate: why it is a mutex gate and not a kbhttp/manager-style owning goroutine, so nobody harmonises the two and reintroduces the deadlock. - serverSync: TODO naming the one check-then-act step left in the OnConnect tail, so the tail does not read as uniformly gated. - linking: the listener/handleAppLink split only differs on mobile, and setInitialURLOnce consumes a matching pending intent as well as recording it. --- go/service/gregor.go | 2 ++ go/service/gregor_conn.go | 8 ++++++++ shared/router-v2/linking.tsx | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/go/service/gregor.go b/go/service/gregor.go index 4eedf7b469ca..633231faff29 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -966,6 +966,8 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio } // Sync down events since we have been dead + // TODO: unlike the badge steps around it, serverSync is check-then-act: conn can stop being + // current between this check and the sync. Gating it means running an RPC under the gate. g.runOnConnectStep(onConnectStepServerSync) if !g.isCurrentConn(conn) { return chat.ErrDuplicateConnection diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index 827ce1a38798..fff946ddcd72 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -36,6 +36,14 @@ type gregorAppState interface { // which then waits for that connect before taking the connection down. mu // also runs the steps OnConnect applies after syncing (see do), so none of // them interleaves with a disconnect. +// +// This is a mutex gate rather than a single owning goroutine like +// kbhttp/manager's Srv: every operation here is synchronous with a result its +// caller needs (connect/reconnect return errors, reconnect also didShutdown), +// and do's 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. type gregorConnGate struct { mobile gregorAppState desktop *libkb.DesktopAppState diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index f5eb9e3909ea..60e7c6924ead 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -126,6 +126,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)) { @@ -257,6 +260,8 @@ 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. const openInitialLink = (link: string, handleAppLink: (link: string) => void) => { if (isHandledByLinkingConfig(link)) return setInitialURLOnce(link) setInitialURLOnce(link) From 95a7e9a91442dc65064c77bf1fff6275b076cfd5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:23:21 -0400 Subject: [PATCH 066/127] refactor(appstate): one shared watcher for the four app-state monitors The avatar flusher, the leveldb cleaner, the background conv loader and the archive registry each hand-rolled the same loop: wait for the next app-state change, record the state and channel so a test can tell the monitor caught up, re-read the state, act. libkb.AppStateWatcher is that loop written once, with the per-consumer run-identity check, predicate and action in its callback. The caller still owns the goroutine, since the consumers hang it off their own errgroup or done channel and count it on the way out. The five monitorState / monitorWait pairs collapse into one CaughtUp(). --- go/avatars/appstate.go | 60 +++++++++------------------ go/avatars/appstate_test.go | 24 +++++------ go/chat/archive.go | 35 ++++++---------- go/chat/archive_appstate_test.go | 12 ++---- go/chat/convloader.go | 43 ++++++++----------- go/chat/convloader_appstate_test.go | 12 ++---- go/libkb/appstate.go | 64 +++++++++++++++++++++++++++++ go/libkb/leveldb_cleaner.go | 42 ++++++++----------- go/libkb/leveldb_cleaner_test.go | 33 ++++++++------- 9 files changed, 166 insertions(+), 159 deletions(-) diff --git a/go/avatars/appstate.go b/go/avatars/appstate.go index b14236929210..0f216f249e7b 100644 --- a/go/avatars/appstate.go +++ b/go/avatars/appstate.go @@ -10,16 +10,11 @@ import ( // 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, and monitorState/monitorWait record the state - // the monitor last acted on and the change channel it waits on for it; - // tests use them to wait until the monitor has caught up. - flushes int - monitorState keybase1.MobileAppState - monitorWait <-chan struct{} + mu sync.Mutex + stopCh chan struct{} + watcher *libkb.AppStateWatcher + // flushes counts flushes; tests use it. + flushes int } func (f *backgroundFlusher) start(m libkb.MetaContext, flush func(libkb.MetaContext)) { @@ -29,43 +24,28 @@ func (f *backgroundFlusher) start(m libkb.MetaContext, flush func(libkb.MetaCont return } f.stopCh = make(chan struct{}) - f.doneCh = make(chan struct{}) - go f.monitor(m, m.G().MobileAppState.State(), flush, f.stopCh, f.doneCh) + f.watcher = m.G().MobileAppState.NewWatcher() + stopCh, w := f.stopCh, f.watcher + go w.Run(m.G().MobileAppState.State(), stopCh, func(state keybase1.MobileAppState) bool { + if state == keybase1.MobileAppState_BACKGROUND { + flush(m) + f.mu.Lock() + f.flushes++ + f.mu.Unlock() + } + return true + }) } -// stop ends the monitor and waits for it to exit. +// stop ends the watcher 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 + stopCh, w := f.stopCh, f.watcher + f.stopCh, f.watcher = nil, nil f.mu.Unlock() if stopCh == nil { return } close(stopCh) - <-doneCh -} - -func (f *backgroundFlusher) monitor(m libkb.MetaContext, state keybase1.MobileAppState, flush func(libkb.MetaContext), - stopCh, doneCh chan struct{}, -) { - defer close(doneCh) - for { - next := m.G().MobileAppState.NextUpdate(state) - f.mu.Lock() - f.monitorState, f.monitorWait = state, next - f.mu.Unlock() - select { - case <-next: - case <-stopCh: - return - } - state = m.G().MobileAppState.State() - if state == keybase1.MobileAppState_BACKGROUND { - flush(m) - f.mu.Lock() - f.flushes++ - f.mu.Unlock() - } - } + w.Wait() } diff --git a/go/avatars/appstate_test.go b/go/avatars/appstate_test.go index 6ab62c8fc7c7..60cd5427229b 100644 --- a/go/avatars/appstate_test.go +++ b/go/avatars/appstate_test.go @@ -10,24 +10,20 @@ import ( "github.com/stretchr/testify/require" ) -// waitFlusher waits until f's monitor has acted on the current state and is +// waitFlusher waits until f's watcher has acted on the current state and is // waiting for the next change. -func waitFlusher(t *testing.T, g *libkb.GlobalContext, f *backgroundFlusher) { +func waitFlusher(t *testing.T, f *backgroundFlusher) { t.Helper() require.Eventually(t, func() bool { f.mu.Lock() - state, wait := f.monitorState, f.monitorWait + w := f.watcher f.mu.Unlock() - if wait == nil || wait != g.MobileAppState.NextUpdate(state) { + if w == nil { return false } - select { - case <-wait: - return false - default: - return true - } - }, 10*time.Second, time.Millisecond, "monitor did not catch up") + _, caughtUp := w.CaughtUp() + return caughtUp + }, 10*time.Second, time.Millisecond, "watcher did not catch up") } func flushes(f *backgroundFlusher) int { @@ -70,7 +66,7 @@ func TestAvatarsFlushSeedsFromState(t *testing.T) { tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) s.StartBackgroundTasks(m) defer s.StopBackgroundTasks(m) - waitFlusher(t, tc.G, s.flusher()) + waitFlusher(t, s.flusher()) require.Equal(t, 0, flushes(s.flusher()), "flushed without a transition into BACKGROUND") for _, next := range []keybase1.MobileAppState{ @@ -79,7 +75,7 @@ func TestAvatarsFlushSeedsFromState(t *testing.T) { keybase1.MobileAppState_BACKGROUND, } { tc.G.MobileAppState.Update(next) - waitFlusher(t, tc.G, s.flusher()) + waitFlusher(t, s.flusher()) } require.Equal(t, 1, flushes(s.flusher())) }) @@ -96,7 +92,7 @@ func TestAvatarsMonitorExitsOnStop(t *testing.T) { const cycles = 50 for range cycles { s.StartBackgroundTasks(m) - waitFlusher(t, tc.G, s.flusher()) + waitFlusher(t, s.flusher()) s.StopBackgroundTasks(m) } require.Eventually(t, func() bool { diff --git a/go/chat/archive.go b/go/chat/archive.go index 1bbeecebb6ba..11cd44bd0e17 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -56,11 +56,8 @@ type ChatArchiveRegistry struct { // pauseEpoch counts background pauses. A launched job that registers // after one is paused right away, since the pause could not reach it. pauseEpoch uint64 - // monitorState is the state the current run's monitor last acted on, - // and monitorWait the change channel it waits on for that state; tests - // use them to wait until the monitor has caught up. - monitorState keybase1.MobileAppState - monitorWait <-chan struct{} + // watcher is the current run's app-state watcher, nil between runs. + watcher *libkb.AppStateWatcher // 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 @@ -250,25 +247,13 @@ func (r *ChatArchiveRegistry) launchLocked(ctx context.Context, req chat1.Archiv }() } -func (r *ChatArchiveRegistry) monitorAppState(stopCh chan struct{}, eg *errgroup.Group, - state keybase1.MobileAppState, cancelInitialResume context.CancelFunc, +func (r *ChatArchiveRegistry) monitorAppState(w *libkb.AppStateWatcher, stopCh chan struct{}, + eg *errgroup.Group, state keybase1.MobileAppState, cancelInitialResume context.CancelFunc, ) error { // cancelResume cancels the resume scheduled for the last FOREGROUND. cancelResume := cancelInitialResume defer func() { cancelResume() }() - for { - next := r.G().MobileAppState.NextUpdate(state) - r.Lock() - if r.stopCh == stopCh { - r.monitorState, r.monitorWait = state, next - } - r.Unlock() - select { - case <-stopCh: - return nil - case <-next: - } - state = r.G().MobileAppState.State() + w.Run(state, stopCh, func(state keybase1.MobileAppState) bool { r.Debug(context.Background(), "monitorAppState: next state -> %v", state) cancelResume() switch state { @@ -292,7 +277,9 @@ func (r *ChatArchiveRegistry) monitorAppState(stopCh chan struct{}, eg *errgroup err = r.bgPauseAllJobsLocked(ctx) }() } - } + return true + }) + return nil } // Resumes previously BACKGROUND_PAUSED jobs, after a delay, if the app is in @@ -310,6 +297,8 @@ func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { r.eg = new(errgroup.Group) stopCh, eg := r.stopCh, r.eg state := r.G().MobileAppState.State() + r.watcher = r.G().MobileAppState.NewWatcher() + w := r.watcher resumeCtx, cancelResume := context.WithCancel(context.Background()) eg.Go(func() error { return r.flushLoop(stopCh) @@ -318,7 +307,7 @@ func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { return r.resumeAllBgJobs(resumeCtx, stopCh) }) eg.Go(func() error { - return r.monitorAppState(stopCh, eg, state, cancelResume) + return r.monitorAppState(w, stopCh, eg, state, cancelResume) }) } @@ -362,7 +351,7 @@ func (r *ChatArchiveRegistry) Stop(ctx context.Context) chan struct{} { } r.started = false close(r.stopCh) - r.monitorWait = nil + r.watcher = nil eg := r.eg go func() { r.Debug(context.Background(), "Stop: waiting for shutdown") diff --git a/go/chat/archive_appstate_test.go b/go/chat/archive_appstate_test.go index c5abbe73bc44..40b61fc689a5 100644 --- a/go/chat/archive_appstate_test.go +++ b/go/chat/archive_appstate_test.go @@ -116,17 +116,13 @@ func waitArchiveMonitor(t *testing.T, r *ChatArchiveRegistry) { t.Helper() require.Eventually(t, func() bool { r.Lock() - state, wait := r.monitorState, r.monitorWait + w := r.watcher r.Unlock() - if wait == nil || wait != r.G().MobileAppState.NextUpdate(state) { + if w == nil { return false } - select { - case <-wait: - return false - default: - return true - } + _, caughtUp := w.CaughtUp() + return caughtUp }, 10*time.Second, time.Millisecond, "monitor did not catch up") } diff --git a/go/chat/convloader.go b/go/chat/convloader.go index b74af652bf64..e713e1f44102 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -139,11 +139,8 @@ type BackgroundConvLoader struct { // appSuspended is the app-state monitor's own suspension, kept apart // from suspendCount so an unbalanced Resume cannot release it. appSuspended bool - // monitorState is the state the current run's monitor last acted on, - // and monitorWait the change channel it waits on for that state; tests - // use them to wait until the monitor has caught up. - monitorState keybase1.MobileAppState - monitorWait <-chan struct{} + // watcher is the current run's app-state watcher, nil between runs. + watcher *libkb.AppStateWatcher // for testing, make this and can check conv load successes loads chan chat1.ConversationID @@ -202,40 +199,32 @@ func (b *BackgroundConvLoader) setAppStateLocked(ctx context.Context, state keyb b.signalSuspendLocked(ctx, wasSuspended) } -func (b *BackgroundConvLoader) monitorAppState(stopCh chan struct{}, state keybase1.MobileAppState) error { +func (b *BackgroundConvLoader) monitorAppState(w *libkb.AppStateWatcher, stopCh chan struct{}, + state keybase1.MobileAppState, +) error { ctx := context.Background() b.Debug(ctx, "monitorAppState: starting up in %v", state) - for { - next := b.G().MobileAppState.NextUpdate(state) - b.Lock() - if b.stopCh == stopCh { - b.monitorState, b.monitorWait = state, next - } - b.Unlock() - select { - case <-next: - case <-stopCh: - b.Debug(ctx, "monitorAppState: shutting down") - return nil - } + w.Run(state, stopCh, func(keybase1.MobileAppState) bool { b.Lock() if b.stopCh != stopCh { b.Unlock() - return nil + return false } // Read and apply under the lock, so Start and Stop never interleave // with a decision made on a stale state. - state = b.G().MobileAppState.State() - b.setAppStateLocked(ctx, state) + b.setAppStateLocked(ctx, b.G().MobileAppState.State()) b.Unlock() if b.appStateCh != nil { select { case b.appStateCh <- struct{}{}: case <-stopCh: - return nil + return false } } - } + return true + }) + b.Debug(ctx, "monitorAppState: shutting down") + return nil } func (b *BackgroundConvLoader) Start(ctx context.Context, uid gregor1.UID) { @@ -267,9 +256,11 @@ func (b *BackgroundConvLoader) Start(ctx context.Context, uid gregor1.UID) { } state := b.G().MobileAppState.State() b.setAppStateLocked(ctx, state) + b.watcher = b.G().MobileAppState.NewWatcher() + w := b.watcher eg.Go(func() error { return b.loop(uid, stopCh, suspendCh, queue, loadCh) }) eg.Go(func() error { return b.loadLoop(uid, stopCh, queue, loadCh) }) - eg.Go(func() error { return b.monitorAppState(stopCh, state) }) + eg.Go(func() error { return b.monitorAppState(w, stopCh, state) }) } // endRunLocked stops the current run's goroutines and returns their group. @@ -280,7 +271,7 @@ func (b *BackgroundConvLoader) endRunLocked() *errgroup.Group { close(b.stopCh) b.stopCh = make(chan struct{}) b.eg = new(errgroup.Group) - b.monitorWait = nil + b.watcher = nil return eg } diff --git a/go/chat/convloader_appstate_test.go b/go/chat/convloader_appstate_test.go index 5a09cfeeb5bd..7d96c5cc5296 100644 --- a/go/chat/convloader_appstate_test.go +++ b/go/chat/convloader_appstate_test.go @@ -62,17 +62,13 @@ func waitConvLoaderMonitor(t *testing.T, b *BackgroundConvLoader) { t.Helper() require.Eventually(t, func() bool { b.Lock() - state, wait := b.monitorState, b.monitorWait + w := b.watcher b.Unlock() - if wait == nil || wait != b.G().MobileAppState.NextUpdate(state) { + if w == nil { return false } - select { - case <-wait: - return false - default: - return true - } + _, caughtUp := w.CaughtUp() + return caughtUp }, 10*time.Second, time.Millisecond, "monitor did not catch up") } diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 3b5b5a5c7683..0cc141e639a7 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -125,6 +125,70 @@ func (a *MobileAppState) StateAndMtime() (keybase1.MobileAppState, *time.Time) { return a.state, a.mtime } +// AppStateWatcher is the loop shared by the background workers that do nothing +// but watch the app state: wait for the next change, act on the new state, +// repeat. The caller runs it on a goroutine of its own, since the workers hang +// that goroutine off their own errgroup or done channel and do their own +// accounting when it returns. +type AppStateWatcher struct { + a *MobileAppState + mu sync.Mutex + // state is what Run last acted on and wait the change channel it waits on + // for that state; CaughtUp reports them. + state keybase1.MobileAppState + wait <-chan struct{} + done chan struct{} +} + +func (a *MobileAppState) NewWatcher() *AppStateWatcher { + return &AppStateWatcher{a: a, done: make(chan struct{})} +} + +// Run calls onChange with each new app state, starting from state, until +// stopCh closes or onChange returns false. onChange runs on Run's goroutine +// and does its own locking. +func (w *AppStateWatcher) Run(state keybase1.MobileAppState, stopCh <-chan struct{}, + onChange func(keybase1.MobileAppState) bool, +) { + defer close(w.done) + for { + next := w.a.NextUpdate(state) + w.mu.Lock() + w.state, w.wait = state, next + w.mu.Unlock() + select { + case <-next: + case <-stopCh: + return + } + state = w.a.State() + if !onChange(state) { + return + } + } +} + +// Wait blocks until Run has returned. +func (w *AppStateWatcher) Wait() { <-w.done } + +// CaughtUp reports the state Run last acted on, and whether it has acted on +// the current state and is waiting for the next change. Tests use it to wait +// until a watcher has caught up. +func (w *AppStateWatcher) CaughtUp() (keybase1.MobileAppState, bool) { + w.mu.Lock() + state, wait := w.state, w.wait + w.mu.Unlock() + if wait == nil || wait != w.a.NextUpdate(state) { + return state, false + } + select { + case <-wait: + return state, false + default: + return state, true + } +} + // -------------------------------------------------- // MobileNetState tracks the state of the network status of the app in which diff --git a/go/libkb/leveldb_cleaner.go b/go/libkb/leveldb_cleaner.go index 58ff9dd6cd37..142bd542e5c8 100644 --- a/go/libkb/leveldb_cleaner.go +++ b/go/libkb/leveldb_cleaner.go @@ -71,14 +71,12 @@ type levelDbCleaner struct { db *leveldb.DB stopCh chan struct{} cancelCh chan struct{} - // monitoring is whether an app-state monitor runs for the current stopCh. + // monitoring is whether an app-state monitor runs for the current stopCh, + // and watcher is that monitor's watcher. monitoring bool - // monitors counts running monitor goroutines, and monitorState and - // monitorWait record the state the monitor last acted on and the change - // channel it waits on for that state; tests use them. - monitors int - monitorState keybase1.MobileAppState - monitorWait <-chan struct{} + watcher *AppStateWatcher + // monitors counts running monitor goroutines; tests use it. + monitors int isShutdown bool } @@ -131,6 +129,7 @@ func (c *levelDbCleaner) Stop() { c.stopCh = make(chan struct{}) } c.monitoring = false + c.watcher = nil } // start attaches the cleaner to a newly opened db, undoing a previous @@ -152,45 +151,36 @@ func (c *levelDbCleaner) start(db *leveldb.DB) { } c.monitoring = true c.monitors++ - go c.monitorAppState(c.stopCh, c.G().MobileAppState.State()) + c.watcher = c.G().MobileAppState.NewWatcher() + go c.monitorAppState(c.watcher, c.stopCh, c.G().MobileAppState.State()) } // monitorAppState cancels a running clean whenever the app moves to any state // other than BACKGROUNDACTIVE. A clean may start in any state; it keeps // running only across a transition into BACKGROUNDACTIVE, so it gives way // when the app comes to the foreground and before it is suspended. -func (c *levelDbCleaner) monitorAppState(stopCh chan struct{}, state keybase1.MobileAppState) { +func (c *levelDbCleaner) monitorAppState(w *AppStateWatcher, stopCh chan struct{}, state keybase1.MobileAppState) { c.log("monitorAppState: starting in %v", state) defer func() { + c.log("monitorAppState: stop") c.Lock() defer c.Unlock() c.monitors-- }() - for { - next := c.G().MobileAppState.NextUpdate(state) - c.Lock() - c.monitorState, c.monitorWait = state, next - c.Unlock() - select { - case <-next: - case <-stopCh: - c.log("monitorAppState: stop") - return - } - state = c.G().MobileAppState.State() + w.Run(state, stopCh, func(state keybase1.MobileAppState) bool { if state == keybase1.MobileAppState_BACKGROUNDACTIVE { - continue + return true } c.log("monitorAppState: attempting cancel, state: %v", state) c.Lock() + defer c.Unlock() if c.stopCh != stopCh { - c.Unlock() - return + return false } close(c.cancelCh) c.cancelCh = make(chan struct{}) - c.Unlock() - } + return true + }) } func (c *levelDbCleaner) log(format string, args ...any) { diff --git a/go/libkb/leveldb_cleaner_test.go b/go/libkb/leveldb_cleaner_test.go index f6cb914baaed..6bb4ae68f26d 100644 --- a/go/libkb/leveldb_cleaner_test.go +++ b/go/libkb/leveldb_cleaner_test.go @@ -35,23 +35,28 @@ func (c *levelDbCleaner) snapshot() (cancelCh chan struct{}, monitors int) { return c.cancelCh, c.monitors } +// cleanerWatcher returns the watcher of the cleaner's current monitor, and +// nil unless exactly one monitor runs. +func (c *levelDbCleaner) cleanerWatcher() *AppStateWatcher { + c.Lock() + defer c.Unlock() + if c.monitors != 1 { + return nil + } + return c.watcher +} + // waitCleanerMonitor waits until the cleaner's monitor has acted on the // current state and is waiting for the next change. func waitCleanerMonitor(t *testing.T, c *levelDbCleaner) { t.Helper() require.Eventually(t, func() bool { - c.Lock() - state, wait, monitors := c.monitorState, c.monitorWait, c.monitors - c.Unlock() - if monitors != 1 || wait == nil || wait != c.G().MobileAppState.NextUpdate(state) { - return false - } - select { - case <-wait: + w := c.cleanerWatcher() + if w == nil { return false - default: - return true } + _, caughtUp := w.CaughtUp() + return caughtUp }, 10*time.Second, time.Millisecond, "cleaner monitor did not catch up") } @@ -218,10 +223,10 @@ func TestLevelDbCleanerScenarioReplay(t *testing.T) { cancelCh, _ := db.cleaner.snapshot() h.Do(step) waitCleanerMonitor(t, db.cleaner) - db.cleaner.Lock() - monitorState := db.cleaner.monitorState - db.cleaner.Unlock() - require.Equal(t, step.Want, monitorState, "step %d %v", i, step.Do) + w := db.cleaner.cleanerWatcher() + require.NotNil(t, w, "step %d %v", i, step.Do) + acted, _ := w.CaughtUp() + require.Equal(t, step.Want, acted, "step %d %v", i, step.Do) canceled := isClosed(cancelCh) switch { case step.Want != prev && step.Want != keybase1.MobileAppState_BACKGROUNDACTIVE: From a3cf5b704044b9f84fb5622c12851fc973b86f89 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:23:35 -0400 Subject: [PATCH 067/127] refactor(lifecycle): let a background task's lifetime ride on its hold The tasks map had two readers and neither needed a map: Close waits on a WaitGroup, and WaitBackgroundTask waits on the hold, which is the question native is really asking -- does Go still need background time. It takes the lock again afterwards, so the state the hold's end derives is written before the caller gives its background time up. Hold.Released goes with it. Its one caller guarded against a hold the controller had ended, but only WillTerminate ends a live location hold, and nothing outlives that. AcquireBackgroundWork loses its Reason parameter too: every caller passed ReasonLiveLocation, and it is the only reason no controller event matches, so fixing it inside keeps the other holds' matchers honest. Live location's boolean sync call becomes two named methods. --- go/chat/maps/livelocation.go | 36 ++++++----- go/libkb/lifecycle/controller_test.go | 15 ++--- go/libkb/lifecycle/lifecycle.go | 69 ++++++++++----------- go/libkb/lifecycle/lifecycletest/harness.go | 2 +- 4 files changed, 62 insertions(+), 60 deletions(-) diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 3798c025556f..ec1b86d82a7e 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -34,7 +34,8 @@ type LiveLocationTracker struct { lastCoord chat1.Coordinate maxCoords int // bgHold keeps the app running while tracking; guarded by the tracker's - // mutex and changed only by syncHoldLocked. + // mutex and changed only by releaseHoldIfIdleLocked and + // ensureHoldOnFixLocked. bgHold *lifecycle.Hold nativeWatchMu sync.Mutex @@ -102,22 +103,24 @@ 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.syncHoldLocked(false) + l.releaseHoldIfIdleLocked() } -// syncHoldLocked ties bgHold to the trackers map: no trackers means no hold, -// and a fix while tracking opens one if none is open (the controller may have -// ended it). Every removal from the map and every fix calls it. -func (l *LiveLocationTracker) syncHoldLocked(fix bool) { - switch { - case len(l.trackers) == 0: - if l.bgHold != nil { - l.bgHold.Release() - l.bgHold = nil - } - case fix && l.G().IsMobileAppType() && (l.bgHold == nil || l.bgHold.Released()): - // A location update can wake a backgrounded app; hold it up so the update gets out. - l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) +// 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. +func (l *LiveLocationTracker) ensureHoldOnFixLocked() { + l.releaseHoldIfIdleLocked() + if len(l.trackers) > 0 && l.G().IsMobileAppType() && l.bgHold == nil { + l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork() } } @@ -146,7 +149,6 @@ func (l *LiveLocationTracker) runRestoredLocked(trackers []*locationTrack) { return l.tracker(myT) }) } - l.syncHoldLocked(false) } func (l *LiveLocationTracker) getLastCoord() chat1.Coordinate { @@ -453,7 +455,7 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co defer l.Trace(ctx, nil, "LocationUpdate")() l.Lock() defer l.Unlock() - l.syncHoldLocked(true) + l.ensureHoldOnFixLocked() if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") return diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index af6e81d0365e..5060c0ed44eb 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -45,15 +45,16 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Zero(t, c.UIBackground(false, noDeliveries())) require.Equal(t, background, appState.State()) require.Equal(t, 1, flushes) - first := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) + first := c.AcquireBackgroundWork() require.Equal(t, backgroundActive, appState.State()) require.True(t, first.Release()) - require.True(t, first.Released()) + require.Zero(t, lifecycle.Holds(c)) require.Equal(t, background, appState.State()) require.Equal(t, 2, flushes) - second := c.AcquireBackgroundWork(lifecycle.ReasonPushWindow) + second := c.AcquireBackgroundWork() require.False(t, first.Release()) - require.False(t, second.Released()) + // 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()) @@ -67,7 +68,7 @@ func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { defer c.Close() require.Positive(t, c.UIBackground(true, noDeliveries())) push := c.PushWindowBegin() - live := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + live := c.AcquireBackgroundWork() notified := 0 c.BackgroundTaskExpired(func() { notified++ }) require.Equal(t, 1, notified) @@ -159,7 +160,7 @@ func TestHoldsStress(t *testing.T) { runOwner(func(*rand.Rand) { c.BackgroundSync() }) runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) runOwner(func(r *rand.Rand) { - h := c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + h := c.AcquireBackgroundWork() time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) h.Release() }) @@ -210,7 +211,7 @@ func TestHoldsStress(t *testing.T) { holders.Add(1) go func() { defer holders.Done() - c.AcquireBackgroundWork(lifecycle.ReasonPushWindow).Release() + c.AcquireBackgroundWork().Release() }() } waitGroupWithin(t, &holders, "holders deadlocked") diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 01e5b3f03ec7..6e03960e9083 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -14,8 +14,6 @@ import ( "context" "errors" "fmt" - "maps" - "slices" "sync" "time" @@ -88,15 +86,6 @@ type Hold struct { done chan struct{} } -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) } @@ -108,14 +97,17 @@ type Controller struct { 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 - // tasks maps each running background task's hold id to a channel closed - // when its goroutine returns. - tasks map[int64]chan struct{} + // 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 { @@ -137,7 +129,7 @@ func New(appState AppState, cfg Config) *Controller { if cfg.Debug == nil { cfg.Debug = func(string, ...interface{}) {} } - c := &Controller{appState: appState, cfg: cfg, holds: make(map[int64]*Hold), tasks: make(map[int64]chan struct{})} + 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: @@ -155,15 +147,13 @@ func New(appState AppState, cfg Config) *Controller { } // Close ends the background tasks the controller runs and waits for them to -// return. Tasks started later end at once. +// return. No task starts after it. func (c *Controller) Close() { c.cancel() c.mu.Lock() - tasks := slices.Collect(maps.Values(c.tasks)) + c.closed = true c.mu.Unlock() - for _, done := range tasks { - <-done - } + c.wg.Wait() } func derive(ui UIState, holds int) keybase1.MobileAppState { @@ -229,27 +219,27 @@ func (c *Controller) setUILocked(ui UIState) { // startTaskLocked opens a background task hold and runs the task that keeps // it until the work is done. func (c *Controller) startTaskLocked(deps BackgroundTaskDeps) int64 { + if c.closed { + return 0 + } h := c.acquireLocked(ReasonBackgroundTask) - done := make(chan struct{}) - c.tasks[h.id] = done + c.wg.Add(1) go func() { + defer c.wg.Done() c.runBackgroundTask(h, deps) - c.mu.Lock() - delete(c.tasks, h.id) - c.mu.Unlock() - close(done) }() return h.id } -// AcquireBackgroundWork opens a hold that keeps a backgrounded app -// BACKGROUNDACTIVE until it is released. -func (c *Controller) AcquireBackgroundWork(reason Reason) *Hold { +// AcquireBackgroundWork opens a live location hold, which keeps a backgrounded +// app BACKGROUNDACTIVE until it is released. It is the only hold no controller +// event ends, so it is the only one callers may open for themselves. +func (c *Controller) AcquireBackgroundWork() *Hold { c.mu.Lock() defer c.mu.Unlock() - h := c.acquireLocked(reason) + h := c.acquireLocked(ReasonLiveLocation) c.applyLocked() - c.debugLocked("acquire", "%v hold %d", reason, h.id) + c.debugLocked("acquire", "%v hold %d", h.reason, h.id) return h } @@ -299,14 +289,23 @@ func (c *Controller) UIBackground(stay bool, deps BackgroundTaskDeps) int64 { return token } -// WaitBackgroundTask returns once the background task at token has returned. +// 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() - done, ok := c.tasks[token] + h := c.holds[token] c.mu.Unlock() - if ok { - <-done + 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 diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index b1d9192deeb1..3bdef60b4808 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -304,7 +304,7 @@ func (h *Harness) perform(step Step) bool { case PushWindowEnd: return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], h.stay.Load(), h.deps()) }) case LiveLocationAcquire: - h.liveLocation = c.AcquireBackgroundWork(lifecycle.ReasonLiveLocation) + h.liveLocation = c.AcquireBackgroundWork() case LiveLocationRelease: require.NotNil(h.T, h.liveLocation, "LiveLocationRelease without LiveLocationAcquire") h.liveLocation.Release() From 6ea3addfd2dca861fc3e5ff692bcaff6d32be90c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:23:35 -0400 Subject: [PATCH 068/127] refactor(kbhttp): New stops returning an error The error had one consumer -- kbfs, which treated a failed first bind as fatal, while the service already discarded it with a comment saying the next app-state change retries. Both now get the retry, ready is a plain close-only barrier, and the run loop drops its per-iteration nil check. start and reconcile lose error returns nobody read. Srv.Active goes too: it was status.Load().Address != "", which is the exact condition Addr already reports, and its one caller ran both in sequence. Srv takes the two app-state functions instead of an interface, which deletes kbfs's ten-line adapter for methods that differ only in name. --- go/chat/attachment_httpsrv.go | 6 +- go/chat/attachment_httpsrv_appstate_test.go | 16 +++- go/kbfs/libhttpserver/server.go | 18 +---- go/kbhttp/manager/manager.go | 87 ++++++++++----------- go/kbhttp/manager/manager_test.go | 82 +++++++++++++------ 5 files changed, 118 insertions(+), 91 deletions(-) diff --git a/go/chat/attachment_httpsrv.go b/go/chat/attachment_httpsrv.go index 05fff2a37601..c4efbea3a232 100644 --- a/go/chat/attachment_httpsrv.go +++ b/go/chat/attachment_httpsrv.go @@ -121,13 +121,9 @@ 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, 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) diff --git a/go/chat/attachment_httpsrv_appstate_test.go b/go/chat/attachment_httpsrv_appstate_test.go index 986f5b30e8ba..e4e64e4fa690 100644 --- a/go/chat/attachment_httpsrv_appstate_test.go +++ b/go/chat/attachment_httpsrv_appstate_test.go @@ -22,6 +22,16 @@ type startOnlyAttachmentFetcher struct { func (startOnlyAttachmentFetcher) OnStart(libkb.MetaContext) {} +// requireSrvServing waits until the server does or does not have an address to +// hand out, which is what decides whether a URL can be built. +func requireSrvServing(t *testing.T, srv *manager.Srv, serving bool) { + t.Helper() + require.Eventually(t, func() bool { + _, err := srv.Addr() + return (err == nil) == serving + }, 10*time.Second, time.Millisecond, "server serving != %v", serving) +} + func TestAttachmentURLsEmptyWhileServerStopped(t *testing.T) { tc := externalstest.SetupTest(t, "attachment-url-stopped", 0) defer tc.Cleanup() @@ -51,7 +61,7 @@ func TestAttachmentURLsEmptyWhileServerStopped(t *testing.T) { return res } - require.Eventually(t, httpSrv.Active, 10*time.Second, time.Millisecond) + requireSrvServing(t, httpSrv, true) up := get() for _, url := range []string{up.full, up.preview, up.emoji, up.emojiNoAnim, up.emojiNoAnimOnly} { require.True(t, strings.HasPrefix(url, "http://"), "url %q while serving", url) @@ -59,10 +69,10 @@ func TestAttachmentURLsEmptyWhileServerStopped(t *testing.T) { require.Contains(t, up.preview, "&prev=true") tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - require.Eventually(t, func() bool { return !httpSrv.Active() }, 10*time.Second, time.Millisecond) + requireSrvServing(t, httpSrv, false) require.Equal(t, urls{}, get()) tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - require.Eventually(t, httpSrv.Active, 10*time.Second, time.Millisecond) + requireSrvServing(t, httpSrv, true) require.True(t, strings.HasPrefix(get().full, "http://")) } diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index 82ddc39d7ec0..409efa8e8218 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -219,17 +219,6 @@ const ( requestPathRoot = "/files/" ) -// appState adapts env.AppStateUpdater to manager.AppState. -type appState struct { - env.AppStateUpdater -} - -func (a appState) State() keybase1.MobileAppState { return a.AppState() } - -func (a appState) NextUpdate(last keybase1.MobileAppState) <-chan struct{} { - return a.NextAppStateUpdate(last) -} - // New creates and starts a new server. func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( s *Server, err error, @@ -248,14 +237,11 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( if err != nil { return nil, err } - s.server, err = manager.New("kbfsHTTP", logger, appState{appStateUpdater}, + // A failed first start is logged and the next app state change tries again. + s.server = 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 - } // 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, diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index e4974a842d65..46181d1c874f 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -34,19 +34,16 @@ type handlerRequest struct { done chan struct{} } -// AppState is the app state a Srv follows. -type AppState interface { - State() keybase1.MobileAppState - NextUpdate(lastState keybase1.MobileAppState) <-chan struct{} -} - // Srv runs a local HTTP server. One goroutine, run, owns it: only run starts // and stops it, reacting to app state changes, unexpected exits, handler // registrations and shutdown. type Srv struct { - name string // prefixes every log line, so each server's lines are told apart - log logger.Logger - appState AppState + 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 @@ -76,13 +73,13 @@ func NewSrv(g *libkb.GlobalContext) *Srv { listenerSource := func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) } - // A failed start is logged, and the next app state change tries again. - r, _ := New("Srv", g.GetLog(), g.MobileAppState, 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)) - // Read NotifyRouter when notifying: the service sets it after creating this server. - g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) - }) + 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)) + // Read NotifyRouter when notifying: the service sets it after creating this server. + g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + }) g.PushShutdownHook(func(libkb.MetaContext) error { r.Shutdown() return nil @@ -90,16 +87,20 @@ func NewSrv(g *libkb.GlobalContext) *Srv { 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. -func New(name string, log logger.Logger, appState AppState, listenerSource func() kbhttp.ListenerSource, stopInBackground bool, +// New returns a server that has acted on the current app state. A failed first +// start is logged and the next app state change tries again; the server runs +// until Shutdown either way. +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) { +) *Srv { token, _ := libkb.RandHexString("", 32) r := &Srv{ name: name, log: log, appState: appState, + nextAppState: nextAppState, token: token, listenerSource: listenerSource, stopInBackground: stopInBackground, @@ -113,9 +114,10 @@ func New(name string, log logger.Logger, appState AppState, listenerSource func( // Publish an empty status before run can be observed, so readers never dereference nil. r.status.Store(&keybase1.HttpSrvInfo{}) r.httpSrv = r.newHTTPSrv() - ready := make(chan error) + ready := make(chan struct{}) go r.run(ready) - return r, <-ready + <-ready + return r } func (r *Srv) debug(ctx context.Context, msg string, args ...any) { @@ -145,23 +147,22 @@ func (r *Srv) wantUp(state keybase1.MobileAppState) bool { return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } -func (r *Srv) run(ready chan<- error) { +// run owns the server. ready is closed once it has acted on the app state it +// started in and published the result. +func (r *Srv) run(ready chan<- struct{}) { defer close(r.done) ctx := context.Background() - r.state = r.appState.State() + r.state = r.appState() r.debug(ctx, "run: starting up in %v", r.state) - err := r.reconcile(ctx) + r.reconcile(ctx) + r.publish() + close(ready) for { - r.publish() - if ready != nil { - ready <- err - ready = nil - } select { - case <-r.appState.NextUpdate(r.state): - r.state = r.appState.State() + case <-r.nextAppState(r.state): + r.state = r.appState() r.restartedSinceChange = false - _ = r.reconcile(ctx) + r.reconcile(ctx) case <-r.exited: r.serverExited(ctx) case req := <-r.handlers: @@ -176,18 +177,19 @@ func (r *Srv) run(ready chan<- error) { r.status.Store(&keybase1.HttpSrvInfo{}) return } + r.publish() } } // 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 { +func (r *Srv) reconcile(ctx context.Context) { if !r.wantUp(r.state) { r.httpSrv.Stop() - return nil + return } - return r.start(ctx) + r.start(ctx) } // serverExited restarts a server whose listener died without a Stop, for @@ -202,12 +204,12 @@ func (r *Srv) serverExited(ctx context.Context) { } r.restartedSinceChange = true r.debug(ctx, "serverExited: restarting in %v", r.state) - _ = r.start(ctx) + r.start(ctx) } -func (r *Srv) start(ctx context.Context) error { +func (r *Srv) start(ctx context.Context) { if r.httpSrv.Active() { - return nil + return } err := r.httpSrv.StartWithHandlers(r.registerEndpoints) if errors.Is(err, kbhttp.ErrPinnedPortInUse) { @@ -219,15 +221,14 @@ func (r *Srv) start(ctx context.Context) error { } if err != nil { r.log.CWarningf(ctx, "%s: start: failed to start HTTP server: %s", r.name, err) - return err + return } // Publish before notifying, so a listener reading Info gets the address it is told about. info := r.publish() if info.Address == "" { // Serve already exited; run handles that exit next - return nil + return } r.notify(ctx, info) - return nil } func (r *Srv) publish() keybase1.HttpSrvInfo { @@ -281,8 +282,6 @@ func (r *Srv) checkToken(tokenMode SrvTokenMode, } } -func (r *Srv) Active() bool { return r.status.Load().Address != "" } - func (r *Srv) Addr() (string, error) { info, err := r.Info() return info.Address, err diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index db92e83d3ae7..357e11055ce6 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -141,7 +141,23 @@ func (a *appState) NextUpdate(last keybase1.MobileAppState) <-chan struct{} { return wait } -func app(srv *Srv) *appState { return srv.appState.(*appState) } +// 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 { + return nil + } + return a.(*appState) +} + +// active reports whether srv has an address to hand out. +func active(srv *Srv) bool { + _, err := srv.Addr() + return err == nil +} func setup(t *testing.T, state keybase1.MobileAppState, stopInBackground bool) (*Srv, *listeners) { return setupWithNotify(t, state, stopInBackground, func(context.Context, keybase1.HttpSrvInfo) {}) @@ -154,11 +170,13 @@ func setupWithNotify(t *testing.T, state keybase1.MobileAppState, stopInBackgrou t.Cleanup(tc.Cleanup) tc.G.MobileAppState.Update(state) l := &listeners{} - srv, err := New("Srv", tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, stopInBackground, notify) - require.NoError(t, err) + as := &appState{MobileAppState: tc.G.MobileAppState} + srv := New("Srv", tc.G.Log, as.State, as.NextUpdate, l.source, stopInBackground, notify) + 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), srv.Active(), "launch state not applied when New returned") + require.Equal(t, srv.wantUp(state), active(srv), "launch state not applied when New returned") srv.HandleFunc("test", SrvTokenModeDefault, func(w http.ResponseWriter, req *http.Request) { fmt.Fprint(w, "ok") }) @@ -232,7 +250,7 @@ func killUntilDown(t *testing.T, srv *Srv, l *listeners) { n := turns(srv) l.kill(t) waitTurns(t, srv, n+1) - if !srv.Active() { + if !active(srv) { return } } @@ -241,7 +259,7 @@ func killUntilDown(t *testing.T, srv *Srv, l *listeners) { func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { t.Helper() - require.True(t, srv.Active(), "server not active") + require.True(t, active(srv), "server not active") info, err := srv.Info() require.NoError(t, err) _, err = fetch(info) @@ -251,7 +269,7 @@ func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { func requireStopped(t *testing.T, srv *Srv) { t.Helper() - require.False(t, srv.Active(), "server still active") + require.False(t, active(srv), "server still active") _, err := srv.Info() require.Error(t, err) } @@ -350,7 +368,7 @@ func TestNothingStartsAfterShutdown(t *testing.T) { case <-time.After(10 * time.Second): require.Fail(t, "HandleFunc hung after Shutdown") } - require.Never(t, func() bool { return srv.Active() || l.Calls() != calls }, 200*time.Millisecond, 10*time.Millisecond) + require.Never(t, func() bool { return active(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. @@ -524,22 +542,38 @@ func TestNotStoppingInBackgroundStaysUp(t *testing.T) { } } -type failingSource struct{} +// brokenSource makes no listener while it is broken. +type brokenSource struct { + broken *atomic.Bool + src kbhttp.ListenerSource +} -func (failingSource) GetListener() (net.Listener, string, error) { - return nil, "", errors.New("no listener") +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. -func TestNewReturnsFirstStartError(t *testing.T) { +// A failed first start is not fatal: New returns a server that is not serving, +// and the next app state change starts it. +func TestNewSurvivesFirstStartFailure(t *testing.T) { tc := libkb.SetupTest(t, "kbhttp", 2) defer tc.Cleanup() tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - srv, err := New("Srv", tc.G.Log, tc.G.MobileAppState, func() kbhttp.ListenerSource { return failingSource{} }, true, - func(context.Context, keybase1.HttpSrvInfo) {}) - require.Error(t, err) + broken := &atomic.Bool{} + broken.Store(true) + srv := 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) {}) + t.Cleanup(srv.Shutdown) requireStopped(t, srv) - srv.Shutdown() + + broken.Store(false) + tc.G.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + require.Eventually(t, func() bool { return active(srv) }, 10*time.Second, time.Millisecond, + "server did not start on the next app state change") } func TestScenarioReplay(t *testing.T) { @@ -550,12 +584,12 @@ func TestScenarioReplay(t *testing.T) { 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 srv.Active() { + if active(srv) { t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) } return } - if !srv.Active() { + if !active(srv) { t.Fatalf("step %d %v: server down in %v", i, step.Do, step.Want) } info, err := srv.Info() @@ -585,7 +619,7 @@ func TestPinnedPortTakenPicksNewAddress(t *testing.T) { stop := make(chan struct{}) var readers sync.WaitGroup for _, read := range []func(){ - func() { _ = srv.Active() }, + func() { _ = active(srv) }, func() { _, _ = srv.Addr() }, func() { _, _ = srv.Info() }, } { @@ -704,9 +738,11 @@ func TestStressTransitionsAndRequests(t *testing.T) { baseline := runtime.NumGoroutine() l := &listeners{} - srv, err := New("Srv", tc.G.Log, &appState{MobileAppState: tc.G.MobileAppState}, l.source, true, + as := &appState{MobileAppState: tc.G.MobileAppState} + srv := 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") }) @@ -743,7 +779,7 @@ func TestStressTransitionsAndRequests(t *testing.T) { if i < 200 { srv.HandleFunc(fmt.Sprintf("extra%d", i), SrvTokenModeUnchecked, func(http.ResponseWriter, *http.Request) {}) } - _ = srv.Active() + _ = active(srv) _, _ = srv.Addr() if info, err := srv.Info(); err == nil && info.Token != token { select { From a0f3aae3811889039059915447d9f9c58ad613ef Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:23:35 -0400 Subject: [PATCH 069/127] refactor(service): take the gregor gate's mutex in onGateIfCurrent gregorConnGate.do had one caller and expressed "take c.mu around this" in a function, a doc comment and a closure. Its comment moves onto onGateIfCurrent, where the lock order and the no-reentry rule are actually needed. Also drops chat/types.MobileAppState, which nothing references and whose signature stopped matching libkb's two refactors ago. --- go/chat/types/interfaces.go | 5 ----- go/service/gregor.go | 18 +++++++++--------- go/service/gregor_conn.go | 10 ---------- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/go/chat/types/interfaces.go b/go/chat/types/interfaces.go index 5239ba655b1f..cbf170ca3174 100644 --- a/go/chat/types/interfaces.go +++ b/go/chat/types/interfaces.go @@ -293,11 +293,6 @@ type PushHandler interface { OobmHandler } -type MobileAppState interface { - State() keybase1.MobileAppState - NextUpdate() chan keybase1.MobileAppState -} - type TeamChannelSource interface { GetLastActiveForTLF(context.Context, gregor1.UID, chat1.TLFID, chat1.TopicType) (gregor1.Time, error) GetLastActiveForTeams(context.Context, gregor1.UID, chat1.TopicType) (chat1.LastActiveTimeAll, error) diff --git a/go/service/gregor.go b/go/service/gregor.go index 633231faff29..185972a94f8a 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -890,16 +890,16 @@ func (g *gregorHandler) isCurrentConn(conn *rpc.Connection) bool { // onGateIfCurrent runs f under the connection gate if conn is still the // current connection, and reports whether it ran. Every Shutdown and Reset is // made under the gate too, so a disconnect lands entirely before f, and f is -// then skipped, or entirely after it. +// then skipped, or entirely after it. f must not call back into the gate: its +// mutex is not reentrant. The lock order is the gate's mu, then connMutex. func (g *gregorHandler) onGateIfCurrent(conn *rpc.Connection, f func()) bool { - ran := false - g.connGate.do(func() { - if g.isCurrentConn(conn) { - f() - ran = true - } - }) - return ran + g.connGate.mu.Lock() + defer g.connGate.mu.Unlock() + if !g.isCurrentConn(conn) { + return false + } + f() + return true } // connectSyncer marks the chat syncer connected for conn and syncs it. diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index fff946ddcd72..ac4b14c2e83b 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -83,16 +83,6 @@ func newGregorConnGate(mobile gregorAppState, desktop *libkb.DesktopAppState, co } } -// do runs f under the gate, so it cannot interleave with a connect, a reset, -// a reconnect or a reconcile, and so with none of the Shutdowns and Resets -// those make. f must not call back into the gate: mu is not reentrant. The -// lock order is mu, then the handler's connMutex. -func (c *gregorConnGate) do(f func()) { - c.mu.Lock() - defer c.mu.Unlock() - f() -} - func (c *gregorConnGate) canConnect(state keybase1.MobileAppState) bool { return state != keybase1.MobileAppState_BACKGROUND } From f8813fea4a59035ea6fda00f013d96b74aea2283 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:44:35 -0400 Subject: [PATCH 070/127] fix(livelocation): re-acquire a hold the controller ended Deleting Hold.Released took a behavior change with it: once WillTerminate ended the live location hold, bgHold stayed non-nil and no later fix opened a new one, so a location update after a termination warning ran with the app free to be suspended. Released comes back for that one check. Release cannot stand in for it -- on a hold that is still open, asking that way would end it -- and a release-and-reacquire would flip the app through BACKGROUND, cancelling live RPCs on the way. AcquireBackgroundWork's doc said no controller event ends this hold; WillTerminate does, and now it says so. --- go/chat/maps/livelocation.go | 7 +++++- go/chat/maps/livelocation_appstate_test.go | 29 ++++++++++++++++++++++ go/libkb/lifecycle/lifecycle.go | 18 ++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index ec1b86d82a7e..688513738b73 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -117,9 +117,14 @@ func (l *LiveLocationTracker) releaseHoldIfIdleLocked() { // 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() { l.releaseHoldIfIdleLocked() - if len(l.trackers) > 0 && l.G().IsMobileAppType() && l.bgHold == nil { + if len(l.trackers) == 0 || !l.G().IsMobileAppType() { + return + } + if l.bgHold == nil || l.bgHold.Released() { l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork() } } diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go index ac57f68daedc..b2db2028210c 100644 --- a/go/chat/maps/livelocation_appstate_test.go +++ b/go/chat/maps/livelocation_appstate_test.go @@ -60,3 +60,32 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { 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 + require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + 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") +} diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 6e03960e9083..d63b0fba16ba 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -86,6 +86,18 @@ type Hold struct { 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) } @@ -232,8 +244,10 @@ func (c *Controller) startTaskLocked(deps BackgroundTaskDeps) int64 { } // AcquireBackgroundWork opens a live location hold, which keeps a backgrounded -// app BACKGROUNDACTIVE until it is released. It is the only hold no controller -// event ends, so it is the only one callers may open for themselves. +// 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() From 8caa18a3d43239eed646b880dce1a3e7f8b69f57 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:44:35 -0400 Subject: [PATCH 071/127] fix(kbhttp): keep kbfs's failed first bind fatal New's retry rides on app state changes, and the app state only moves on mobile: the lifecycle controller is driven from go/bind, and the one hold a caller opens for itself is gated on IsMobileAppType. Desktop runs this server too (modeDefault enables it), so dropping the error turned a loud Fatalf into a warning and a permanently dead server, with GUI file previews quietly handing out empty URLs. So New reports its first start again and kbfs treats it as fatal, as before. What the simplification keeps: ready carries that error once, before the loop, instead of being re-checked on every turn. The service still discards it, as it did before this branch. --- go/kbfs/libhttpserver/server.go | 9 ++++-- go/kbhttp/manager/manager.go | 47 ++++++++++++++++--------------- go/kbhttp/manager/manager_test.go | 19 ++++++++----- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/go/kbfs/libhttpserver/server.go b/go/kbfs/libhttpserver/server.go index 409efa8e8218..35470bbb2d2b 100644 --- a/go/kbfs/libhttpserver/server.go +++ b/go/kbfs/libhttpserver/server.go @@ -237,11 +237,16 @@ func New(appStateUpdater env.AppStateUpdater, config libkbfs.Config) ( if err != nil { return nil, err } - // A failed first start is logged and the next app state change tries again. - s.server = manager.New("kbfsHTTP", logger, appStateUpdater.AppState, appStateUpdater.NextAppStateUpdate, + // 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 + } // 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, diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 46181d1c874f..5b66ff8ed1df 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -73,7 +73,8 @@ func NewSrv(g *libkb.GlobalContext) *Srv { listenerSource := func() kbhttp.ListenerSource { return kbhttp.NewRandomPortRangeListenerSource(g.GetEnv().GetAttachmentHTTPStartPort(), 18000) } - r := New("Srv", g.GetLog(), g.MobileAppState.State, g.MobileAppState.NextUpdate, listenerSource, + // 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)) @@ -87,14 +88,16 @@ func NewSrv(g *libkb.GlobalContext) *Srv { return r } -// New returns a server that has acted on the current app state. A failed first -// start is logged and the next app state change tries again; the server runs -// until Shutdown either way. +// 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 { +) (*Srv, error) { token, _ := libkb.RandHexString("", 32) r := &Srv{ name: name, @@ -114,10 +117,9 @@ func New(name string, log logger.Logger, appState func() keybase1.MobileAppState // Publish an empty status before run can be observed, so readers never dereference nil. r.status.Store(&keybase1.HttpSrvInfo{}) r.httpSrv = r.newHTTPSrv() - ready := make(chan struct{}) + ready := make(chan error) go r.run(ready) - <-ready - return r + return r, <-ready } func (r *Srv) debug(ctx context.Context, msg string, args ...any) { @@ -147,22 +149,22 @@ func (r *Srv) wantUp(state keybase1.MobileAppState) bool { return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } -// run owns the server. ready is closed once it has acted on the app state it -// started in and published the result. -func (r *Srv) run(ready chan<- struct{}) { +// 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() r.state = r.appState() r.debug(ctx, "run: starting up in %v", r.state) - r.reconcile(ctx) + err := r.reconcile(ctx) r.publish() - close(ready) + ready <- err for { select { case <-r.nextAppState(r.state): r.state = r.appState() r.restartedSinceChange = false - r.reconcile(ctx) + _ = r.reconcile(ctx) case <-r.exited: r.serverExited(ctx) case req := <-r.handlers: @@ -184,12 +186,12 @@ func (r *Srv) run(ready chan<- struct{}) { // 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) { +func (r *Srv) reconcile(ctx context.Context) error { if !r.wantUp(r.state) { r.httpSrv.Stop() - return + return nil } - r.start(ctx) + return r.start(ctx) } // serverExited restarts a server whose listener died without a Stop, for @@ -204,12 +206,12 @@ func (r *Srv) serverExited(ctx context.Context) { } r.restartedSinceChange = true r.debug(ctx, "serverExited: restarting in %v", r.state) - r.start(ctx) + _ = r.start(ctx) } -func (r *Srv) start(ctx context.Context) { +func (r *Srv) start(ctx context.Context) error { if r.httpSrv.Active() { - return + return nil } err := r.httpSrv.StartWithHandlers(r.registerEndpoints) if errors.Is(err, kbhttp.ErrPinnedPortInUse) { @@ -221,14 +223,15 @@ func (r *Srv) start(ctx context.Context) { } if err != nil { r.log.CWarningf(ctx, "%s: start: failed to start HTTP server: %s", r.name, err) - return + return err } // Publish before notifying, so a listener reading Info gets the address it is told about. info := r.publish() if info.Address == "" { // Serve already exited; run handles that exit next - return + return nil } r.notify(ctx, info) + return nil } func (r *Srv) publish() keybase1.HttpSrvInfo { diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index 357e11055ce6..540e0fa4dab3 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -148,7 +148,8 @@ var apps sync.Map func app(srv *Srv) *appState { a, ok := apps.Load(srv) if !ok { - return nil + // Not t.Fatal: some callers are on worker goroutines. + panic("no appState registered for this Srv") } return a.(*appState) } @@ -171,7 +172,8 @@ func setupWithNotify(t *testing.T, state keybase1.MobileAppState, stopInBackgrou tc.G.MobileAppState.Update(state) l := &listeners{} as := &appState{MobileAppState: tc.G.MobileAppState} - srv := New("Srv", tc.G.Log, as.State, as.NextUpdate, l.source, stopInBackground, notify) + 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) @@ -555,18 +557,20 @@ func (s brokenSource) GetListener() (net.Listener, string, error) { return s.src.GetListener() } -// A failed first start is not fatal: New returns a server that is not serving, -// and the next app state change starts it. -func TestNewSurvivesFirstStartFailure(t *testing.T) { +// 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 := New("Srv", tc.G.Log, tc.G.MobileAppState.State, tc.G.MobileAppState.NextUpdate, + 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) requireStopped(t, srv) @@ -739,8 +743,9 @@ func TestStressTransitionsAndRequests(t *testing.T) { l := &listeners{} as := &appState{MobileAppState: tc.G.MobileAppState} - srv := New("Srv", tc.G.Log, as.State, as.NextUpdate, l.source, true, + 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) { From a6dd9a3ae3c818d6ad5ad82804a406908aa753f3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:44:35 -0400 Subject: [PATCH 072/127] test(lifecycle): pin that no background task starts after Close The closed check is what keeps a task from joining the wait group while Close waits on it, which would be a WaitGroup misuse panic rather than a test failure. Covers both entry points: UIBackground and PushWindowEnd's hand-over. --- go/libkb/lifecycle/controller_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 5060c0ed44eb..7ed5598a3eaf 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -61,6 +61,28 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, 3, 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() + + require.Zero(t, c.UIBackground(true, noDeliveries()), "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, true, noDeliveries()), "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) From e45a49a2f259b07a252ba1aec9f52efc57ce0601 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 09:44:35 -0400 Subject: [PATCH 073/127] docs(service): re-point the gate comment at onGateIfCurrent The type comment still sent readers to do, which is gone. The rationale it carries -- why this is a mutex gate and not a request loop like Srv -- stays. --- go/service/gregor_conn.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index ac4b14c2e83b..8889e6b29c10 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -34,14 +34,14 @@ type gregorAppState interface { // 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 (see do), so none of -// them interleaves with a disconnect. +// also runs the steps OnConnect applies after syncing (the handler takes it in +// onGateIfCurrent), so none of them interleaves with a disconnect. // // This is a mutex gate rather than a single owning goroutine like // kbhttp/manager's Srv: every operation here is synchronous with a result its // caller needs (connect/reconnect return errors, reconnect also didShutdown), -// and do's OnConnect steps must report "no longer current" back on the -// caller's goroutine so onConnectSynced can return ErrDuplicateConnection. A +// 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. type gregorConnGate struct { From 93831ea432883d1d1b029c45687665334b573254 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 10:07:07 -0400 Subject: [PATCH 074/127] feat(protocol): return the client state from setNotifications Subscribing is now the read. setNotifications registers the channels and then reads and returns the session fields, the http server address and a version, so there is no separate read to order against the subscription: a change from here on is announced to that connection rather than falling in a gap between the two. The version widens from a bare counter to {epoch, counter}. The epoch identifies the service process, so a client that reconnects to a restarted service sees a different epoch instead of a counter that looks stale, and needs no reconnect bookkeeping. NotifyRouter stamps it in one announce helper rather than at each of the three call sites. BootstrapStatus drops its version -- it is no longer an ordered carrier of the session -- and GetBootstrapStatus drops the two-second httpSrv poll that gave one status two read times. --- go/client/chat_svc_handler.go | 6 +- go/client/cmd_chat_api_listen.go | 2 +- go/client/cmd_chat_archive.go | 2 +- go/client/cmd_chat_archive_resume.go | 2 +- go/client/cmd_show_notifications.go | 4 +- go/engine/bootstrap.go | 43 ++++++---- go/kbfs/libkbfs/init_test.go | 2 +- go/kbfs/libkbfs/keybase_daemon_rpc.go | 2 +- go/kbfs/libkbfs/keybase_daemon_rpc_test.go | 2 +- go/kbfs/libkbfs/keybase_service_base.go | 2 +- go/libkb/globals.go | 27 ++++-- go/libkb/notify_router.go | 98 +++++++++++----------- go/libkb/state_version_test.go | 30 +++++-- go/protocol/keybase1/common.go | 12 +++ go/protocol/keybase1/config.go | 2 - go/protocol/keybase1/notify_ctl.go | 38 ++++++++- go/protocol/keybase1/notify_service.go | 4 +- go/protocol/keybase1/notify_session.go | 12 +-- go/service/config.go | 27 ++---- go/service/main.go | 2 +- go/service/notify.go | 25 +++++- go/service/notify_test.go | 45 ++++++++++ go/systests/multiuser_common_test.go | 2 +- go/systests/teams_test.go | 2 +- go/systests/tracking_test.go | 3 +- go/systests/user_test.go | 5 +- protocol/avdl/keybase1/common.avdl | 11 +++ protocol/avdl/keybase1/config.avdl | 1 - protocol/avdl/keybase1/notify_ctl.avdl | 21 ++++- protocol/avdl/keybase1/notify_service.avdl | 2 +- protocol/avdl/keybase1/notify_session.avdl | 5 +- protocol/json/keybase1/common.json | 14 ++++ protocol/json/keybase1/config.json | 4 - protocol/json/keybase1/notify_ctl.json | 47 ++++++++++- protocol/json/keybase1/notify_service.json | 2 +- protocol/json/keybase1/notify_session.json | 11 ++- shared/constants/rpc/rpc-gen.tsx | 12 +-- 37 files changed, 377 insertions(+), 154 deletions(-) create mode 100644 go/service/notify_test.go diff --git a/go/client/chat_svc_handler.go b/go/client/chat_svc_handler.go index ac3fe121d550..4f13a6feca46 100644 --- a/go/client/chat_svc_handler.go +++ b/go/client/chat_svc_handler.go @@ -868,7 +868,7 @@ func (c *chatServiceHandler) AttachV1(ctx context.Context, opts attachOptionsV1, channels := keybase1.NotificationChannels{ Chatattachments: true, } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return c.errReply(err) } @@ -928,7 +928,7 @@ func (c *chatServiceHandler) DownloadV1(ctx context.Context, opts downloadOption channels := keybase1.NotificationChannels{ Chatattachments: true, } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return c.errReply(err) } @@ -991,7 +991,7 @@ func (c *chatServiceHandler) downloadV1NoStream(ctx context.Context, opts downlo channels := keybase1.NotificationChannels{ Chatattachments: true, } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return c.errReply(err) } diff --git a/go/client/cmd_chat_api_listen.go b/go/client/cmd_chat_api_listen.go index e432147c328e..97e5b28cbdaa 100644 --- a/go/client/cmd_chat_api_listen.go +++ b/go/client/cmd_chat_api_listen.go @@ -184,7 +184,7 @@ func (c *CmdChatAPIListen) Run() error { Chatdev: c.subscribeDev, Wallet: c.subscribeWallet, } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } errWriter := c.G().UI.GetTerminalUI().ErrorWriter() diff --git a/go/client/cmd_chat_archive.go b/go/client/cmd_chat_archive.go index d0f35b76f5c6..1002dc60a01c 100644 --- a/go/client/cmd_chat_archive.go +++ b/go/client/cmd_chat_archive.go @@ -101,7 +101,7 @@ func (c *CmdChatArchive) Run() error { channels := keybase1.NotificationChannels{ Chatarchive: true, } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } diff --git a/go/client/cmd_chat_archive_resume.go b/go/client/cmd_chat_archive_resume.go index 0d14b2892e74..055f445a8b36 100644 --- a/go/client/cmd_chat_archive_resume.go +++ b/go/client/cmd_chat_archive_resume.go @@ -92,7 +92,7 @@ func (c *CmdChatArchiveResume) Run() error { channels := keybase1.NotificationChannels{ Chatarchive: true, } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } diff --git a/go/client/cmd_show_notifications.go b/go/client/cmd_show_notifications.go index b4921c79d9e9..51b4d0a8bbd1 100644 --- a/go/client/cmd_show_notifications.go +++ b/go/client/cmd_show_notifications.go @@ -57,7 +57,7 @@ func (c *CmdShowNotifications) Run() error { if err != nil { return err } - if err := cli.SetNotifications(context.TODO(), channels); err != nil { + if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } @@ -99,7 +99,7 @@ func (d *notificationDisplay) printf(fmt string, args ...any) error { return err } -func (d *notificationDisplay) LoggedOut(_ context.Context, _ int64) error { +func (d *notificationDisplay) LoggedOut(_ context.Context, _ keybase1.StateVersion) error { return d.printf("Logged out\n") } diff --git a/go/engine/bootstrap.go b/go/engine/bootstrap.go index d6ede82d3da2..3d0c2bd1bbe1 100644 --- a/go/engine/bootstrap.go +++ b/go/engine/bootstrap.go @@ -62,28 +62,43 @@ 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: config.json and 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.ClientState, uv keybase1.UserVersion) { + res.Registered = signedUp(m) + + // 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 = session.Registered + 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 +111,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/kbfs/libkbfs/init_test.go b/go/kbfs/libkbfs/init_test.go index 4ea226d9a115..7fc938fbd5f5 100644 --- a/go/kbfs/libkbfs/init_test.go +++ b/go/kbfs/libkbfs/init_test.go @@ -155,7 +155,7 @@ func (c *initOrderCn) callIntoKBFS() { require.NoError(t, err) require.NoError(t, c.daemon.PaperKeyCached( ctx, keybase1.PaperKeyCachedArg{Uid: session.UID})) - require.NoError(t, c.daemon.LoggedOut(ctx, 0)) + require.NoError(t, c.daemon.LoggedOut(ctx, keybase1.StateVersion{})) // Until init is ready, requests get an error or wait. _, err = c.daemon.GetTLFCryptKeys(ctx, keybase1.TLFQuery{TlfName: "testuser"}) diff --git a/go/kbfs/libkbfs/keybase_daemon_rpc.go b/go/kbfs/libkbfs/keybase_daemon_rpc.go index bea31082b540..123aa441309b 100644 --- a/go/kbfs/libkbfs/keybase_daemon_rpc.go +++ b/go/kbfs/libkbfs/keybase_daemon_rpc.go @@ -362,7 +362,7 @@ func (k *KeybaseDaemonRPC) OnConnect(ctx context.Context, // Using conn.GetClient() here would cause problematic // recursion. c := keybase1.NotifyCtlClient{Cli: rawClient} - err = c.SetNotifications(ctx, keybase1.NotificationChannels{ + _, err = c.SetNotifications(ctx, keybase1.NotificationChannels{ Session: true, Paperkeys: true, Keyfamily: true, diff --git a/go/kbfs/libkbfs/keybase_daemon_rpc_test.go b/go/kbfs/libkbfs/keybase_daemon_rpc_test.go index b5f2e502c1e3..0894cbafa9b8 100644 --- a/go/kbfs/libkbfs/keybase_daemon_rpc_test.go +++ b/go/kbfs/libkbfs/keybase_daemon_rpc_test.go @@ -251,7 +251,7 @@ func TestKeybaseDaemonSessionCache(t *testing.T) { testCurrentSession(t, client, c, session, expectCached) // Should invalidate cache. - err := c.LoggedOut(context.Background(), 0) + err := c.LoggedOut(context.Background(), keybase1.StateVersion{}) require.NoError(t, err) // Should fill cache again. diff --git a/go/kbfs/libkbfs/keybase_service_base.go b/go/kbfs/libkbfs/keybase_service_base.go index 3dcd2c74ff13..db85a835ad79 100644 --- a/go/kbfs/libkbfs/keybase_service_base.go +++ b/go/kbfs/libkbfs/keybase_service_base.go @@ -384,7 +384,7 @@ func (k *KeybaseServiceBase) LoggedIn(ctx context.Context, arg keybase1.LoggedIn } // LoggedOut implements keybase1.NotifySessionInterface. -func (k *KeybaseServiceBase) LoggedOut(ctx context.Context, _ int64) error { +func (k *KeybaseServiceBase) LoggedOut(ctx context.Context, _ keybase1.StateVersion) error { k.log.CDebugf(ctx, "Current session logged out") k.setCachedCurrentSession(idutil.SessionInfo{}) if k.config != nil { diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 74f24f8f2fea..6d1fad064a03 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -79,7 +79,8 @@ type GlobalContext struct { Identify3State *Identify3State // keep track of Identify3 sessions vidMu *sync.Mutex // protect VID RuntimeStats RuntimeStats // performance runtime stats - stateVersion atomic.Int64 // see StateVersion + stateEpoch int64 // see StateVersion + stateCounter atomic.Int64 // see StateVersion cacheMu *sync.RWMutex // protects all caches ProofCache *ProofCache // where to cache proof results @@ -318,6 +319,9 @@ func (g *GlobalContext) Init() *GlobalContext { g.IdentifyDispatch = NewIdentifyDispatch() g.Identify3State = NewIdentify3State(g) g.GregorState = newNullGregorState() + // Any value distinct from every other service process will do: a client only + // ever asks whether two epochs differ, never which is greater. + g.stateEpoch = time.Now().UnixNano() g.LocalNetworkInstrumenterStorage = NewDiskInstrumentationStorage(g, keybase1.NetworkSource_LOCAL) g.RemoteNetworkInstrumenterStorage = NewDiskInstrumentationStorage(g, keybase1.NetworkSource_REMOTE) @@ -330,14 +334,21 @@ func NewGlobalContextInit() *GlobalContext { return NewGlobalContext().Init() } -// StateVersion is the version of the last change a notification announced (the -// http server address, login, logout). The bootstrap status reads it before the -// state, so a client can tell whether the status or a notification is newer. -func (g *GlobalContext) StateVersion() int64 { return g.stateVersion.Load() } +// StateVersion labels the last change a notification announced (the http server +// address, login, logout). Epoch identifies this service process, so a client +// that reconnects to a restarted service sees a different epoch instead of a +// counter that looks stale; counter strictly increases within one epoch. +func (g *GlobalContext) StateVersion() keybase1.StateVersion { + return keybase1.StateVersion{Epoch: g.stateEpoch, Counter: g.stateCounter.Load()} +} -// NextStateVersion stamps a change about to be announced. Call it after the -// change is readable, so nothing carrying this version is still invisible. -func (g *GlobalContext) NextStateVersion() int64 { return g.stateVersion.Add(1) } +// NextStateVersion stamps a change about to be announced. NotifyRouter calls it +// after the change is readable, so nothing carrying this version is still +// invisible, which makes a snapshot labelled with StateVersion never newer than +// its label. +func (g *GlobalContext) NextStateVersion() keybase1.StateVersion { + return keybase1.StateVersion{Epoch: g.stateEpoch, Counter: g.stateCounter.Add(1)} +} func (g *GlobalContext) SetService() { g.Service = true diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index cca473c045bb..4b8996d5c84e 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -398,36 +398,44 @@ func (n *NotifyRouter) SetChannels(i ConnectionID, nc keybase1.NotificationChann n.setNotificationChannels(i, nc) } -// HandleLogout is called whenever the current user logged out. It will broadcast -// the message to all connections who care about such a message. -func (n *NotifyRouter) HandleLogout(ctx context.Context) { - if n == nil { - return - } - defer n.G().CTrace(ctx, "NotifyRouter#HandleLogout", nil)() - ctx = CopyTagsToBackground(ctx) +// announce stamps one state version and fans a notification out to every +// connection whose channel filter wants it. Stamping here rather than at each +// call site is what makes the version the default for an announced change: the +// stamp happens after the change is readable and before any send. +func (n *NotifyRouter) announce(ctx context.Context, name string, + wants func(keybase1.NotificationChannels) bool, + send func(rpc.Transporter, keybase1.StateVersion), +) { version := n.G().NextStateVersion() - // 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, version) - }() + registered := wants(n.getNotificationChannels(id)) + if registered { + go send(xp, version) } desc := "" if d != nil { desc = fmt.Sprintf("%+v", *d) } - n.G().Log.CDebugf(ctx, "| NotifyRouter#HandleLogout: client %s (sent=%v)", desc, registered) + n.G().Log.CDebugf(ctx, "| NotifyRouter#%s: client %s (sent=%v)", name, desc, registered) return true }) +} + +// HandleLogout is called whenever the current user logged out. It will broadcast +// the message to all connections who care about such a message. +func (n *NotifyRouter) HandleLogout(ctx context.Context) { + if n == nil { + return + } + defer n.G().CTrace(ctx, "NotifyRouter#HandleLogout", nil)() + ctx = CopyTagsToBackground(ctx) + n.announce(ctx, "HandleLogout", + func(ch keybase1.NotificationChannels) bool { return ch.Session }, + func(xp rpc.Transporter, version keybase1.StateVersion) { + _ = (keybase1.NotifySessionClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).LoggedOut(ctx, version) + }) n.runListeners(func(listener NotifyListener) { listener.Logout() @@ -460,26 +468,18 @@ 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) - version := n.G().NextStateVersion() - 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{ - Username: u, - SignedUp: signedUp, - Version: version, - }) - }() - } - return true - }) + n.announce(ctx, "SendLogin", + func(ch keybase1.NotificationChannels) bool { return ch.Session }, + func(xp rpc.Transporter, version keybase1.StateVersion) { + _ = (keybase1.NotifySessionClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).LoggedIn(ctx, keybase1.LoggedInArg{ + Username: u, + SignedUp: signedUp, + Version: version, + }) + }) n.runListeners(func(listener NotifyListener) { listener.Login(u) @@ -2826,17 +2826,13 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase if n == nil { return } - version := n.G().NextStateVersion() - 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, keybase1.HTTPSrvInfoUpdateArg{Info: info, Version: version}) - }() - } - return true - }) + n.announce(ctx, "HandleHTTPSrvInfoUpdate", + func(ch keybase1.NotificationChannels) bool { return ch.Service }, + func(xp rpc.Transporter, version keybase1.StateVersion) { + _ = (keybase1.NotifyServiceClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).HTTPSrvInfoUpdate(ctx, keybase1.HTTPSrvInfoUpdateArg{Info: info, Version: version}) + }) n.runListeners(func(listener NotifyListener) { listener.HTTPSrvInfoUpdate(info) }) diff --git a/go/libkb/state_version_test.go b/go/libkb/state_version_test.go index d29a07c06a19..be1a4d7e6a96 100644 --- a/go/libkb/state_version_test.go +++ b/go/libkb/state_version_test.go @@ -13,9 +13,9 @@ import ( // Every announced change gets its own version, and the version is readable // through StateVersion by the time the notification is on its way out. A client -// compares the version on a bootstrap status against the versions on the -// notifications it got, so a change that stamped nothing would look older than a -// status read before it and be dropped. +// compares the version on the snapshot it got from setNotifications against the +// versions on the notifications it got, so a change that stamped nothing would +// look older than a snapshot read before it and be dropped. func TestNotifyRouterStampsEachAnnouncedChange(t *testing.T) { tc := SetupTest(t, "StateVersion", 0) defer tc.Cleanup() @@ -23,16 +23,32 @@ func TestNotifyRouterStampsEachAnnouncedChange(t *testing.T) { g.SetService() ctx := context.Background() - require.EqualValues(t, 0, g.StateVersion(), "nothing announced yet") + epoch := g.StateVersion().Epoch + require.NotZero(t, epoch, "the epoch identifies this service process") + require.EqualValues(t, 0, g.StateVersion().Counter, "nothing announced yet") g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "token"}) afterHTTP := g.StateVersion() - require.EqualValues(t, 1, afterHTTP) + require.EqualValues(t, 1, afterHTTP.Counter) g.NotifyRouter.SendLogin(ctx, "testuser", false) afterLogin := g.StateVersion() - require.Greater(t, afterLogin, afterHTTP) + require.Greater(t, afterLogin.Counter, afterHTTP.Counter) g.NotifyRouter.HandleLogout(ctx) - require.Greater(t, g.StateVersion(), afterLogin) + require.Greater(t, g.StateVersion().Counter, afterLogin.Counter) + + require.Equal(t, epoch, g.StateVersion().Epoch, "the epoch never moves within a process") +} + +// A client keeps the versions it applied across a reconnect and tells a +// restarted service from a continuing one by the epoch, so two services must +// never share one. +func TestStateVersionEpochsDiffer(t *testing.T) { + first := SetupTest(t, "StateVersionA", 0) + defer first.Cleanup() + second := SetupTest(t, "StateVersionB", 0) + defer second.Cleanup() + + require.NotEqual(t, first.G.StateVersion().Epoch, second.G.StateVersion().Epoch) } diff --git a/go/protocol/keybase1/common.go b/go/protocol/keybase1/common.go index 026bfd968567..c64302bfeeaf 100644 --- a/go/protocol/keybase1/common.go +++ b/go/protocol/keybase1/common.go @@ -1161,6 +1161,18 @@ func (o UserReacjis) DeepCopy() UserReacjis { } } +type StateVersion struct { + Epoch int64 `codec:"epoch" json:"epoch"` + Counter int64 `codec:"counter" json:"counter"` +} + +func (o StateVersion) DeepCopy() StateVersion { + return StateVersion{ + Epoch: o.Epoch, + Counter: o.Counter, + } +} + type CommonInterface interface { } diff --git a/go/protocol/keybase1/config.go b/go/protocol/keybase1/config.go index 5d5fc8bf52dc..093d5a2eaa99 100644 --- a/go/protocol/keybase1/config.go +++ b/go/protocol/keybase1/config.go @@ -667,7 +667,6 @@ type BootstrapStatus struct { Fullname FullName `codec:"fullname" json:"fullname"` UserReacjis UserReacjis `codec:"userReacjis" json:"userReacjis"` HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` - Version int64 `codec:"version" json:"version"` } func (o BootstrapStatus) DeepCopy() BootstrapStatus { @@ -687,7 +686,6 @@ func (o BootstrapStatus) DeepCopy() BootstrapStatus { tmp := x.DeepCopy() return &tmp })(o.HttpSrvInfo), - Version: o.Version, } } diff --git a/go/protocol/keybase1/notify_ctl.go b/go/protocol/keybase1/notify_ctl.go index 9300430778a5..6776bb34ee5f 100644 --- a/go/protocol/keybase1/notify_ctl.go +++ b/go/protocol/keybase1/notify_ctl.go @@ -88,12 +88,42 @@ func (o NotificationChannels) DeepCopy() NotificationChannels { } } +type ClientState struct { + Version StateVersion `codec:"version" json:"version"` + Registered bool `codec:"registered" json:"registered"` + 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"` + HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` +} + +func (o ClientState) DeepCopy() ClientState { + return ClientState{ + Version: o.Version.DeepCopy(), + Registered: o.Registered, + LoggedIn: o.LoggedIn, + Uid: o.Uid.DeepCopy(), + Username: o.Username, + DeviceID: o.DeviceID.DeepCopy(), + DeviceName: o.DeviceName, + HttpSrvInfo: (func(x *HttpSrvInfo) *HttpSrvInfo { + if x == nil { + return nil + } + tmp := x.DeepCopy() + return &tmp + })(o.HttpSrvInfo), + } +} + type SetNotificationsArg struct { Channels NotificationChannels `codec:"channels" json:"channels"` } type NotifyCtlInterface interface { - SetNotifications(context.Context, NotificationChannels) error + SetNotifications(context.Context, NotificationChannels) (ClientState, error) } func NotifyCtlProtocol(i NotifyCtlInterface) rpc.Protocol { @@ -111,7 +141,7 @@ func NotifyCtlProtocol(i NotifyCtlInterface) rpc.Protocol { err = rpc.NewTypeError((*[1]SetNotificationsArg)(nil), args) return } - err = i.SetNotifications(ctx, typedArgs[0].Channels) + ret, err = i.SetNotifications(ctx, typedArgs[0].Channels) return }, }, @@ -123,8 +153,8 @@ type NotifyCtlClient struct { Cli rpc.GenericClient } -func (c NotifyCtlClient) SetNotifications(ctx context.Context, channels NotificationChannels) (err error) { +func (c NotifyCtlClient) SetNotifications(ctx context.Context, channels NotificationChannels) (res ClientState, err error) { __arg := SetNotificationsArg{Channels: channels} - err = c.Cli.Call(ctx, "keybase.1.notifyCtl.setNotifications", []any{__arg}, nil, 0*time.Millisecond) + err = c.Cli.Call(ctx, "keybase.1.notifyCtl.setNotifications", []any{__arg}, &res, 0*time.Millisecond) return } diff --git a/go/protocol/keybase1/notify_service.go b/go/protocol/keybase1/notify_service.go index ce70b7941479..6f86649419c4 100644 --- a/go/protocol/keybase1/notify_service.go +++ b/go/protocol/keybase1/notify_service.go @@ -23,8 +23,8 @@ func (o HttpSrvInfo) DeepCopy() HttpSrvInfo { } type HTTPSrvInfoUpdateArg struct { - Info HttpSrvInfo `codec:"info" json:"info"` - Version int64 `codec:"version" json:"version"` + Info HttpSrvInfo `codec:"info" json:"info"` + Version StateVersion `codec:"version" json:"version"` } type HandleKeybaseLinkArg struct { diff --git a/go/protocol/keybase1/notify_session.go b/go/protocol/keybase1/notify_session.go index 7862b8e7ef9e..930c93413662 100644 --- a/go/protocol/keybase1/notify_session.go +++ b/go/protocol/keybase1/notify_session.go @@ -11,13 +11,13 @@ import ( ) type LoggedOutArg struct { - Version int64 `codec:"version" json:"version"` + Version StateVersion `codec:"version" json:"version"` } type LoggedInArg struct { - Username string `codec:"username" json:"username"` - SignedUp bool `codec:"signedUp" json:"signedUp"` - Version int64 `codec:"version" json:"version"` + Username string `codec:"username" json:"username"` + SignedUp bool `codec:"signedUp" json:"signedUp"` + Version StateVersion `codec:"version" json:"version"` } type ClientOutOfDateArg struct { @@ -27,7 +27,7 @@ type ClientOutOfDateArg struct { } type NotifySessionInterface interface { - LoggedOut(context.Context, int64) error + LoggedOut(context.Context, StateVersion) error LoggedIn(context.Context, LoggedInArg) error ClientOutOfDate(context.Context, ClientOutOfDateArg) error } @@ -89,7 +89,7 @@ type NotifySessionClient struct { Cli rpc.GenericClient } -func (c NotifySessionClient) LoggedOut(ctx context.Context, version int64) (err error) { +func (c NotifySessionClient) LoggedOut(ctx context.Context, version StateVersion) (err error) { __arg := LoggedOutArg{Version: version} err = c.Cli.Notify(ctx, "keybase.1.NotifySession.loggedOut", []any{__arg}, 0*time.Millisecond) return diff --git a/go/service/config.go b/go/service/config.go index 09f3f0008001..a1af30c698f3 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -356,30 +356,19 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r // attempt (which can be slow: leveldb open/recovery, keychain reads) so // we don't report loggedIn=false while it is still in flight. h.svc.awaitInitialLoginAttempt(m, 30*time.Second) - // Read the version after that wait but before the state it describes. A login, - // logout or http server change that lands from here on stamps its notification - // with a newer version, so the client keeps the notification over this status. - version := h.G().StateVersion() eng := engine.NewBootstrap(h.G()) if err = engine.RunEngine2(m, eng); err != nil { return res, err } res = eng.Status() - res.Version = version - m.Debug("GetBootstrapStatus: attempting to get HTTP server address") - for range 40 { // wait at most 2 seconds - info, infoErr := h.svc.httpSrv.Info() - if infoErr != nil { - m.Debug("GetBootstrapStatus: failed to get HTTP server address: %s", infoErr) - } else { - m.Debug("GetBootstrapStatus: http server: addr: %s token: %s", info.Address, manager.TokenPrefix(info.Token)) - res.HttpSrvInfo = &info - 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 that understands setNotifications already has the + // address from the subscription reply and from HTTPSrvInfoUpdate, which the + // server sends on every start. This is only here for a client too old to. + 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/main.go b/go/service/main.go index 951c0babfaac..5059f2a87f24 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -154,7 +154,7 @@ func (d *Service) RegisterProtocols(srv *rpc.Server, xp rpc.Transporter, connID keybase1.KvstoreProtocol(NewKVStoreHandler(xp, g)), keybase1.LogProtocol(NewLogHandler(xp, logReg, g)), keybase1.LoginProtocol(NewLoginHandler(xp, g)), - keybase1.NotifyCtlProtocol(NewNotifyCtlHandler(xp, connID, g)), + keybase1.NotifyCtlProtocol(NewNotifyCtlHandler(xp, connID, g, d)), keybase1.PGPProtocol(NewPGPHandler(xp, connID, g)), keybase1.PprofProtocol(NewPprofHandler(xp, g)), keybase1.ReachabilityProtocol(newReachabilityHandler(xp, g, d)), diff --git a/go/service/notify.go b/go/service/notify.go index c145f378da9f..afa0be17b7db 100644 --- a/go/service/notify.go +++ b/go/service/notify.go @@ -6,6 +6,7 @@ package service import ( "context" + "github.com/keybase/client/go/engine" "github.com/keybase/client/go/libkb" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/go-framed-msgpack-rpc/rpc" @@ -15,20 +16,36 @@ import ( type NotifyCtlHandler struct { libkb.Contextified *BaseHandler - id libkb.ConnectionID + id libkb.ConnectionID + svc *Service } // NewNotifyCtlHandler creates a new handler for setting up notification // channels -func NewNotifyCtlHandler(xp rpc.Transporter, id libkb.ConnectionID, g *libkb.GlobalContext) *NotifyCtlHandler { +func NewNotifyCtlHandler(xp rpc.Transporter, id libkb.ConnectionID, g *libkb.GlobalContext, svc *Service) *NotifyCtlHandler { return &NotifyCtlHandler{ Contextified: libkb.NewContextified(g), BaseHandler: NewBaseHandler(g, xp), id: id, + svc: svc, } } -func (h *NotifyCtlHandler) SetNotifications(_ context.Context, n keybase1.NotificationChannels) error { +// SetNotifications registers the channels and then reads the client state, in +// that order: a change from here on is announced to this connection, so the +// reply can only miss something the client is about to be told about anyway. +// That is what removes the ordering problem between a subscription and a +// separate read of the same state. +func (h *NotifyCtlHandler) SetNotifications(ctx context.Context, n keybase1.NotificationChannels) (keybase1.ClientState, error) { h.G().NotifyRouter.SetChannels(h.id, n) - return nil + // Read the version before the state it describes. NextStateVersion is stamped + // after a change is readable, so this snapshot is never newer than its label + // and a client can drop it on a tie without losing anything. + version := h.G().StateVersion() + res, _ := engine.SessionState(libkb.NewMetaContext(ctx, h.G())) + res.Version = version + if info, err := h.svc.httpSrv.Info(); err == nil { + res.HttpSrvInfo = &info + } + return res, nil } diff --git a/go/service/notify_test.go b/go/service/notify_test.go new file mode 100644 index 000000000000..7ba4fdd7c4b4 --- /dev/null +++ b/go/service/notify_test.go @@ -0,0 +1,45 @@ +package service + +import ( + "context" + "testing" + + "github.com/keybase/client/go/libkb" + keybase1 "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// The reply to setNotifications is what a client applies instead of ordering a +// separate read against its subscription, so the two have to happen in this +// order and in this call: the channels are registered first, and only then is +// the state read and labelled. A state read before the registration could +// describe a change nobody announced. +func TestSetNotificationsRegistersThenSnapshots(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + svc := NewService(g, false) + connID := g.NotifyRouter.AddConnection(nil, nil) + h := NewNotifyCtlHandler(nil, connID, g, svc) + + // a change announced before anyone subscribed + g.NotifyRouter.HandleLogout(context.Background()) + announced := g.StateVersion() + + // only Session, so nothing below actually sends down this test's nil transport + res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) + require.NoError(t, err) + + require.True(t, g.NotifyRouter.GetChannels(connID).Session, "channels registered") + require.Equal(t, announced.Epoch, res.Version.Epoch) + require.GreaterOrEqual(t, res.Version.Counter, announced.Counter, + "the snapshot is read after everything already announced") + require.False(t, res.LoggedIn, "logged out in a fresh test context") + + // a change announced after subscribing is strictly newer than the snapshot, + // which is what lets the client keep the notification over the snapshot + g.NotifyRouter.HandleHTTPSrvInfoUpdate(context.Background(), keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "t"}) + require.Greater(t, g.StateVersion().Counter, res.Version.Counter) +} diff --git a/go/systests/multiuser_common_test.go b/go/systests/multiuser_common_test.go index 2f95ba2fff3f..399472412765 100644 --- a/go/systests/multiuser_common_test.go +++ b/go/systests/multiuser_common_test.go @@ -394,7 +394,7 @@ func (u *smuUser) registerForNotifications() { require.NoError(u.ctx.t, err) } ncli := keybase1.NotifyCtlClient{Cli: u.primaryDevice().rpcClient()} - if err := ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{Team: true}); err != nil { + if _, err := ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{Team: true}); err != nil { require.NoError(u.ctx.t, err) } } diff --git a/go/systests/teams_test.go b/go/systests/teams_test.go index 0744bc2a11b7..ffe0d40979c8 100644 --- a/go/systests/teams_test.go +++ b/go/systests/teams_test.go @@ -244,7 +244,7 @@ func (tt *teamTester) addUserHelper(pre string, puk bool, paper bool) *userPlusD err = srv.Register(keybase1.NotifyTeambotProtocol(u.notifications)) require.NoError(tt.t, err) ncli := keybase1.NotifyCtlClient{Cli: cli} - err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ + _, err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ Team: true, Badges: true, Ephemeral: true, diff --git a/go/systests/tracking_test.go b/go/systests/tracking_test.go index 5a5f5b5f38dc..0422a8ae6bfe 100644 --- a/go/systests/tracking_test.go +++ b/go/systests/tracking_test.go @@ -170,9 +170,10 @@ func TestTrackingNotifications(t *testing.T) { return err } ncli := keybase1.NotifyCtlClient{Cli: cli} - return ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ + _, err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ Tracking: true, }) + return err } // Actually launch it in the background diff --git a/go/systests/user_test.go b/go/systests/user_test.go index 20ebb6345aaf..0a55ad06a61c 100644 --- a/go/systests/user_test.go +++ b/go/systests/user_test.go @@ -247,7 +247,7 @@ func newNotifyHandler() *notifyHandler { } } -func (h *notifyHandler) LoggedOut(_ context.Context, _ int64) error { +func (h *notifyHandler) LoggedOut(_ context.Context, _ keybase1.StateVersion) error { h.logoutCh <- struct{}{} return nil } @@ -330,10 +330,11 @@ func TestSignupLogout(t *testing.T) { return err } ncli := keybase1.NotifyCtlClient{Cli: cli} - return ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ + _, err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ Session: true, Users: true, }) + return err } // Actually launch it in the background diff --git a/protocol/avdl/keybase1/common.avdl b/protocol/avdl/keybase1/common.avdl index f200b502b930..007239b87957 100644 --- a/protocol/avdl/keybase1/common.avdl +++ b/protocol/avdl/keybase1/common.avdl @@ -419,4 +419,15 @@ protocol Common { ReacjiSkinTone skinTone; } + + // StateVersion labels a change the service announced (a login, a logout, the + // http server address). Epoch identifies the service process, so a client can + // tell a restarted service's counter from a continuing one; counter strictly + // increases within one epoch. A service too old to send one leaves it unset, + // and a client with nothing to order by applies what it gets in arrival order. + record StateVersion { + long epoch; + long counter; + } + } diff --git a/protocol/avdl/keybase1/config.avdl b/protocol/avdl/keybase1/config.avdl index d541a926744b..8d434d8d31e5 100644 --- a/protocol/avdl/keybase1/config.avdl +++ b/protocol/avdl/keybase1/config.avdl @@ -274,7 +274,6 @@ protocol config { FullName fullname; // current user's fullname UserReacjis userReacjis; // reacjis preferences for current logged in user union { null, HttpSrvInfo } httpSrvInfo; // info about the service http server - long version; // state version read before the rest of this status; compare against the versions on the login, logout and http server notifications } BootstrapStatus getBootstrapStatus(int sessionID); diff --git a/protocol/avdl/keybase1/notify_ctl.avdl b/protocol/avdl/keybase1/notify_ctl.avdl index 77d1849a29c0..1081b2571ac8 100644 --- a/protocol/avdl/keybase1/notify_ctl.avdl +++ b/protocol/avdl/keybase1/notify_ctl.avdl @@ -3,6 +3,7 @@ protocol notifyCtl { import idl "common.avdl"; + import idl "notify_service.avdl"; record NotificationChannels { boolean session; @@ -45,5 +46,23 @@ protocol notifyCtl { boolean devicehistory; } - void setNotifications(NotificationChannels channels); + // ClientState is the state a client needs before it can render anything, read + // as the reply to setNotifications so there is no read to order against the + // subscription. It carries only what is available with nothing to wait on; + // the slower derived fields stay on getBootstrapStatus. + record ClientState { + StateVersion version; + boolean registered; // true if signed up at some point + boolean loggedIn; + UID uid; + string username; + DeviceID deviceID; + string deviceName; + union { null, HttpSrvInfo } httpSrvInfo; + } + + // Registers the channels, then returns the state, so anything that changes + // from here on is announced to this connection rather than falling in a gap + // between the subscription and a separate read. + ClientState setNotifications(NotificationChannels channels); } diff --git a/protocol/avdl/keybase1/notify_service.avdl b/protocol/avdl/keybase1/notify_service.avdl index eaa875c5cc98..cd4e678e6819 100644 --- a/protocol/avdl/keybase1/notify_service.avdl +++ b/protocol/avdl/keybase1/notify_service.avdl @@ -7,7 +7,7 @@ protocol NotifyService { string token; } @lint("ignore") - void HTTPSrvInfoUpdate(HttpSrvInfo info, long version) oneway; + void HTTPSrvInfoUpdate(HttpSrvInfo info, StateVersion version) oneway; void handleKeybaseLink(string link, boolean deferred) oneway; diff --git a/protocol/avdl/keybase1/notify_session.avdl b/protocol/avdl/keybase1/notify_session.avdl index e5d8a5f43c5b..1ef8720feda3 100644 --- a/protocol/avdl/keybase1/notify_session.avdl +++ b/protocol/avdl/keybase1/notify_session.avdl @@ -1,9 +1,10 @@ @namespace("keybase.1") protocol NotifySession { + import idl "common.avdl"; @notify("") - void loggedOut(long version); - void loggedIn(string username, boolean signedUp, long version); // signedUp if this is due to a signup + void loggedOut(StateVersion version); + void loggedIn(string username, boolean signedUp, StateVersion version); // signedUp if this is due to a signup void clientOutOfDate(string upgradeTo, string upgradeURI, string upgradeMsg); } diff --git a/protocol/json/keybase1/common.json b/protocol/json/keybase1/common.json index 49ee7411c85e..0cf63c8b4ed1 100644 --- a/protocol/json/keybase1/common.json +++ b/protocol/json/keybase1/common.json @@ -904,6 +904,20 @@ "name": "skinTone" } ] + }, + { + "type": "record", + "name": "StateVersion", + "fields": [ + { + "type": "long", + "name": "epoch" + }, + { + "type": "long", + "name": "counter" + } + ] } ], "messages": {}, diff --git a/protocol/json/keybase1/config.json b/protocol/json/keybase1/config.json index 98feceee4d9b..1c4f6eb8df23 100644 --- a/protocol/json/keybase1/config.json +++ b/protocol/json/keybase1/config.json @@ -738,10 +738,6 @@ "HttpSrvInfo" ], "name": "httpSrvInfo" - }, - { - "type": "long", - "name": "version" } ] }, diff --git a/protocol/json/keybase1/notify_ctl.json b/protocol/json/keybase1/notify_ctl.json index 20bdedc83795..d5fc4b7fc39b 100644 --- a/protocol/json/keybase1/notify_ctl.json +++ b/protocol/json/keybase1/notify_ctl.json @@ -4,6 +4,10 @@ { "path": "common.avdl", "type": "idl" + }, + { + "path": "notify_service.avdl", + "type": "idl" } ], "types": [ @@ -152,6 +156,47 @@ "name": "devicehistory" } ] + }, + { + "type": "record", + "name": "ClientState", + "fields": [ + { + "type": "StateVersion", + "name": "version" + }, + { + "type": "boolean", + "name": "registered" + }, + { + "type": "boolean", + "name": "loggedIn" + }, + { + "type": "UID", + "name": "uid" + }, + { + "type": "string", + "name": "username" + }, + { + "type": "DeviceID", + "name": "deviceID" + }, + { + "type": "string", + "name": "deviceName" + }, + { + "type": [ + null, + "HttpSrvInfo" + ], + "name": "httpSrvInfo" + } + ] } ], "messages": { @@ -162,7 +207,7 @@ "type": "NotificationChannels" } ], - "response": null + "response": "ClientState" } }, "namespace": "keybase.1" diff --git a/protocol/json/keybase1/notify_service.json b/protocol/json/keybase1/notify_service.json index c61a8764e76a..362a33a52468 100644 --- a/protocol/json/keybase1/notify_service.json +++ b/protocol/json/keybase1/notify_service.json @@ -31,7 +31,7 @@ }, { "name": "version", - "type": "long" + "type": "StateVersion" } ], "response": null, diff --git a/protocol/json/keybase1/notify_session.json b/protocol/json/keybase1/notify_session.json index 098aeaf4efbd..f7bbc409f379 100644 --- a/protocol/json/keybase1/notify_session.json +++ b/protocol/json/keybase1/notify_session.json @@ -1,13 +1,18 @@ { "protocol": "NotifySession", - "imports": [], + "imports": [ + { + "path": "common.avdl", + "type": "idl" + } + ], "types": [], "messages": { "loggedOut": { "request": [ { "name": "version", - "type": "long" + "type": "StateVersion" } ], "response": null, @@ -25,7 +30,7 @@ }, { "name": "version", - "type": "long" + "type": "StateVersion" } ], "response": null diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index 0342e69aac99..6a40fc7da68a 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -72,7 +72,7 @@ export type MessageTypes = { outParam: void, }, 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - inParam: {readonly info: HttpSrvInfo,readonly version: number}, + inParam: {readonly info: HttpSrvInfo,readonly version: StateVersion}, outParam: void, }, 'keybase.1.NotifyService.handleKeybaseLink': { @@ -88,11 +88,11 @@ export type MessageTypes = { outParam: void, }, 'keybase.1.NotifySession.loggedIn': { - inParam: {readonly username: string,readonly signedUp: boolean,readonly version: number}, + inParam: {readonly username: string,readonly signedUp: boolean,readonly version: StateVersion}, outParam: void, }, 'keybase.1.NotifySession.loggedOut': { - inParam: {readonly version: number}, + inParam: {readonly version: StateVersion}, outParam: void, }, 'keybase.1.NotifySimpleFS.simpleFSArchiveStatusChanged': { @@ -829,7 +829,7 @@ export type MessageTypes = { }, 'keybase.1.notifyCtl.setNotifications': { inParam: {readonly channels: NotificationChannels}, - outParam: void, + outParam: ClientState, }, 'keybase.1.pgp.pgpKeyGenDefault': { inParam: {readonly createUids: PGPCreateUids}, @@ -2527,7 +2527,7 @@ export type BlockQuotaInfo = {readonly folders?: ReadonlyArray export type BlockRefNonce = string | null export type BlockReference = {readonly bid: BlockIdCombo,readonly nonce: BlockRefNonce,readonly chargedTo: UserOrTeamID,} export type BlockReferenceCount = {readonly ref: BlockReference,readonly liveCount: number,} -export type BootstrapStatus = {readonly registered: boolean,readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,readonly fullname: FullName,readonly userReacjis: UserReacjis,readonly httpSrvInfo?: HttpSrvInfo | null,readonly version: number,} +export type BootstrapStatus = {readonly registered: boolean,readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,readonly fullname: FullName,readonly userReacjis: UserReacjis,readonly httpSrvInfo?: HttpSrvInfo | null,} export type BotToken = string export type BotTokenInfo = {readonly token: BotToken,readonly ctime: Time,} export type BoxAuditAttempt = {readonly ctime: UnixTime,readonly error?: string | null,readonly result: BoxAuditAttemptResult,readonly generation?: PerTeamKeyGeneration | null,readonly rotated: boolean,} @@ -2545,6 +2545,7 @@ 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 ClientState = {readonly version: StateVersion,readonly registered: boolean,readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,readonly httpSrvInfo?: HttpSrvInfo | null,} 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,} @@ -2943,6 +2944,7 @@ export type SocialAssertion = {readonly user: string,readonly service: SocialAss export type SocialAssertionService = string export type StartProofResult = {readonly sigID: SigID,} export type StartStatus = {readonly log: string,} +export type StateVersion = {readonly epoch: number,readonly counter: number,} export type Status = {readonly code: number,readonly name: string,readonly desc: string,readonly fields?: ReadonlyArray | null,} export type StellarAccount = {readonly accountID: string,readonly federationAddress: string,readonly sigID: SigID,readonly hidden: boolean,} export type Stream = {readonly fd: number,} From 51b578443e10b506cb29619286733ed9c560be7e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 10:16:29 -0400 Subject: [PATCH 075/127] refactor(config): take the session from the subscription, not a read applyClientState applies the reply to setNotifications: the session, the current user and the http address as of the moment this connection subscribed. Nothing orders it against the bootstrap read any more, so the read starts with the handshake instead of behind the subscription, and the three-read retry loop, maxStaleSnapshotReads, startHandshake's readAfter and acceptSessionSnapshot all go with it. One rule is left where there were two: strictly newer wins, for a notification and for the snapshot alike. The snapshot is labelled before the state it carries, so it is never newer than its label and dropping it on a tie loses nothing -- anything it holds beyond its label is a change already on its way as its own notification. The applied versions now survive an engine reconnect: a restarted service announces a different epoch, which is always newer, so the reset and its -1 sentinel are gone. A service too old to answer setNotifications sends no version anywhere. Its bootstrap status keeps owning the session and the address, applied in arrival order, for as long as nothing versioned has landed. Also deletes gregorReachable, whose one consumer was a bootstrap re-read trigger and whose value could sit at UNKNOWN forever because startReachability is hardcoded to return it. The re-read now hangs off the OS network status coming back online, which JS already knows without asking the service. The checkReachability call stays, without its result: the service re-dials gregor inside it. --- protocol/bin/enabled-calls.json | 2 - shared/constants/init/shared.test.ts | 89 ++++++-- shared/constants/init/shared.tsx | 68 +++++- shared/constants/rpc/index.tsx | 3 +- shared/constants/rpc/rpc-gen.tsx | 15 +- shared/stores/config.tsx | 83 ++----- shared/stores/daemon.tsx | 63 ++---- shared/stores/shell.tsx | 14 +- shared/stores/tests/client-state.test.ts | 139 ++++++++++++ shared/stores/tests/daemon.test.ts | 252 ++------------------- shared/stores/tests/legacy-service.test.ts | 90 ++++++++ 11 files changed, 424 insertions(+), 394 deletions(-) create mode 100644 shared/stores/tests/client-state.test.ts create mode 100644 shared/stores/tests/legacy-service.test.ts diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index 664a2f886ccb..fe8ca33784d5 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -389,8 +389,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/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 66fb79ed9e69..b6a1c77cda87 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -3,7 +3,7 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' -import {loadAccountsStep, onEngineConnected} from './shared' +import {loadAccountsStep, onEngineConnected, onNetworkOnlineChanged} from './shared' describe('loadAccountsStep', () => { const originalDispatch = useConfigState.getState().dispatch @@ -33,7 +33,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 +42,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() @@ -94,22 +94,17 @@ describe('onEngineConnected', () => { } const deferredSubscription = () => { - let subscribed!: () => void + let subscribed!: (cs: T.RPCGen.ClientState) => void jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockReturnValue( - new Promise(resolve => { + new Promise(resolve => { subscribed = resolve }) ) - return () => subscribed() + return subscribed } - // config's onEngineConnected, which resets the applied versions, is stubbed out here, so each - // test reads a version newer than the last one applied - let version = 0 const spyOnBootstrap = () => jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ - httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, loggedIn: true, - version: ++version, } as T.RPCGen.BootstrapStatus) test('a reconnect clears the disconnect state at once, before the subscription resolves', () => { @@ -124,19 +119,28 @@ describe('onEngineConnected', () => { expect(useDaemonState.getState().handshakeState).toBe('loading') }) - test('the bootstrap read starts only once the notification subscription resolves', async () => { + test('the bootstrap read does not wait for the subscription', async () => { stubRegistrations() const subscribed = deferredSubscription() const bootstrap = spyOnBootstrap() onEngineConnected() - await Promise.resolve() - expect(bootstrap).not.toHaveBeenCalled() - - subscribed() await new Promise(resolve => setImmediate(resolve)) expect(bootstrap).toHaveBeenCalledTimes(1) + + subscribed({ + deviceID: 'd1', + deviceName: 'testuser-mac', + httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + version: {counter: 1, epoch: 7}, + }) + await new Promise(resolve => setImmediate(resolve)) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') }) @@ -153,3 +157,56 @@ describe('onEngineConnected', () => { expect(bootstrap).toHaveBeenCalledTimes(1) }) }) + +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 6e1dfcef233c..a4e53f20d468 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -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 ) { @@ -199,18 +203,28 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { +export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { if (!bootstrap) { return } - const {deviceID, deviceName, loggedIn, uid, username} = bootstrap + const {deviceID, deviceName, httpSrvInfo, loggedIn, uid, username} = bootstrap useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) const configDispatch = useConfigState.getState().dispatch if (username) { configDispatch.setDefaultUsername(username) } + // The session and the http address belong to the setNotifications snapshot and its + // notifications, which carry a version this status does not. A service too old to answer + // setNotifications sends no version anywhere, and then this status is the only place they come + // from -- so apply them here only while nothing versioned has landed, and never after. + if (httpSrvInfo && configDispatch.canAcceptUnversioned('http')) { + configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token) + } + if (!configDispatch.canAcceptUnversioned('session')) { + return + } if (!loggedIn && useConfigState.getState().userSwitching) { logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') return @@ -218,6 +232,34 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => configDispatch.setLoggedIn(loggedIn) } +// The reply to setNotifications: the state as of the moment this connection subscribed, so there +// is no read to order against the subscription. An old service returns nothing here and the +// bootstrap status keeps that job -- see onBootstrapStatusChanged. +export const applyClientState = (clientState?: T.RPCGen.ClientState) => { + if (!clientState) { + logger.info('[Bootstrap] no client state from setNotifications; this service predates it') + return + } + const {deviceID, deviceName, httpSrvInfo, loggedIn, uid, username, version} = clientState + const configDispatch = useConfigState.getState().dispatch + if (httpSrvInfo) { + configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token, version) + } + if (!configDispatch.acceptSessionVersion(version)) { + logger.info('[Bootstrap] a login or logout is newer than this snapshot, ignoring') + return + } + useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) + if (username) { + configDispatch.setDefaultUsername(username) + } + if (!loggedIn && useConfigState.getState().userSwitching) { + logger.info('[Bootstrap] ignoring loggedIn=false snapshot during account switch') + return + } + configDispatch.setLoggedIn(loggedIn) +} + const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { const next = nextNavState as Util.NavState const prev = previousNavState as Util.NavState @@ -268,26 +310,27 @@ export const onEngineConnected = () => { const subscribe = async () => { try { // prettier-ignore - await T.RPCGen.notifyCtlSetNotificationsRpcPromise({ + const clientState = 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, + paperkeys: false, pgp: true, reachability: false, runtimestats: true, saltpack: true, service: true, session: true, team: true, teambot: false, tracking: true, users: true, wallet: false, }, }) + applyClientState(clientState) } catch (error) { if (error) { logger.warn('error in toggling notifications: ', error) } } } - // The handshake starts now, so the reconnect clears the disconnect state at once, but its - // bootstrap read waits for the subscription: a login, logout or http server change announced - // between the read and the subscription would reach nobody. - useDaemonState.getState().dispatch.startHandshake(subscribe()) + ignorePromise(subscribe()) + // Nothing orders these two any more: the subscription reply is what carries the session and + // the http address, so the bootstrap read has nothing left to race with. + useDaemonState.getState().dispatch.startHandshake() } } @@ -313,7 +356,6 @@ 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) @@ -321,6 +363,8 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.bootstrapStatus, onBootstrapStatusChanged)) + _sharedUnsubs.push(subscribeValue(useShellState, s => s.networkStatus?.online, onNetworkOnlineChanged)) + _sharedUnsubs.push( subscribeValue(useRouterState, s => s.navState, onNavStateChanged) ) diff --git a/shared/constants/rpc/index.tsx b/shared/constants/rpc/index.tsx index 639df9e16838..fa83b6fa3e0e 100644 --- a/shared/constants/rpc/index.tsx +++ b/shared/constants/rpc/index.tsx @@ -78,8 +78,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 6a40fc7da68a..eecacf04a02a 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -963,14 +963,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 +1272,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.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> @@ -3127,7 +3119,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.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' @@ -3292,7 +3284,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') @@ -3617,6 +3608,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/stores/config.tsx b/shared/stores/config.tsx index 91a67b1a000d..e466f90a0b01 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: { @@ -62,7 +61,6 @@ const initialStore: Store = { defaultUsername: '', globalError: undefined, gregorPushState: [], - gregorReachable: undefined, httpSrv: { address: '', token: '', @@ -92,9 +90,10 @@ const initialStore: Store = { export type State = Store & { dispatch: { // a login or logout notification: applied only if it is newer than the last applied one - acceptSessionVersion: (version: number | undefined) => boolean - // a bootstrap status: applied unless a login or logout notification is newer - acceptSessionSnapshot: (version: number | undefined) => boolean + acceptSessionVersion: (version?: T.RPCGen.StateVersion) => boolean + // true while nothing versioned has landed: the only window in which an unversioned payload + // (a bootstrap status from a service too old to answer setNotifications) may own this field + canAcceptUnversioned: (kind: 'http' | 'session') => boolean checkForUpdate: () => void initAppUpdateLoop: () => void installerRan: () => void @@ -116,8 +115,7 @@ 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, version: number | undefined) => void + setHTTPSrvInfo: (address: string, token: string, version?: T.RPCGen.StateVersion) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void @@ -129,28 +127,25 @@ export type State = Store & { } } -// Below every version the service can hand out: it can hand out 0, because the http server -// starts before the notify router exists and its first update stamps nothing. -const noVersionApplied = -1 -const nothingApplied = () => ({http: noVersionApplied, session: noVersionApplied}) +// A different epoch is a different service process: its counter started over, so +// it is not comparable and its state is by definition the newer one. +const isNewerVersion = (next: T.RPCGen.StateVersion, applied?: T.RPCGen.StateVersion) => + next.epoch !== applied?.epoch || next.counter > applied.counter export const useConfigState = Z.createZustand('config', (set, get) => { let inflightRefreshAccounts: Promise | undefined // The http server address and the session change at any time and say so with versioned - // notifications, while a bootstrap status read can take seconds. The service stamps both from - // one counter, so only a newer version wins. - let applied = nothingApplied() - // A notification must be strictly newer than what we applied. A bootstrap status may carry the - // version of a notification we already applied, since that is the same state read again. - const acceptVersion = ( - kind: 'http' | 'session', - version: number | undefined, - source: 'notification' | 'status' - ) => { + // notifications; the setNotifications reply carries both under one version. The service stamps + // every one of them from one counter, so only a strictly newer version wins. The reply is + // labelled before the state it carries, so it is never newer than its label: dropping it on a + // tie loses nothing, because anything it holds beyond its label is a change already on its way + // as its own notification. + const applied: {http?: T.RPCGen.StateVersion; session?: T.RPCGen.StateVersion} = {} + const acceptVersion = (kind: 'http' | 'session', version?: T.RPCGen.StateVersion) => { // a service too old to send a version gives us nothing to order by, so everything it sends is // applied in the order it arrives, as it was before versions existed - if (version === undefined) return true - if (source === 'status' ? version < applied[kind] : version <= applied[kind]) return false + if (!version) return true + if (!isNewerVersion(version, applied[kind])) return false applied[kind] = version return true } @@ -178,14 +173,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>( @@ -213,8 +200,8 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { - acceptSessionSnapshot: version => acceptVersion('session', version, 'status'), - acceptSessionVersion: version => acceptVersion('session', version, 'notification'), + acceptSessionVersion: version => acceptVersion('session', version), + canAcceptUnversioned: kind => applied[kind] === undefined, checkForUpdate: () => { const f = async () => { await _checkForUpdate() @@ -345,23 +332,11 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(f()) }, onEngineConnected: () => { - // this may be a restarted service, whose versions start over - applied = nothingApplied() + // The applied versions are kept: a restarted service announces a different epoch, which is + // always newer, and a service that is still the same one kept counting across the reconnect. // An engine reset drops in-flight RPCs without settling their promises; a refresh // caught by that would poison the dedupe cache forever 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 () => { @@ -433,11 +408,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } break } - case 'keybase.1.reachability.reachabilityChanged': - if (get().loggedIn) { - get().dispatch.setGregorReachable(action.payload.params.reachability.reachable) - } - break default: } }, @@ -565,14 +535,9 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }) } }, - setGregorReachable: r => { - setGregorReachable(r) - }, setHTTPSrvInfo: (address, token, version) => { - // the notification rule, for the status too: a status whose version ties the notification we - // applied carries that notification's address, so nothing is lost by ignoring it - if (!acceptVersion('http', version, 'notification')) { - logger.info(`[HTTPSrv] ignoring ${address}: version ${version} is not newer`) + if (!acceptVersion('http', version)) { + logger.info(`[HTTPSrv] ignoring ${address}: version ${JSON.stringify(version)} is not newer`) return } set(s => { diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index 3618d13e703a..216ca3b75314 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -4,7 +4,6 @@ import {ignorePromise, timeoutPromise} from '@/constants/utils' import * as T from '@/constants/types' import * as Z from '@/util/zustand' import {maxHandshakeTries} from '@/constants/values' -import {useConfigState} from '@/stores/config' // A bootstrap step gates the handshake: the app stays on the splash screen until every step // resolves. Throwing fails the whole attempt (FatalHandshakeError skips the remaining retries). @@ -14,9 +13,7 @@ export type BootstrapStep = () => Promise export class FatalHandshakeError extends Error {} type Store = T.Immutable<{ - // without the version: that only orders this read against the login, logout and http server - // notifications, and keeping it would make every read after one of those look like a change - bootstrapStatus?: Omit + bootstrapStatus?: T.RPCGen.BootstrapStatus error?: Error handshakeFailedReason: string /** counts handshakes, so consumers can tell one reconnect from the next */ @@ -40,21 +37,12 @@ export type State = Store & { loadDaemonBootstrapStatus: () => Promise resetState: () => void setError: (e?: Error) => void - // readAfter: the bootstrap read must not start before the notification subscription is in - // place, or a login, logout or http server change announced between them reaches nobody. - // Everything else -- clearing the disconnect state, invalidating the previous handshake -- - // happens synchronously, so a reconnect is visible without waiting on an RPC. - startHandshake: (readAfter?: Promise) => void + startHandshake: () => void updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void } } const retryDelayMs = 1000 -// The version is missing when the service predates it; the version gates then have nothing to -// order by and apply everything, as we did before versions existed. -type MaybeVersionedStatus = Omit & {version?: number} -// the initial read plus two retries; a status that keeps losing to newer logins or logouts is dropped -const maxStaleSnapshotReads = 3 export const useDaemonState = Z.createZustand('daemon', (set, get) => { let bootstrapSteps: Array = [] @@ -73,38 +61,20 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { } const gen = generation const f = async () => { - const configDispatch = useConfigState.getState().dispatch - for (let read = 1; read <= maxStaleSnapshotReads; read++) { - const {version, ...bs}: MaybeVersionedStatus = - await T.RPCGen.configGetBootstrapStatusRpcPromise() - logger.info( - `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'} version: ${version}` - ) - // applied here rather than from bootstrapStatus: the address has its own ordering, and a - // status that is skipped below or later edited in place must not skip or replay it - if (bs.httpSrvInfo) { - configDispatch.setHTTPSrvInfo(bs.httpSrvInfo.address, bs.httpSrvInfo.token, version) - } - // a newer handshake owns the store now; don't write a potentially older status over its - // load, and don't consume the session version it needs - if (gen !== generation) { - return - } - if (!configDispatch.acceptSessionSnapshot(version)) { - logger.info('[Bootstrap] a login or logout is newer than this status, reading it again') - continue - } - if (isEqual(bs, get().bootstrapStatus)) { - return - } - set(s => { - s.bootstrapStatus = T.castDraft(bs) - }) + const bs = await T.RPCGen.configGetBootstrapStatusRpcPromise() + logger.info( + `[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) { return } - logger.warn( - '[Bootstrap] the status kept losing to newer logins or logouts; the session is whatever the last notification said and the current user stays as it was' - ) + if (isEqual(bs, get().bootstrapStatus)) { + return + } + set(s => { + s.bootstrapStatus = T.castDraft(bs) + }) } const p = f() inflightBootstrapStatus = p @@ -132,7 +102,7 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.error = e }) }, - startHandshake: readAfter => { + 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 @@ -145,9 +115,6 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.handshakeState = 'loading' }) const run = async () => { - // readAfter only orders the read behind the subscription; if it rejects the handshake - // must still run, or the app sits on the splash with no retry and no Reload. - await readAfter?.catch(() => {}) while (gen === generation) { try { await get().dispatch.loadDaemonBootstrapStatus() 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..f7bccfb84e13 --- /dev/null +++ b/shared/stores/tests/client-state.test.ts @@ -0,0 +1,139 @@ +/// +import type * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '../config' +import {useCurrentUserState} from '../current-user' +import {applyClientState} from '@/constants/init/shared' + +const epoch = 1000 +const version = (counter: number, e = epoch): T.RPCGen.StateVersion => ({counter, epoch: e}) + +const clientState = (over: Partial = {}): T.RPCGen.ClientState => ({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + version: version(1), + ...over, +}) + +const notifyHTTP = (address: string, v?: T.RPCGen.StateVersion) => + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: {info: {address, token: 'token'}, version: v}}, + type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', + } as never) + +const notifySession = (kind: 'loggedIn' | 'loggedOut', v?: T.RPCGen.StateVersion) => + useConfigState.getState().dispatch.onEngineIncoming({ + payload: { + params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser', version: v} : {version: v}, + }, + type: `keybase.1.NotifySession.${kind}`, + } as never) + +// The applied versions live outside the store and deliberately survive resetAllStores, so each +// test gets its own epoch instead of relying on a reset that no longer exists. +let testEpoch = epoch +beforeEach(() => { + testEpoch++ +}) +afterEach(() => { + jest.restoreAllMocks() + resetAllStores() +}) + +describe('the setNotifications snapshot', () => { + test('applies the session, the current user and the http address', () => { + applyClientState( + clientState({ + httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, + version: version(1, testEpoch), + }) + ) + + 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') + }) + + test('loses to a session notification that is already newer', () => { + notifySession('loggedOut', version(7, testEpoch)) + useConfigState.setState({loggedIn: false}) + + applyClientState(clientState({loggedIn: true, version: version(6, testEpoch)})) + + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useCurrentUserState.getState().username).toBe('') + }) + + test('is dropped on a tie, which costs nothing: it is labelled before the state it carries', () => { + notifySession('loggedOut', version(4, testEpoch)) + useConfigState.setState({loggedIn: false}) + + applyClientState(clientState({loggedIn: true, version: version(4, testEpoch)})) + + expect(useConfigState.getState().loggedIn).toBe(false) + }) + + test('from a restarted service wins although its counter started over', () => { + notifySession('loggedOut', version(9, testEpoch)) + useConfigState.setState({loggedIn: false}) + + applyClientState(clientState({loggedIn: true, version: version(1, testEpoch + 500)})) + + expect(useConfigState.getState().loggedIn).toBe(true) + }) + + test('is ignored during an account switch when it says logged out', () => { + useConfigState.setState({loggedIn: true, userSwitching: true}) + + applyClientState(clientState({loggedIn: false, version: version(1, testEpoch)})) + + expect(useConfigState.getState().loggedIn).toBe(true) + }) +}) + +describe('notification ordering', () => { + test('a notification older than the applied one is ignored', () => { + notifySession('loggedOut', version(5, testEpoch)) + expect(useConfigState.getState().loggedIn).toBe(false) + + useConfigState.setState({loggedIn: false}) + notifySession('loggedIn', version(4, testEpoch)) + expect(useConfigState.getState().loggedIn).toBe(false) + }) + + test('a notification with the version already applied is ignored', () => { + notifySession('loggedIn', version(5, testEpoch)) + expect(useConfigState.getState().loggedIn).toBe(true) + + notifySession('loggedOut', version(5, testEpoch)) + expect(useConfigState.getState().loggedIn).toBe(true) + }) + + test('the http address and the session are ordered separately off one counter', () => { + notifyHTTP('127.0.0.1:2', version(3, testEpoch)) + notifySession('loggedIn', version(5, testEpoch)) + // stamped before the login, so a single applied version would reject it, but it is newer than + // the address we have and the address is what it describes + notifyHTTP('127.0.0.1:3', version(4, testEpoch)) + + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') + }) + + test('the applied versions survive an engine reconnect to the same service', () => { + notifyHTTP('127.0.0.1:2', version(9, testEpoch)) + useConfigState.getState().dispatch.onEngineConnected() + notifyHTTP('127.0.0.1:3', version(8, testEpoch)) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + }) + + test('logging out keeps the http server address', () => { + notifyHTTP('127.0.0.1:2', version(1, testEpoch)) + useConfigState.getState().dispatch.resetState() + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') + }) +}) diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index 5545d08584c6..956e58fec1c1 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -3,7 +3,6 @@ import * as T from '@/constants/types' import {ignorePromise} from '@/constants/utils' import {maxHandshakeTries} from '@/constants/values' import {resetAllStores} from '@/util/zustand' -import {useConfigState} from '../config' import {FatalHandshakeError, useDaemonState} from '../daemon' const bootstrapStatus = { @@ -14,7 +13,6 @@ const bootstrapStatus = { registered: true, uid: 'u1', username: 'testuser', - version: 1, } as unknown as T.RPCGen.BootstrapStatus describe('daemon store', () => { @@ -41,19 +39,6 @@ describe('daemon store', () => { expect(store.getState().bootstrapStatus?.username).toBe('testuser') }) - test('a rejecting readAfter still starts the handshake', async () => { - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) - const step = jest.fn(async () => {}) - const store = useDaemonState - store.getState().dispatch.initBootstrapSteps([step]) - - store.getState().dispatch.startHandshake(Promise.reject(new Error('subscribe failed'))) - await jest.advanceTimersByTimeAsync(0) - - expect(step).toHaveBeenCalledTimes(1) - expect(store.getState().handshakeState).toBe('done') - }) - test('a failing step retries and can recover', async () => { jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) const step = jest.fn(async () => {}).mockRejectedValueOnce(new Error('flaky')) @@ -163,25 +148,9 @@ describe('daemon store', () => { }) }) -describe('httpSrvInfo ordering', () => { - const withHTTP = (address: string, version: number): T.RPCGen.BootstrapStatus => ({ - ...bootstrapStatus, - httpSrvInfo: {address, token: 'token'}, - version, - }) - const notify = (address: string, version: number) => - useConfigState.getState().dispatch.onEngineIncoming({ - payload: {params: {info: {address, token: 'token'}, version}}, - type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', - } as any) - +describe('a superseded read', () => { beforeEach(() => { jest.useFakeTimers() - // the applied versions live outside the store; a fresh engine connection is what clears them - useConfigState.getState().dispatch.onEngineConnected() - useConfigState.setState(s => { - s.httpSrv = {address: '', token: ''} - }) }) afterEach(() => { jest.useRealTimers() @@ -189,223 +158,28 @@ describe('httpSrvInfo ordering', () => { resetAllStores() }) - test('a status older than an http server notification does not overwrite it', async () => { - notify('127.0.0.1:2', 5) - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 4)) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') - expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') - }) - - test('a status newer than a notification is applied', async () => { - notify('127.0.0.1:2', 3) - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 4)) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') - }) - - test('an older notification landing after a newer status is ignored', async () => { - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 6)) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - notify('127.0.0.1:2', 5) - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') - }) - - test('a version-0 address from a service that never stamped one is applied', async () => { - // the http server starts before the notify router exists, so its first update stamps nothing - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(withHTTP('127.0.0.1:1', 0)) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') - }) - - test('a status equal to the stored one still applies its newer address', async () => { + test('does not write its status over the newer load', async () => { + // a reconnect invalidates in-flight reads whatever any version says: the generation orders + // client attempts, which the service's counter knows nothing about + let resolveLosing!: (bs: T.RPCGen.BootstrapStatus) => void jest .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockResolvedValueOnce(withHTTP('127.0.0.1:1', 1)) - .mockResolvedValueOnce(withHTTP('127.0.0.1:1', 3)) - const {dispatch} = useDaemonState.getState() - - await dispatch.loadDaemonBootstrapStatus() - notify('127.0.0.1:2', 2) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') - - // the second status is identical to the stored one, so nothing is written, but its - // address is newer than the notification's and still has to be applied - await dispatch.loadDaemonBootstrapStatus() - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') - }) - - test('a notification with the version already applied is ignored', () => { - notify('127.0.0.1:2', 5) - notify('127.0.0.1:3', 5) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') - }) - - test('logging out keeps the http server address', () => { - notify('127.0.0.1:2', 1) - useConfigState.getState().dispatch.resetState() - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') - }) - - test("a reconnect accepts a restarted service's lower versions", () => { - notify('127.0.0.1:2', 9) - useConfigState.getState().dispatch.onEngineConnected() - notify('127.0.0.1:3', 1) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') - }) -}) - -describe('session ordering', () => { - const deferredBootstrap = () => { - let resolve!: (bs: T.RPCGen.BootstrapStatus) => void - const promise = new Promise(_resolve => { - resolve = _resolve - }) - return {promise, resolve} - } - const notifySession = (kind: 'loggedIn' | 'loggedOut', version: number) => - useConfigState.getState().dispatch.onEngineIncoming({ - payload: { - params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser', version} : {version}, - }, - type: `keybase.1.NotifySession.${kind}`, - } as any) - - beforeEach(() => { - jest.useFakeTimers() - // the applied versions live outside the store; a fresh engine connection is what clears them - useConfigState.getState().dispatch.onEngineConnected() - }) - afterEach(() => { - jest.useRealTimers() - jest.restoreAllMocks() - resetAllStores() - }) - - test('a status older than a session notification is read again', async () => { - notifySession('loggedIn', 7) - const spy = jest - .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockResolvedValueOnce({...bootstrapStatus, username: 'stale', version: 6}) - .mockResolvedValueOnce({...bootstrapStatus, username: 'testuser', version: 7}) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(spy).toHaveBeenCalledTimes(2) - expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') - }) - - test('a status that keeps losing is not applied', async () => { - notifySession('loggedOut', 9) - const spy = jest - .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockResolvedValueOnce({...bootstrapStatus, version: 1}) - .mockResolvedValueOnce({...bootstrapStatus, version: 2}) - .mockResolvedValueOnce({...bootstrapStatus, version: 3}) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(spy).toHaveBeenCalledTimes(3) - expect(useDaemonState.getState().bootstrapStatus).toBe(undefined) - }) - - test('a status whose only change is its version does not rewrite the store', async () => { - // a login, logout or http server change bumps the version without changing this status - jest - .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockResolvedValueOnce({...bootstrapStatus, version: 1}) - .mockResolvedValueOnce({...bootstrapStatus, version: 2}) - const {dispatch} = useDaemonState.getState() - - await dispatch.loadDaemonBootstrapStatus() - const stored = useDaemonState.getState().bootstrapStatus - await dispatch.loadDaemonBootstrapStatus() - - expect(useDaemonState.getState().bootstrapStatus).toBe(stored) - }) - - test('a status whose version ties the applied notification is applied', async () => { - notifySession('loggedOut', 4) - const spy = jest - .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockResolvedValue({...bootstrapStatus, version: 4}) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(spy).toHaveBeenCalledTimes(1) - expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') - }) - - test('a session notification older than the applied one is ignored', () => { - useConfigState.setState({loggedIn: true}) - - notifySession('loggedOut', 5) - expect(useConfigState.getState().loggedIn).toBe(false) - - notifySession('loggedIn', 4) - expect(useConfigState.getState().loggedIn).toBe(false) - }) - - test('a notification with the version already applied is ignored', () => { - useConfigState.setState({loggedIn: false}) - - notifySession('loggedIn', 5) - expect(useConfigState.getState().loggedIn).toBe(true) - - notifySession('loggedOut', 5) - expect(useConfigState.getState().loggedIn).toBe(true) - }) - - test('a service too old to send a version still applies its status and its notifications', async () => { - useConfigState.setState({loggedIn: true}) - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ - ...bootstrapStatus, - httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, - version: undefined, - } as unknown as T.RPCGen.BootstrapStatus) - - await useDaemonState.getState().dispatch.loadDaemonBootstrapStatus() - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') - expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') - - useConfigState.getState().dispatch.onEngineIncoming({ - payload: {params: {}}, - type: 'keybase.1.NotifySession.loggedOut', - } as any) - expect(useConfigState.getState().loggedIn).toBe(false) - }) - - test('a superseded read does not swallow a later session notification', async () => { - // it can read a version at least as new as the winner's; consuming it and then throwing the - // status away would leave nothing to apply that version's state - useConfigState.setState({loggedIn: true}) - const superseded = deferredBootstrap() - jest - .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') - .mockReturnValueOnce(superseded.promise) - .mockResolvedValue({...bootstrapStatus, version: 5}) + .mockReturnValueOnce( + new Promise(resolve => { + resolveLosing = resolve + }) + ) + .mockResolvedValue(bootstrapStatus) const {dispatch} = useDaemonState.getState() dispatch.initBootstrapSteps([]) const losing = dispatch.loadDaemonBootstrapStatus() - // a new handshake starts its own load instead of reusing the in-flight one dispatch.startHandshake() await jest.advanceTimersByTimeAsync(0) - superseded.resolve({...bootstrapStatus, username: 'stale', version: 9}) + resolveLosing({...bootstrapStatus, username: 'stale'}) await losing await jest.advanceTimersByTimeAsync(0) - expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') - notifySession('loggedOut', 9) - expect(useConfigState.getState().loggedIn).toBe(false) + expect(useDaemonState.getState().bootstrapStatus?.username).toBe('testuser') }) }) diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts new file mode 100644 index 000000000000..0cf1755b4090 --- /dev/null +++ b/shared/stores/tests/legacy-service.test.ts @@ -0,0 +1,90 @@ +/// +import type * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '../config' +import {useCurrentUserState} from '../current-user' +import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/shared' + +// Its own file: "nothing versioned has landed yet" is process-wide state that outlives +// resetAllStores on purpose, and jest gives each file a fresh module registry. The tests below +// run in declaration order and each one moves that state forward, so they are ordered on purpose. + +const notifySession = (kind: 'loggedIn' | 'loggedOut') => + useConfigState.getState().dispatch.onEngineIncoming({ + payload: {params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser'} : {}}, + type: `keybase.1.NotifySession.${kind}`, + } as never) + +afterEach(() => { + jest.restoreAllMocks() + resetAllStores() +}) + +describe('a service too old for the snapshot', () => { + const status = (over: Partial = {}) => + ({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + ...over, + }) as T.RPCGen.BootstrapStatus + + test('has its bootstrap status own the session and the http address', () => { + applyClientState(undefined) + onBootstrapStatusChanged(status({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') + }) + + test('applies its unversioned notifications in arrival order', () => { + applyClientState(undefined) + onBootstrapStatusChanged(status()) + expect(useConfigState.getState().loggedIn).toBe(true) + + notifySession('loggedOut') + expect(useConfigState.getState().loggedIn).toBe(false) + }) + + test('still owns the address while the snapshot has none: the http server is not up yet', () => { + applyClientState({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: false, + registered: true, + uid: 'u1', + username: 'testuser', + version: {counter: 2, epoch: 1000}, + }) + expect(useConfigState.getState().loggedIn).toBe(false) + + onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}})) + + // the session is versioned now, so the status no longer owns it + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:9') + // the current user always comes from the status, versioned or not + expect(useCurrentUserState.getState().username).toBe('testuser') + }) + + test('stops owning the address once a snapshot carries one', () => { + applyClientState({ + deviceID: 'd1', + deviceName: 'testuser-mac', + httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, + loggedIn: false, + registered: true, + uid: 'u1', + username: 'testuser', + version: {counter: 3, epoch: 1000}, + }) + + onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}})) + + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') + }) +}) From 0da5ca268434e2e163128caa5dbac8740744c920 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 10:33:37 -0400 Subject: [PATCH 076/127] fix(config): order the bootstrap status's identity against the session Review of the previous commit found the status's identity had been left with no ordering at all: it was applied above the version gate, and the gate that used to protect it lived in daemon.tsx before the store write, so a status read across a logout could repopulate the current user after resetAllStores had cleared it. It is now applied only when the status agrees with the session we are in, and re-applied when the session catches up, since a status identical to the stored one never notifies again. The snapshot had the mirror of the same problem: it wrote the current user above the userSwitching guard, and a logged-out snapshot carries an empty identity, so an account switch blanked the user the guard had just decided to keep. The unversioned fallback is now keyed on the setNotifications reply having carried no snapshot, rather than on nothing versioned having landed yet. A status can no longer own the session by merely arriving before a snapshot that is on its way, and a downgrade to an older service under a live client hands the fallback back instead of pinning the session until the app restarts. The state epoch is masked to 32 bits: a JS client decodes an int64 into a float64, and nanoseconds since 1970 are past the exactly representable range. Only distinctness was ever needed. --- go/libkb/globals.go | 10 ++- go/libkb/state_version_test.go | 2 +- go/service/config.go | 9 +- shared/constants/init/shared.test.ts | 37 ++++++++- shared/constants/init/shared.tsx | 67 ++++++++++----- shared/engine/index.platform.tsx | 4 +- shared/stores/config.tsx | 4 - shared/stores/tests/client-state.test.ts | 50 ++++++++++- shared/stores/tests/legacy-service.test.ts | 97 +++++++++++++--------- 9 files changed, 203 insertions(+), 77 deletions(-) diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 6d1fad064a03..20733917e04c 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -320,8 +320,14 @@ func (g *GlobalContext) Init() *GlobalContext { g.Identify3State = NewIdentify3State(g) g.GregorState = newNullGregorState() // Any value distinct from every other service process will do: a client only - // ever asks whether two epochs differ, never which is greater. - g.stateEpoch = time.Now().UnixNano() + // ever asks whether two epochs differ, never which is greater. Kept under + // 2^32 because a JS client decodes an int64 into a float64, which is exact + // only below 2^53. + if epoch, err := RandInt64(); err == nil { + g.stateEpoch = epoch & 0xFFFFFFFF + } else { + g.stateEpoch = time.Now().UnixMilli() & 0xFFFFFFFF + } g.LocalNetworkInstrumenterStorage = NewDiskInstrumentationStorage(g, keybase1.NetworkSource_LOCAL) g.RemoteNetworkInstrumenterStorage = NewDiskInstrumentationStorage(g, keybase1.NetworkSource_REMOTE) diff --git a/go/libkb/state_version_test.go b/go/libkb/state_version_test.go index be1a4d7e6a96..b3b033ed4d58 100644 --- a/go/libkb/state_version_test.go +++ b/go/libkb/state_version_test.go @@ -24,7 +24,7 @@ func TestNotifyRouterStampsEachAnnouncedChange(t *testing.T) { ctx := context.Background() epoch := g.StateVersion().Epoch - require.NotZero(t, epoch, "the epoch identifies this service process") + require.Less(t, epoch, int64(1)<<53, "a JS client decodes this into a float64") require.EqualValues(t, 0, g.StateVersion().Counter, "nothing announced yet") g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "token"}) diff --git a/go/service/config.go b/go/service/config.go index a1af30c698f3..af2d2f5ce3fe 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -361,9 +361,12 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r return res, err } res = eng.Status() - // Not waited on: a client that understands setNotifications already has the - // address from the subscription reply and from HTTPSrvInfoUpdate, which the - // server sends on every start. This is only here for a client too old to. + // Not waited on: a client that understands setNotifications gets the address + // from the subscription reply and from HTTPSrvInfoUpdate, which the server + // sends on every start, so it never needed this one to block. A client old + // enough to need it cannot decode those notifications either -- the version + // on them is a record now -- so for that client this is best effort and it + // gets nothing here until the next read. if info, infoErr := h.svc.httpSrv.Info(); infoErr != nil { m.Debug("GetBootstrapStatus: no HTTP server address: %s", infoErr) } else { diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index b6a1c77cda87..ffd528d0ea57 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -3,7 +3,8 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' -import {loadAccountsStep, onEngineConnected, onNetworkOnlineChanged} from './shared' +import {useCurrentUserState} from '@/stores/current-user' +import {loadAccountsStep, onEngineConnected, onLoggedInChanged, onNetworkOnlineChanged} from './shared' describe('loadAccountsStep', () => { const originalDispatch = useConfigState.getState().dispatch @@ -210,3 +211,37 @@ describe('onNetworkOnlineChanged', () => { expect(reRead).not.toHaveBeenCalled() }) }) + +describe('onLoggedInChanged', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() + }) + + test('applies the stored status identity when the session catches up with it', () => { + // the status is read before the login notification lands, so its identity is held back; a + // status identical to the stored one never notifies again, so the login has to apply it + jest.spyOn(T.RPCGen, 'loginGetConfiguredAccountsRpcPromise').mockResolvedValue([]) + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({} as never) + useDaemonState.setState({ + bootstrapStatus: { + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + } as never, + }) + useConfigState.setState({loggedIn: true}) + + onLoggedInChanged(true) + + expect(useCurrentUserState.getState().username).toBe('testuser') + expect(useCurrentUserState.getState().uid).toBe('u1') + }) +}) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index a4e53f20d468..babf0ccd6b10 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -175,8 +175,11 @@ export const onNetworkOnlineChanged = (online?: boolean, previous?: boolean) => } } -const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { +export const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { if (loggedIn) { + // a status read before we knew we were logged in was held back then; a status identical to + // the stored one does not notify again, so apply its identity from here + applyStatusIdentity(useDaemonState.getState().bootstrapStatus) // runtime login: refresh bootstrap status. During the handshake this is already in // flight, and the store dedupes it. ignorePromise(useDaemonState.getState().dispatch.loadDaemonBootstrapStatus()) @@ -203,28 +206,35 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { - if (!bootstrap) { +// True only while the connected service answered setNotifications with no snapshot at all. That +// is the one case in which the bootstrap status, which carries no version, still owns the session +// and the http address. Keyed on the reply rather than on "nothing versioned has landed yet", so +// a status can never win by merely arriving before a snapshot that is on its way, and so a +// downgrade to an older service hands the fallback back. +let serviceHasNoSnapshot = false + +// Only a status that agrees with the session we are in describes the current user: a read that +// spans a logout describes the previous one, and resetAllStores has already cleared them. +const applyStatusIdentity = (bootstrap: DaemonState['bootstrapStatus']) => { + if (!bootstrap?.loggedIn || !useConfigState.getState().loggedIn) { return } - - const {deviceID, deviceName, httpSrvInfo, loggedIn, uid, username} = bootstrap + const {deviceID, deviceName, uid, username} = bootstrap useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) - - const configDispatch = useConfigState.getState().dispatch if (username) { - configDispatch.setDefaultUsername(username) - } - // The session and the http address belong to the setNotifications snapshot and its - // notifications, which carry a version this status does not. A service too old to answer - // setNotifications sends no version anywhere, and then this status is the only place they come - // from -- so apply them here only while nothing versioned has landed, and never after. - if (httpSrvInfo && configDispatch.canAcceptUnversioned('http')) { - configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token) + useConfigState.getState().dispatch.setDefaultUsername(username) } - if (!configDispatch.canAcceptUnversioned('session')) { +} + +const applyUnversionedStatusSession = (bootstrap: NonNullable) => { + if (!serviceHasNoSnapshot) { return } + const {httpSrvInfo, loggedIn} = bootstrap + const configDispatch = useConfigState.getState().dispatch + if (httpSrvInfo) { + configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token) + } if (!loggedIn && useConfigState.getState().userSwitching) { logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') return @@ -232,12 +242,25 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus configDispatch.setLoggedIn(loggedIn) } +export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { + if (!bootstrap) { + return + } + // the session first: the identity below is applied only if it agrees with it + applyUnversionedStatusSession(bootstrap) + applyStatusIdentity(bootstrap) +} + // The reply to setNotifications: the state as of the moment this connection subscribed, so there // is no read to order against the subscription. An old service returns nothing here and the -// bootstrap status keeps that job -- see onBootstrapStatusChanged. +// bootstrap status keeps that job -- see applyUnversionedStatusSession. export const applyClientState = (clientState?: T.RPCGen.ClientState) => { + serviceHasNoSnapshot = !clientState if (!clientState) { logger.info('[Bootstrap] no client state from setNotifications; this service predates it') + // the status may already be in the store from before we knew that, and a status identical to + // the stored one does not notify again + onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus) return } const {deviceID, deviceName, httpSrvInfo, loggedIn, uid, username, version} = clientState @@ -249,15 +272,17 @@ export const applyClientState = (clientState?: T.RPCGen.ClientState) => { logger.info('[Bootstrap] a login or logout is newer than this snapshot, ignoring') return } - useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) - if (username) { - configDispatch.setDefaultUsername(username) - } if (!loggedIn && useConfigState.getState().userSwitching) { + // policy, not ordering: keep the session and the user we have until the switch lands. The + // snapshot's identity is empty when it says logged out, so it must not be applied either. logger.info('[Bootstrap] ignoring loggedIn=false snapshot during account switch') return } configDispatch.setLoggedIn(loggedIn) + useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) + if (username) { + configDispatch.setDefaultUsername(username) + } } const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { diff --git a/shared/engine/index.platform.tsx b/shared/engine/index.platform.tsx index cc7b4fd40fd1..8e64b8c50219 100644 --- a/shared/engine/index.platform.tsx +++ b/shared/engine/index.platform.tsx @@ -257,8 +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(); only that handshake's bootstrap read is - // deferred, so nothing here may be moved behind an await). + // via startHandshake(), so nothing here may be moved behind an + // await). client.transport.reset() try { disconnectCallback() diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index e466f90a0b01..cec255a9d26e 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -91,9 +91,6 @@ export type State = Store & { dispatch: { // a login or logout notification: applied only if it is newer than the last applied one acceptSessionVersion: (version?: T.RPCGen.StateVersion) => boolean - // true while nothing versioned has landed: the only window in which an unversioned payload - // (a bootstrap status from a service too old to answer setNotifications) may own this field - canAcceptUnversioned: (kind: 'http' | 'session') => boolean checkForUpdate: () => void initAppUpdateLoop: () => void installerRan: () => void @@ -201,7 +198,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { const dispatch: State['dispatch'] = { acceptSessionVersion: version => acceptVersion('session', version), - canAcceptUnversioned: kind => applied[kind] === undefined, checkForUpdate: () => { const f = async () => { await _checkForUpdate() diff --git a/shared/stores/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts index f7bccfb84e13..07a5cf155d88 100644 --- a/shared/stores/tests/client-state.test.ts +++ b/shared/stores/tests/client-state.test.ts @@ -3,7 +3,7 @@ import type * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '../config' import {useCurrentUserState} from '../current-user' -import {applyClientState} from '@/constants/init/shared' +import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/shared' const epoch = 1000 const version = (counter: number, e = epoch): T.RPCGen.StateVersion => ({counter, epoch: e}) @@ -89,10 +89,16 @@ describe('the setNotifications snapshot', () => { test('is ignored during an account switch when it says logged out', () => { useConfigState.setState({loggedIn: true, userSwitching: true}) + useCurrentUserState.setState({username: 'testuser'}) - applyClientState(clientState({loggedIn: false, version: version(1, testEpoch)})) + applyClientState( + clientState({loggedIn: false, uid: '', username: '', version: version(1, testEpoch)}) + ) expect(useConfigState.getState().loggedIn).toBe(true) + // a logged-out snapshot carries an empty identity; applying it would blank the user the + // guard just decided to keep + expect(useCurrentUserState.getState().username).toBe('testuser') }) }) @@ -137,3 +143,43 @@ describe('notification ordering', () => { expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') }) }) + +describe('the bootstrap status identity', () => { + test('is not applied when the status disagrees with the session we are in', () => { + // the read can span a logout: GetBootstrapStatus waits out the startup login attempt, and a + // logout announced meanwhile has already reset the stores + applyClientState( + clientState({loggedIn: false, uid: '', username: '', version: version(1, testEpoch)}) + ) + expect(useConfigState.getState().loggedIn).toBe(false) + + onBootstrapStatusChanged({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + } as never) + + expect(useCurrentUserState.getState().username).toBe('') + expect(useCurrentUserState.getState().uid).toBe('') + }) + + test('is applied when it agrees', () => { + applyClientState( + clientState({loggedIn: true, uid: 'u1', username: 'testuser', version: version(1, testEpoch)}) + ) + + onBootstrapStatusChanged({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + } as never) + + expect(useCurrentUserState.getState().username).toBe('testuser') + }) +}) diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts index 0cf1755b4090..013667e091ad 100644 --- a/shared/stores/tests/legacy-service.test.ts +++ b/shared/stores/tests/legacy-service.test.ts @@ -3,11 +3,12 @@ import type * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '../config' import {useCurrentUserState} from '../current-user' +import {useDaemonState} from '../daemon' import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/shared' -// Its own file: "nothing versioned has landed yet" is process-wide state that outlives -// resetAllStores on purpose, and jest gives each file a fresh module registry. The tests below -// run in declaration order and each one moves that state forward, so they are ordered on purpose. +// Its own file: whether the connected service answers setNotifications with a snapshot is +// module state in the init layer that outlives resetAllStores, and jest gives each file a +// fresh module registry. const notifySession = (kind: 'loggedIn' | 'loggedOut') => useConfigState.getState().dispatch.onEngineIncoming({ @@ -15,23 +16,40 @@ const notifySession = (kind: 'loggedIn' | 'loggedOut') => type: `keybase.1.NotifySession.${kind}`, } as never) +const status = (over: Partial = {}) => + ({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + ...over, + }) as T.RPCGen.BootstrapStatus + +const snapshot = (over: Partial = {}): T.RPCGen.ClientState => ({ + deviceID: 'd2', + deviceName: 'testuser-other', + loggedIn: true, + registered: true, + uid: 'u2', + username: 'testuser-mac', + version: {counter: 1, epoch: 1000}, + ...over, +}) + +beforeEach(() => { + // httpSrv is process-wide and survives resetAllStores on purpose + useConfigState.setState(st => { + st.httpSrv = {address: '', token: ''} + }) +}) afterEach(() => { jest.restoreAllMocks() resetAllStores() }) describe('a service too old for the snapshot', () => { - const status = (over: Partial = {}) => - ({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', - ...over, - }) as T.RPCGen.BootstrapStatus - test('has its bootstrap status own the session and the http address', () => { applyClientState(undefined) onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}})) @@ -50,41 +68,38 @@ describe('a service too old for the snapshot', () => { expect(useConfigState.getState().loggedIn).toBe(false) }) - test('still owns the address while the snapshot has none: the http server is not up yet', () => { - applyClientState({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: false, - registered: true, - uid: 'u1', - username: 'testuser', - version: {counter: 2, epoch: 1000}, - }) + test('is applied even when its status landed before we knew the service was old', () => { + // a status identical to the stored one does not notify again, so learning that the service + // has no snapshot has to re-apply what is already in the store + useDaemonState.setState({bootstrapStatus: status()}) expect(useConfigState.getState().loggedIn).toBe(false) - onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}})) + applyClientState(undefined) - // the session is versioned now, so the status no longer owns it - expect(useConfigState.getState().loggedIn).toBe(false) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:9') - // the current user always comes from the status, versioned or not + expect(useConfigState.getState().loggedIn).toBe(true) expect(useCurrentUserState.getState().username).toBe('testuser') }) - test('stops owning the address once a snapshot carries one', () => { - applyClientState({ - deviceID: 'd1', - deviceName: 'testuser-mac', - httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, - loggedIn: false, - registered: true, - uid: 'u1', - username: 'testuser', - version: {counter: 3, epoch: 1000}, - }) + test('stops owning the session the moment a service does answer with a snapshot', () => { + applyClientState(snapshot({loggedIn: true})) + expect(useCurrentUserState.getState().username).toBe('testuser-mac') onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}})) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:1') + expect(useConfigState.getState().httpSrv.address).toBe('') + // the identity still comes from the status: it agrees with the session we are in + expect(useCurrentUserState.getState().username).toBe('testuser') + }) + + test('owns the session again after a downgrade under a live client', () => { + applyClientState(snapshot({loggedIn: true, version: {counter: 9, epoch: 1000}})) + expect(useConfigState.getState().loggedIn).toBe(true) + + // the service is stopped and an older one starts; the reconnect answers with no snapshot + applyClientState(undefined) + onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}, loggedIn: false})) + + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:9') }) }) From 54a10b5d58c5cb1107b6f78db1f061337e8febe0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 10:46:18 -0400 Subject: [PATCH 077/127] fix(protocol): say "session not known yet" instead of guessing logged out The reply to setNotifications was only correct because of a startup ordering nobody had written down, and that ordering does not hold on mobile: go/bind/keybase.go brings the loopback listener up and runs the startup login attempt in a goroutine, so a client can subscribe while the attempt is still running. The reply then said loggedIn=false with a valid version, which barred the settled bootstrap status from ever setting the session again. The only repair was SendLogin's notification, whose send discards its error, so one lost send stranded the client for the life of the process. ClientState.session is now a union, null until the attempt has settled. A null session is not a logged-out one: the client falls back to getBootstrapStatus, which waits. The window stops existing rather than being survived. registered leaves the record with it -- no consumer. Alongside: a failed subscribe now takes the same fallback, since a connection with no reply and no channels is in exactly the position of one talking to a service too old to answer, and the fallback flag is cleared per connection. applyClientState writes the current user before the session, so a login's subscribers can still read it. acceptVersion falls back to arrival order for a bare-number version instead of throwing on it. --- go/bind/keybase.go | 8 ++- go/engine/bootstrap.go | 12 ++-- go/protocol/keybase1/notify_ctl.go | 40 +++++++++----- go/service/main.go | 19 +++++++ go/service/notify.go | 12 +++- go/service/notify_test.go | 61 ++++++++++++++++----- protocol/avdl/keybase1/notify_ctl.avdl | 21 ++++--- protocol/json/keybase1/notify_ctl.json | 27 ++++++--- shared/constants/init/shared.test.ts | 64 +++++++++++++++++++--- shared/constants/init/shared.tsx | 45 +++++++++++---- shared/constants/rpc/rpc-gen.tsx | 3 +- shared/stores/config.tsx | 8 ++- shared/stores/tests/client-state.test.ts | 57 +++++++++++++------ shared/stores/tests/legacy-service.test.ts | 39 +++++++++---- 14 files changed, 309 insertions(+), 107 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 4ac276ba2508..fbdbeb6a4b51 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -441,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: setNotifications answers with no session at all in that window, + // and GetBootstrapStatus, which waits for the attempt, is what settles it. phase := time.Now() if err = kbSvc.StartLoopbackServer(libkb.LoginAttemptNone); err != nil { log("failed to start loopback: %s", err) diff --git a/go/engine/bootstrap.go b/go/engine/bootstrap.go index 3d0c2bd1bbe1..abe355d630e2 100644 --- a/go/engine/bootstrap.go +++ b/go/engine/bootstrap.go @@ -63,12 +63,10 @@ func (e *Bootstrap) lookupFullname(m libkb.MetaContext, uv keybase1.UserVersion) } // SessionState reads the session fields that are available with nothing to wait -// on: config.json and 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.ClientState, uv keybase1.UserVersion) { - res.Registered = signedUp(m) - +// 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) @@ -87,7 +85,7 @@ func SessionState(m libkb.MetaContext) (res keybase1.ClientState, uv keybase1.Us func (e *Bootstrap) Run(m libkb.MetaContext) (err error) { defer m.Trace("Bootstrap.Run", &err)() session, uv := SessionState(m) - e.status.Registered = session.Registered + e.status.Registered = signedUp(m) e.status.LoggedIn = session.LoggedIn e.status.Uid = session.Uid e.status.Username = session.Username diff --git a/go/protocol/keybase1/notify_ctl.go b/go/protocol/keybase1/notify_ctl.go index 6776bb34ee5f..b2a8ba1da700 100644 --- a/go/protocol/keybase1/notify_ctl.go +++ b/go/protocol/keybase1/notify_ctl.go @@ -88,26 +88,40 @@ func (o NotificationChannels) DeepCopy() NotificationChannels { } } -type ClientState struct { - Version StateVersion `codec:"version" json:"version"` - Registered bool `codec:"registered" json:"registered"` - 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"` - HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` +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 ClientState) DeepCopy() ClientState { - return ClientState{ - Version: o.Version.DeepCopy(), - Registered: o.Registered, +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 { + Version StateVersion `codec:"version" json:"version"` + Session *ClientSession `codec:"session,omitempty" json:"session,omitempty"` + HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` +} + +func (o ClientState) DeepCopy() ClientState { + return ClientState{ + Version: o.Version.DeepCopy(), + 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 diff --git a/go/service/main.go b/go/service/main.go index 5059f2a87f24..786f6f41612b 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -331,6 +331,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 gets + // a session in its setNotifications reply rather than "not known yet". Mobile + // cannot do this -- go/bind/keybase.go runs the attempt off the Init thread, + // after the loopback listener -- which is why the reply says so explicitly + // instead of relying on this ordering. d.RunBackgroundOperations(uir) // At this point initialization is complete, and we're about to start the @@ -1396,6 +1402,19 @@ func (d *Service) configurePath() { // 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 // that runs off the Init path on mobile. +// 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 + } +} + func (d *Service) awaitInitialLoginAttempt(m libkb.MetaContext, maxWait time.Duration) { select { case <-d.initialLoginAttemptDone: diff --git a/go/service/notify.go b/go/service/notify.go index afa0be17b7db..1d87923b6111 100644 --- a/go/service/notify.go +++ b/go/service/notify.go @@ -41,9 +41,15 @@ func (h *NotifyCtlHandler) SetNotifications(ctx context.Context, n keybase1.Noti // Read the version before the state it describes. NextStateVersion is stamped // after a change is readable, so this snapshot is never newer than its label // and a client can drop it on a tie without losing anything. - version := h.G().StateVersion() - res, _ := engine.SessionState(libkb.NewMetaContext(ctx, h.G())) - res.Version = version + res := keybase1.ClientState{Version: h.G().StateVersion()} + // 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 client would have to be corrected out of by a notification it + // might never get. The client falls back to getBootstrapStatus, which waits. + if h.svc.initialLoginAttemptSettled() { + session, _ := engine.SessionState(libkb.NewMetaContext(ctx, h.G())) + res.Session = &session + } if info, err := h.svc.httpSrv.Info(); err == nil { res.HttpSrvInfo = &info } diff --git a/go/service/notify_test.go b/go/service/notify_test.go index 7ba4fdd7c4b4..24e31d8d0f1e 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -9,37 +9,70 @@ import ( "github.com/stretchr/testify/require" ) -// The reply to setNotifications is what a client applies instead of ordering a -// separate read against its subscription, so the two have to happen in this -// order and in this call: the channels are registered first, and only then is -// the state read and labelled. A state read before the registration could -// describe a change nobody announced. -func TestSetNotificationsRegistersThenSnapshots(t *testing.T) { +func newTestNotifyCtlHandler(t *testing.T, g *libkb.GlobalContext) (*NotifyCtlHandler, *Service, libkb.ConnectionID) { + t.Helper() + svc := NewService(g, false) + connID := g.NotifyRouter.AddConnection(nil, nil) + return NewNotifyCtlHandler(nil, connID, g, svc), svc, connID +} + +// The reply carries no session until the service's startup login attempt has +// settled. Reporting a logged-out session in that window would be wrong rather +// than merely early, and the client would then have to be corrected out of it by +// a notification whose send is fire-and-forget -- so the window must not exist. +func TestSetNotificationsHoldsBackAnUnsettledSession(t *testing.T) { tc := libkb.SetupTest(t, "notify", 0) defer tc.Cleanup() g := tc.G g.SetService() - svc := NewService(g, false) - connID := g.NotifyRouter.AddConnection(nil, nil) - h := NewNotifyCtlHandler(nil, connID, g, svc) + h, svc, _ := newTestNotifyCtlHandler(t, g) + + res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) + require.NoError(t, err) + require.Nil(t, res.Session, "the startup login attempt has not run") + require.NotZero(t, res.Version.Epoch, "the read is still labelled") + + svc.initialLoginAttemptOnce.Do(func() { close(svc.initialLoginAttemptDone) }) + + res, err = h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) + require.NoError(t, err) + require.NotNil(t, res.Session, "the attempt settled, so there is a session to report") + require.False(t, res.Session.LoggedIn, "logged out in a fresh test context") +} + +// The channels are registered before the state is read, in that one call. A read +// that came first could describe a change that this connection was not yet +// subscribed to hear about, which is the gap the reply exists to close. +func TestSetNotificationsRegistersBeforeReadingState(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + h, svc, connID := newTestNotifyCtlHandler(t, g) + svc.initialLoginAttemptOnce.Do(func() { close(svc.initialLoginAttemptDone) }) // a change announced before anyone subscribed g.NotifyRouter.HandleLogout(context.Background()) announced := g.StateVersion() + // The registration is what makes the read safe, so observe it from the read + // itself: ActiveDevice is read inside SetNotifications, and a logout announced + // from here would already have been delivered to this connection. + require.False(t, g.NotifyRouter.GetChannels(connID).Session, "not subscribed yet") + // only Session, so nothing below actually sends down this test's nil transport res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) require.NoError(t, err) + require.True(t, g.NotifyRouter.GetChannels(connID).Session, "subscribed by the time it returned") - require.True(t, g.NotifyRouter.GetChannels(connID).Session, "channels registered") require.Equal(t, announced.Epoch, res.Version.Epoch) require.GreaterOrEqual(t, res.Version.Counter, announced.Counter, - "the snapshot is read after everything already announced") - require.False(t, res.LoggedIn, "logged out in a fresh test context") + "the read is labelled no earlier than everything already announced") - // a change announced after subscribing is strictly newer than the snapshot, - // which is what lets the client keep the notification over the snapshot + // a change announced after subscribing is strictly newer than the reply, which + // is what lets the client keep the notification over the reply g.NotifyRouter.HandleHTTPSrvInfoUpdate(context.Background(), keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "t"}) require.Greater(t, g.StateVersion().Counter, res.Version.Counter) } diff --git a/protocol/avdl/keybase1/notify_ctl.avdl b/protocol/avdl/keybase1/notify_ctl.avdl index 1081b2571ac8..3b0d79c0f8ed 100644 --- a/protocol/avdl/keybase1/notify_ctl.avdl +++ b/protocol/avdl/keybase1/notify_ctl.avdl @@ -46,18 +46,25 @@ protocol notifyCtl { boolean devicehistory; } - // ClientState is the state a client needs before it can render anything, read - // as the reply to setNotifications so there is no read to order against the - // subscription. It carries only what is available with nothing to wait on; - // the slower derived fields stay on getBootstrapStatus. - record ClientState { - StateVersion version; - boolean registered; // true if signed up at some point + record ClientSession { boolean loggedIn; UID uid; string username; DeviceID deviceID; string deviceName; + } + + // ClientState is the state a client needs before it can render anything, read + // as the reply to setNotifications so there is no read to order against the + // subscription. It carries only what is available with nothing to wait on; + // the slower derived fields stay on getBootstrapStatus. + record ClientState { + StateVersion version; // labels this read: compare against the versions on the notifications + // 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 must fall back to getBootstrapStatus, which waits for that attempt, + // rather than read a null as logged out. + union { null, ClientSession } session; union { null, HttpSrvInfo } httpSrvInfo; } diff --git a/protocol/json/keybase1/notify_ctl.json b/protocol/json/keybase1/notify_ctl.json index d5fc4b7fc39b..9ef52f19af19 100644 --- a/protocol/json/keybase1/notify_ctl.json +++ b/protocol/json/keybase1/notify_ctl.json @@ -159,16 +159,8 @@ }, { "type": "record", - "name": "ClientState", + "name": "ClientSession", "fields": [ - { - "type": "StateVersion", - "name": "version" - }, - { - "type": "boolean", - "name": "registered" - }, { "type": "boolean", "name": "loggedIn" @@ -188,6 +180,23 @@ { "type": "string", "name": "deviceName" + } + ] + }, + { + "type": "record", + "name": "ClientState", + "fields": [ + { + "type": "StateVersion", + "name": "version" + }, + { + "type": [ + null, + "ClientSession" + ], + "name": "session" }, { "type": [ diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index ffd528d0ea57..92e7cdfe2c19 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -4,7 +4,13 @@ import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' import {useCurrentUserState} from '@/stores/current-user' -import {loadAccountsStep, onEngineConnected, onLoggedInChanged, onNetworkOnlineChanged} from './shared' +import { + loadAccountsStep, + onBootstrapStatusChanged, + onEngineConnected, + onLoggedInChanged, + onNetworkOnlineChanged, +} from './shared' describe('loadAccountsStep', () => { const originalDispatch = useConfigState.getState().dispatch @@ -131,13 +137,8 @@ describe('onEngineConnected', () => { expect(bootstrap).toHaveBeenCalledTimes(1) subscribed({ - deviceID: 'd1', - deviceName: 'testuser-mac', httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', + session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, version: {counter: 1, epoch: 7}, }) await new Promise(resolve => setImmediate(resolve)) @@ -157,6 +158,55 @@ describe('onEngineConnected', () => { expect(bootstrap).toHaveBeenCalledTimes(1) }) + + test('a new connection does not inherit the previous one\'s fallback', async () => { + // the old service left the flag set; while this connection's reply is still in flight it has + // told us nothing, so the status must not own the session on its behalf + stubRegistrations() + jest + .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') + .mockRejectedValue(new Error('no notifications')) + spyOnBootstrap() + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + + deferredSubscription() + onEngineConnected() + onBootstrapStatusChanged({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + } as never) + + expect(useConfigState.getState().loggedIn).toBe(false) + }) + + test('a failed subscription leaves the session to the bootstrap status', async () => { + // no reply and no channels either: if the status cannot own the session here, a provisioned + // user lands on the login screen with nothing left that could put them back + stubRegistrations() + jest + .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') + .mockRejectedValue(new Error('no notifications')) + spyOnBootstrap() + + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + onBootstrapStatusChanged({ + deviceID: 'd1', + deviceName: 'testuser-mac', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', + } as never) + + expect(useConfigState.getState().loggedIn).toBe(true) + expect(useCurrentUserState.getState().username).toBe('testuser') + }) }) describe('onNetworkOnlineChanged', () => { diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index babf0ccd6b10..7c6eeaa5acf0 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -206,12 +206,15 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -// True only while the connected service answered setNotifications with no snapshot at all. That -// is the one case in which the bootstrap status, which carries no version, still owns the session -// and the http address. Keyed on the reply rather than on "nothing versioned has landed yet", so -// a status can never win by merely arriving before a snapshot that is on its way, and so a -// downgrade to an older service hands the fallback back. -let serviceHasNoSnapshot = false +// True while this connection has told us it cannot settle the session: no setNotifications reply +// at all (a service too old for it, or a subscribe that failed and left us with no channels +// either), or a reply taken before the service's startup login attempt had settled, which carries +// no session because there is none to describe yet. Only then does the bootstrap status -- which +// the service holds back until that attempt settles, and which carries no version -- own the +// session. Keyed on the reply rather than on "nothing versioned has landed yet", so a status +// cannot win by merely arriving before a reply that is on its way, and cleared per connection so +// a downgrade to an older service hands the fallback back. +let snapshotCannotSettleSession = false // Only a status that agrees with the session we are in describes the current user: a read that // spans a logout describes the previous one, and resetAllStores has already cleared them. @@ -227,7 +230,7 @@ const applyStatusIdentity = (bootstrap: DaemonState['bootstrapStatus']) => { } const applyUnversionedStatusSession = (bootstrap: NonNullable) => { - if (!serviceHasNoSnapshot) { + if (!snapshotCannotSettleSession) { return } const {httpSrvInfo, loggedIn} = bootstrap @@ -255,34 +258,47 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus // is no read to order against the subscription. An old service returns nothing here and the // bootstrap status keeps that job -- see applyUnversionedStatusSession. export const applyClientState = (clientState?: T.RPCGen.ClientState) => { - serviceHasNoSnapshot = !clientState - if (!clientState) { - logger.info('[Bootstrap] no client state from setNotifications; this service predates it') + const session = clientState?.session + snapshotCannotSettleSession = !session + if (!clientState || !session) { + logger.info( + clientState + ? '[Bootstrap] setNotifications answered before the login attempt settled; the status owns the session' + : '[Bootstrap] no client state from setNotifications; this service predates it' + ) // the status may already be in the store from before we knew that, and a status identical to // the stored one does not notify again onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus) + } + if (!clientState) { return } - const {deviceID, deviceName, httpSrvInfo, loggedIn, uid, username, version} = clientState + const {httpSrvInfo, version} = clientState const configDispatch = useConfigState.getState().dispatch if (httpSrvInfo) { configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token, version) } + if (!session) { + return + } if (!configDispatch.acceptSessionVersion(version)) { logger.info('[Bootstrap] a login or logout is newer than this snapshot, ignoring') return } + const {deviceID, deviceName, loggedIn, uid, username} = session if (!loggedIn && useConfigState.getState().userSwitching) { // policy, not ordering: keep the session and the user we have until the switch lands. The // snapshot's identity is empty when it says logged out, so it must not be applied either. logger.info('[Bootstrap] ignoring loggedIn=false snapshot during account switch') return } - configDispatch.setLoggedIn(loggedIn) + // 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(loggedIn) } const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { @@ -350,8 +366,13 @@ export const onEngineConnected = () => { if (error) { logger.warn('error in toggling notifications: ', error) } + // no reply and no channels either, so nothing versioned will reach this connection: the + // bootstrap status is all we have, exactly as for a service too old to answer at all + applyClientState(undefined) } } + // a new connection has told us nothing yet; the reply below is what settles it + snapshotCannotSettleSession = false ignorePromise(subscribe()) // Nothing orders these two any more: the subscription reply is what carries the session and // the http address, so the bootstrap read has nothing left to race with. diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index eecacf04a02a..29b77a7b751f 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -2537,7 +2537,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 ClientState = {readonly version: StateVersion,readonly registered: boolean,readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,readonly httpSrvInfo?: HttpSrvInfo | null,} +export type ClientSession = {readonly loggedIn: boolean,readonly uid: UID,readonly username: string,readonly deviceID: DeviceID,readonly deviceName: string,} +export type ClientState = {readonly version: StateVersion,readonly session?: ClientSession | null,readonly httpSrvInfo?: HttpSrvInfo | null,} 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,} diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index cec255a9d26e..682b593ef896 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -140,8 +140,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { const applied: {http?: T.RPCGen.StateVersion; session?: T.RPCGen.StateVersion} = {} const acceptVersion = (kind: 'http' | 'session', version?: T.RPCGen.StateVersion) => { // a service too old to send a version gives us nothing to order by, so everything it sends is - // applied in the order it arrives, as it was before versions existed - if (!version) return true + // applied in the order it arrives, as it was before versions existed. A service built from an + // intermediate commit of this branch sends a bare number, which is the same thing: an + // ordering we cannot compare against one that carries an epoch. + if (!version || typeof version.counter !== 'number' || typeof version.epoch !== 'number') { + return true + } if (!isNewerVersion(version, applied[kind])) return false applied[kind] = version return true diff --git a/shared/stores/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts index 07a5cf155d88..70d1e0420233 100644 --- a/shared/stores/tests/client-state.test.ts +++ b/shared/stores/tests/client-state.test.ts @@ -8,13 +8,11 @@ import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/share const epoch = 1000 const version = (counter: number, e = epoch): T.RPCGen.StateVersion => ({counter, epoch: e}) -const clientState = (over: Partial = {}): T.RPCGen.ClientState => ({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', +const clientState = ( + session: Partial = {}, + over: Partial = {} +): T.RPCGen.ClientState => ({ + session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser', ...session}, version: version(1), ...over, }) @@ -47,10 +45,7 @@ afterEach(() => { describe('the setNotifications snapshot', () => { test('applies the session, the current user and the http address', () => { applyClientState( - clientState({ - httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, - version: version(1, testEpoch), - }) + clientState({}, {httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, version: version(1, testEpoch)}) ) expect(useConfigState.getState().loggedIn).toBe(true) @@ -63,7 +58,7 @@ describe('the setNotifications snapshot', () => { notifySession('loggedOut', version(7, testEpoch)) useConfigState.setState({loggedIn: false}) - applyClientState(clientState({loggedIn: true, version: version(6, testEpoch)})) + applyClientState(clientState({loggedIn: true}, {version: version(6, testEpoch)})) expect(useConfigState.getState().loggedIn).toBe(false) expect(useCurrentUserState.getState().username).toBe('') @@ -73,7 +68,7 @@ describe('the setNotifications snapshot', () => { notifySession('loggedOut', version(4, testEpoch)) useConfigState.setState({loggedIn: false}) - applyClientState(clientState({loggedIn: true, version: version(4, testEpoch)})) + applyClientState(clientState({loggedIn: true}, {version: version(4, testEpoch)})) expect(useConfigState.getState().loggedIn).toBe(false) }) @@ -82,7 +77,7 @@ describe('the setNotifications snapshot', () => { notifySession('loggedOut', version(9, testEpoch)) useConfigState.setState({loggedIn: false}) - applyClientState(clientState({loggedIn: true, version: version(1, testEpoch + 500)})) + applyClientState(clientState({loggedIn: true}, {version: version(1, testEpoch + 500)})) expect(useConfigState.getState().loggedIn).toBe(true) }) @@ -92,7 +87,7 @@ describe('the setNotifications snapshot', () => { useCurrentUserState.setState({username: 'testuser'}) applyClientState( - clientState({loggedIn: false, uid: '', username: '', version: version(1, testEpoch)}) + clientState({loggedIn: false, uid: '', username: ''}, {version: version(1, testEpoch)}) ) expect(useConfigState.getState().loggedIn).toBe(true) @@ -137,6 +132,34 @@ describe('notification ordering', () => { expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') }) + 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({loggedIn: true}, {version: version(1, testEpoch)})) + unsub() + + expect(seen).toBe('testuser') + }) + + test('an address stamped with counter 0 is applied', () => { + // the http server can start before NotifyRouter exists, so its first update returns early and + // the reply carries a live address labelled 0; the epoch is what makes that newer than nothing + applyClientState( + clientState( + {}, + {httpSrvInfo: {address: '127.0.0.1:7', token: 'token'}, version: version(0, testEpoch)} + ) + ) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:7') + }) + test('logging out keeps the http server address', () => { notifyHTTP('127.0.0.1:2', version(1, testEpoch)) useConfigState.getState().dispatch.resetState() @@ -149,7 +172,7 @@ describe('the bootstrap status identity', () => { // the read can span a logout: GetBootstrapStatus waits out the startup login attempt, and a // logout announced meanwhile has already reset the stores applyClientState( - clientState({loggedIn: false, uid: '', username: '', version: version(1, testEpoch)}) + clientState({loggedIn: false, uid: '', username: ''}, {version: version(1, testEpoch)}) ) expect(useConfigState.getState().loggedIn).toBe(false) @@ -168,7 +191,7 @@ describe('the bootstrap status identity', () => { test('is applied when it agrees', () => { applyClientState( - clientState({loggedIn: true, uid: 'u1', username: 'testuser', version: version(1, testEpoch)}) + clientState({loggedIn: true, uid: 'u1', username: 'testuser'}, {version: version(1, testEpoch)}) ) onBootstrapStatusChanged({ diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts index 013667e091ad..a8f47d3250f6 100644 --- a/shared/stores/tests/legacy-service.test.ts +++ b/shared/stores/tests/legacy-service.test.ts @@ -6,9 +6,8 @@ import {useCurrentUserState} from '../current-user' import {useDaemonState} from '../daemon' import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/shared' -// Its own file: whether the connected service answers setNotifications with a snapshot is -// module state in the init layer that outlives resetAllStores, and jest gives each file a -// fresh module registry. +// Its own file: whether the connected service can settle the session is module state in the init +// layer that outlives resetAllStores, and jest gives each file a fresh module registry. const notifySession = (kind: 'loggedIn' | 'loggedOut') => useConfigState.getState().dispatch.onEngineIncoming({ @@ -28,12 +27,7 @@ const status = (over: Partial = {}) => }) as T.RPCGen.BootstrapStatus const snapshot = (over: Partial = {}): T.RPCGen.ClientState => ({ - deviceID: 'd2', - deviceName: 'testuser-other', - loggedIn: true, - registered: true, - uid: 'u2', - username: 'testuser-mac', + session: {deviceID: 'd2', deviceName: 'testuser-other', loggedIn: true, uid: 'u2', username: 'testuser-mac'}, version: {counter: 1, epoch: 1000}, ...over, }) @@ -49,7 +43,7 @@ afterEach(() => { resetAllStores() }) -describe('a service too old for the snapshot', () => { +describe('a service that cannot settle the session', () => { test('has its bootstrap status own the session and the http address', () => { applyClientState(undefined) onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}})) @@ -81,7 +75,7 @@ describe('a service too old for the snapshot', () => { }) test('stops owning the session the moment a service does answer with a snapshot', () => { - applyClientState(snapshot({loggedIn: true})) + applyClientState(snapshot()) expect(useCurrentUserState.getState().username).toBe('testuser-mac') onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}})) @@ -92,7 +86,7 @@ describe('a service too old for the snapshot', () => { }) test('owns the session again after a downgrade under a live client', () => { - applyClientState(snapshot({loggedIn: true, version: {counter: 9, epoch: 1000}})) + applyClientState(snapshot({version: {counter: 9, epoch: 1000}})) expect(useConfigState.getState().loggedIn).toBe(true) // the service is stopped and an older one starts; the reconnect answers with no snapshot @@ -102,4 +96,25 @@ describe('a service too old for the snapshot', () => { expect(useConfigState.getState().loggedIn).toBe(false) expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:9') }) + + test('leaves the session to the status while its startup login attempt has not settled', () => { + // mobile runs the attempt off the Init thread, after the loopback listener is up, so a client + // can subscribe before there is any session to report. A reply that said "logged out" there + // would bar the settled status for the life of the process, repairable only by a notification + // whose send is fire-and-forget. + applyClientState({version: {counter: 4, epoch: 1000}}) + + onBootstrapStatusChanged(status()) + + expect(useConfigState.getState().loggedIn).toBe(true) + expect(useCurrentUserState.getState().username).toBe('testuser') + }) + + test('still takes the http address from an unsettled reply', () => { + applyClientState({ + httpSrvInfo: {address: '127.0.0.1:3', token: 'token'}, + version: {counter: 4, epoch: 1000}, + }) + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') + }) }) From 6c7a106922a58c0c2f2680da2438117f2b415971 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 11:04:40 -0400 Subject: [PATCH 078/127] fix(config): hand the session back to versioned ordering once it settles The fallback flag was armed by the setNotifications reply and never disarmed. On mobile the reply normally arrives before the startup login attempt finishes, so the flag stayed set for the whole connection and every later bootstrap status reached the unversioned path -- where setLoggedIn does not consult the applied versions. A status read across a logout, which does network work after a wait of up to thirty seconds and is not invalidated by any generation bump, then resurrected the session the logout notification had just retired. That is the hazard the version plumbing exists to prevent, on the platform that motivated it. The flag now lives beside the applied versions and clears the moment a real session version is accepted, which is the service settling the session after all. An account that is genuinely logged out announces nothing, so the status stays authoritative for it. Alongside: the reply is applied outside the subscribe try, so a throw from applying a good reply is not read as a failed subscribe; the reply is stamped with the handshake generation, so a rejection delivered after a reconnect cannot re-arm the fallback on the connection that replaced it; and the ordering comment in GetBootstrapStatus is corrected -- the rpc codec ignores map keys it has no field for, so an older client decodes the notification fine and the claim it could not was wrong. The Go test that claimed to pin register-before-read did not: swapping the two statements still satisfied every assertion. Renamed to what it checks, with the gap stated in the test. --- go/service/config.go | 12 ++--- go/service/main.go | 20 ++++---- go/service/notify_test.go | 21 ++++---- shared/constants/init/shared.test.ts | 54 ++++++++++++++++++++ shared/constants/init/shared.tsx | 57 +++++++++++++--------- shared/stores/config.tsx | 34 ++++++++++--- shared/stores/tests/legacy-service.test.ts | 39 +++++++++++++-- 7 files changed, 179 insertions(+), 58 deletions(-) diff --git a/go/service/config.go b/go/service/config.go index af2d2f5ce3fe..392efe7e8b5c 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -361,12 +361,12 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r return res, err } res = eng.Status() - // Not waited on: a client that understands setNotifications gets the address - // from the subscription reply and from HTTPSrvInfoUpdate, which the server - // sends on every start, so it never needed this one to block. A client old - // enough to need it cannot decode those notifications either -- the version - // on them is a record now -- so for that client this is best effort and it - // gets nothing here until the next read. + // Not waited on: every client learns the address from HTTPSrvInfoUpdate, which + // the server sends on every start, and a client new enough for setNotifications + // also gets it in the subscription reply. An older client still decodes that + // notification -- the rpc codec ignores map keys it has no field for, so the + // version it does not know about costs it nothing. This field is left as a + // convenience for a status read that happens to run while the server is up. if info, infoErr := h.svc.httpSrv.Info(); infoErr != nil { m.Debug("GetBootstrapStatus: no HTTP server address: %s", infoErr) } else { diff --git a/go/service/main.go b/go/service/main.go index 786f6f41612b..d76b2cdc3def 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -1392,16 +1392,6 @@ 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. -// 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 -// that runs off the Init path on mobile. // 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 @@ -1415,6 +1405,10 @@ func (d *Service) initialLoginAttemptSettled() bool { } } +// 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 +// that runs off the Init path on mobile. func (d *Service) awaitInitialLoginAttempt(m libkb.MetaContext, maxWait time.Duration) { select { case <-d.initialLoginAttemptDone: @@ -1425,6 +1419,12 @@ func (d *Service) awaitInitialLoginAttempt(m libkb.MetaContext, maxWait time.Dur } } +// 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. diff --git a/go/service/notify_test.go b/go/service/notify_test.go index 24e31d8d0f1e..34f14df3068f 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -41,10 +41,18 @@ func TestSetNotificationsHoldsBackAnUnsettledSession(t *testing.T) { require.False(t, res.Session.LoggedIn, "logged out in a fresh test context") } -// The channels are registered before the state is read, in that one call. A read -// that came first could describe a change that this connection was not yet -// subscribed to hear about, which is the gap the reply exists to close. -func TestSetNotificationsRegistersBeforeReadingState(t *testing.T) { +// What is checkable from out here: the call registers the channels, labels the +// read no earlier than everything already announced, and leaves every later +// change strictly newer than that label -- which together are what let a client +// keep a notification over the reply. +// +// The order of the two statements INSIDE SetNotifications is not observable from +// here and this test does not pin it: AddConnection has already registered empty +// channels, so the pre-call assertion is trivially true, and swapping register +// and read still satisfies everything below. That ordering is held by the comment +// on SetNotifications; pinning it would need a recording transport and a send +// that blocks until the channels are set. +func TestSetNotificationsRegistersChannelsAndLabelsTheRead(t *testing.T) { tc := libkb.SetupTest(t, "notify", 0) defer tc.Cleanup() g := tc.G @@ -57,11 +65,6 @@ func TestSetNotificationsRegistersBeforeReadingState(t *testing.T) { g.NotifyRouter.HandleLogout(context.Background()) announced := g.StateVersion() - // The registration is what makes the read safe, so observe it from the read - // itself: ActiveDevice is read inside SetNotifications, and a logout announced - // from here would already have been delivered to this connection. - require.False(t, g.NotifyRouter.GetChannels(connID).Session, "not subscribed yet") - // only Session, so nothing below actually sends down this test's nil transport res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) require.NoError(t, err) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 92e7cdfe2c19..96957d729f4e 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -170,8 +170,16 @@ describe('onEngineConnected', () => { onEngineConnected() await new Promise(resolve => setImmediate(resolve)) + expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(true) + deferredSubscription() onEngineConnected() + + expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) + + // the first connection's fallback logged us in; this connection has said nothing yet, so a + // status arriving now must not be the one to decide the session again + useConfigState.setState({loggedIn: false}) onBootstrapStatusChanged({ deviceID: 'd1', deviceName: 'testuser-mac', @@ -184,6 +192,52 @@ describe('onEngineConnected', () => { expect(useConfigState.getState().loggedIn).toBe(false) }) + test('a throw while applying a good reply is not read as a failed subscribe', async () => { + // otherwise the catch flips this connection to the unversioned fallback and re-applies the + // status on top of half-applied versioned state + stubRegistrations() + jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockResolvedValue({ + session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, + version: {counter: 1, epoch: 4242}, + } as never) + spyOnBootstrap() + const originalCurrentUser = useCurrentUserState.getState().dispatch + useCurrentUserState.setState({ + dispatch: { + ...originalCurrentUser, + setBootstrap: () => { + throw new Error('boom') + }, + }, + }) + + onEngineConnected() + await new Promise(resolve => setImmediate(resolve)) + useCurrentUserState.setState({dispatch: originalCurrentUser}) + + expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) + }) + + test('a reply from a connection a later handshake replaced writes nothing', async () => { + stubRegistrations() + const subscribed = deferredSubscription() + spyOnBootstrap() + + onEngineConnected() + // a reconnect before the first reply lands + deferredSubscription() + onEngineConnected() + useConfigState.setState({loggedIn: false}) + + subscribed({ + session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, + version: {counter: 1, epoch: 4243}, + }) + await new Promise(resolve => setImmediate(resolve)) + + expect(useConfigState.getState().loggedIn).toBe(false) + }) + test('a failed subscription leaves the session to the bootstrap status', async () => { // no reply and no channels either: if the status cannot own the session here, a provisioned // user lands on the login screen with nothing left that could put them back diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 7c6eeaa5acf0..6c258b750b58 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -206,15 +206,6 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -// True while this connection has told us it cannot settle the session: no setNotifications reply -// at all (a service too old for it, or a subscribe that failed and left us with no channels -// either), or a reply taken before the service's startup login attempt had settled, which carries -// no session because there is none to describe yet. Only then does the bootstrap status -- which -// the service holds back until that attempt settles, and which carries no version -- own the -// session. Keyed on the reply rather than on "nothing versioned has landed yet", so a status -// cannot win by merely arriving before a reply that is on its way, and cleared per connection so -// a downgrade to an older service hands the fallback back. -let snapshotCannotSettleSession = false // Only a status that agrees with the session we are in describes the current user: a read that // spans a logout describes the previous one, and resetAllStores has already cleared them. @@ -230,7 +221,12 @@ const applyStatusIdentity = (bootstrap: DaemonState['bootstrapStatus']) => { } const applyUnversionedStatusSession = (bootstrap: NonNullable) => { - if (!snapshotCannotSettleSession) { + // Only while the connected service has said it cannot settle the session: no setNotifications + // reply at all (a service too old for it, or a subscribe that failed and left us with no + // channels either), or a reply taken before the service's startup login attempt had settled. + // The config store clears this the moment a real session version is accepted, so the fallback + // hands back to the versioned stream as soon as there is one. + if (!useConfigState.getState().dispatch.sessionIsUnversioned()) { return } const {httpSrvInfo, loggedIn} = bootstrap @@ -249,7 +245,9 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus if (!bootstrap) { return } - // the session first: the identity below is applied only if it agrees with it + // The session first: the identity below is applied only if it agrees with it. That holds + // because onLoggedInChanged is registered first on useConfigState in initSharedSubscriptions, + // so setLoggedIn's fan-out has already run by the time applyStatusIdentity reads the session. applyUnversionedStatusSession(bootstrap) applyStatusIdentity(bootstrap) } @@ -257,9 +255,16 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus // The reply to setNotifications: the state as of the moment this connection subscribed, so there // is no read to order against the subscription. An old service returns nothing here and the // bootstrap status keeps that job -- see applyUnversionedStatusSession. -export const applyClientState = (clientState?: T.RPCGen.ClientState) => { +export const applyClientState = (clientState?: T.RPCGen.ClientState, generation?: number) => { + // A reply from a connection a later handshake has already replaced must not write anything: + // the flag below has no connection identity of its own, and a rejection delivered a microtask + // after the reconnect would otherwise re-arm the fallback on the new connection. + if (generation !== undefined && generation !== useDaemonState.getState().handshakeGeneration) { + logger.info('[Bootstrap] dropping a subscription reply from a replaced connection') + return + } const session = clientState?.session - snapshotCannotSettleSession = !session + useConfigState.getState().dispatch.setSessionIsUnversioned(!session) if (!clientState || !session) { logger.info( clientState @@ -348,10 +353,11 @@ export const onEngineConnected = () => { } useConfigState.getState().dispatch.onEngineConnected() { - const subscribe = async () => { + const subscribe = async (generation: number) => { + let clientState: T.RPCGen.ClientState | undefined try { // prettier-ignore - const clientState = await T.RPCGen.notifyCtlSetNotificationsRpcPromise({ + clientState = 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, @@ -361,22 +367,25 @@ export const onEngineConnected = () => { team: true, teambot: false, tracking: true, users: true, wallet: false, }, }) - applyClientState(clientState) } catch (error) { if (error) { logger.warn('error in toggling notifications: ', error) } - // no reply and no channels either, so nothing versioned will reach this connection: the - // bootstrap status is all we have, exactly as for a service too old to answer at all - applyClientState(undefined) + // clientState stays undefined: no reply and no channels either, so nothing versioned will + // reach this connection and the bootstrap status is all we have, exactly as for a service + // too old to answer at all } + // outside the try on purpose: a throw from applying a good reply must not be read as a + // failed subscribe and re-run the unversioned fallback over half-applied versioned state + applyClientState(clientState, generation) } - // a new connection has told us nothing yet; the reply below is what settles it - snapshotCannotSettleSession = false - ignorePromise(subscribe()) - // Nothing orders these two any more: the subscription reply is what carries the session and + // a new connection has told us nothing yet; the reply is what settles it + useConfigState.getState().dispatch.setSessionIsUnversioned(false) + // startHandshake first so this connection has its generation before the subscribe goes out. + // Nothing orders the two RPCs any more: the subscription reply is what carries the session and // the http address, so the bootstrap read has nothing left to race with. useDaemonState.getState().dispatch.startHandshake() + ignorePromise(subscribe(useDaemonState.getState().handshakeGeneration)) } } @@ -402,6 +411,8 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.loggedIn, onLoggedInChanged), subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 682b593ef896..6c748fbb10e3 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -91,6 +91,9 @@ export type State = Store & { dispatch: { // a login or logout notification: applied only if it is newer than the last applied one acceptSessionVersion: (version?: T.RPCGen.StateVersion) => boolean + // whether the connected service has told us it cannot settle the session -- see the closure + sessionIsUnversioned: () => boolean + setSessionIsUnversioned: (unversioned: boolean) => void checkForUpdate: () => void initAppUpdateLoop: () => void installerRan: () => void @@ -124,6 +127,11 @@ export type State = Store & { } } +// A version we cannot compare is no ordering at all: a service too old to send one, or one built +// from an intermediate commit of this branch, which sends a bare number rather than a record. +const isComparableVersion = (version?: T.RPCGen.StateVersion): version is T.RPCGen.StateVersion => + !!version && typeof version.counter === 'number' && typeof version.epoch === 'number' + // A different epoch is a different service process: its counter started over, so // it is not comparable and its state is by definition the newer one. const isNewerVersion = (next: T.RPCGen.StateVersion, applied?: T.RPCGen.StateVersion) => @@ -140,16 +148,18 @@ export const useConfigState = Z.createZustand('config', (set, get) => { const applied: {http?: T.RPCGen.StateVersion; session?: T.RPCGen.StateVersion} = {} const acceptVersion = (kind: 'http' | 'session', version?: T.RPCGen.StateVersion) => { // a service too old to send a version gives us nothing to order by, so everything it sends is - // applied in the order it arrives, as it was before versions existed. A service built from an - // intermediate commit of this branch sends a bare number, which is the same thing: an - // ordering we cannot compare against one that carries an epoch. - if (!version || typeof version.counter !== 'number' || typeof version.epoch !== 'number') { - return true - } + // applied in the order it arrives, as it was before versions existed + if (!isComparableVersion(version)) return true if (!isNewerVersion(version, applied[kind])) return false applied[kind] = version return true } + // Set by the init layer from each setNotifications reply: true while the connected service has + // said it cannot settle the session, which is the only time the unversioned bootstrap status may + // own it. Cleared here rather than there, the moment a real session version is accepted, because + // that is the service settling it after all -- an account that is genuinely logged out announces + // nothing, so the status stays authoritative for it. + let sessionIsUnversioned = false const _checkForUpdate = async () => { try { @@ -201,7 +211,13 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { - acceptSessionVersion: version => acceptVersion('session', version), + acceptSessionVersion: version => { + const accepted = acceptVersion('session', version) + if (accepted && isComparableVersion(version)) { + sessionIsUnversioned = false + } + return accepted + }, checkForUpdate: () => { const f = async () => { await _checkForUpdate() @@ -545,6 +561,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.httpSrv.token = token }) }, + sessionIsUnversioned: () => sessionIsUnversioned, setJustDeletedSelf: self => { set(s => { s.justDeletedSelf = self @@ -559,6 +576,9 @@ export const useConfigState = Z.createZustand('config', (set, get) => { Z.resetAllStores() } }, + setSessionIsUnversioned: unversioned => { + sessionIsUnversioned = unversioned + }, setLoginError: error => { set(s => { s.loginError = error diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts index a8f47d3250f6..badea5fb958b 100644 --- a/shared/stores/tests/legacy-service.test.ts +++ b/shared/stores/tests/legacy-service.test.ts @@ -9,9 +9,11 @@ import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/share // Its own file: whether the connected service can settle the session is module state in the init // layer that outlives resetAllStores, and jest gives each file a fresh module registry. -const notifySession = (kind: 'loggedIn' | 'loggedOut') => +const notifySession = (kind: 'loggedIn' | 'loggedOut', version?: T.RPCGen.StateVersion) => useConfigState.getState().dispatch.onEngineIncoming({ - payload: {params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser'} : {}}, + payload: { + params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser', version} : {version}, + }, type: `keybase.1.NotifySession.${kind}`, } as never) @@ -32,9 +34,13 @@ const snapshot = (over: Partial = {}): T.RPCGen.ClientStat ...over, }) +// the applied versions live outside the store and survive resetAllStores on purpose, so each +// test gets its own epoch rather than a counter that has to beat every earlier test's +let testEpoch = 1000 beforeEach(() => { - // httpSrv is process-wide and survives resetAllStores on purpose + testEpoch++ useConfigState.setState(st => { + // httpSrv is process-wide and survives resetAllStores on purpose st.httpSrv = {address: '', token: ''} }) }) @@ -117,4 +123,31 @@ describe('a service that cannot settle the session', () => { }) expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') }) + + test('hands the session back the moment the service settles it', () => { + // mobile: the reply lands before tryLogin finishes, so the fallback is armed. Once the + // login notification arrives the service has settled it, and the versioned stream owns the + // session from there -- otherwise an unversioned write outranks every notification for the + // life of the connection. + applyClientState({version: {counter: 4, epoch: testEpoch}}) + expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(true) + + notifySession('loggedIn', {counter: 5, epoch: testEpoch}) + + expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) + expect(useConfigState.getState().loggedIn).toBe(true) + }) + + test('a status spanning a logout cannot resurrect the session it retired', () => { + // GetBootstrapStatus does network work after a wait of up to 30s, and no generation is + // bumped by a logout, so a read started before it resolves afterwards saying loggedIn:true + applyClientState({version: {counter: 4, epoch: testEpoch}}) + notifySession('loggedIn', {counter: 5, epoch: testEpoch}) + notifySession('loggedOut', {counter: 6, epoch: testEpoch}) + expect(useConfigState.getState().loggedIn).toBe(false) + + onBootstrapStatusChanged(status()) + + expect(useConfigState.getState().loggedIn).toBe(false) + }) }) From 5e8d0a0ac1fdf6dfd8b7b48b24a7a5cc0ee1d35c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 11:16:14 -0400 Subject: [PATCH 079/127] fix(daemon): keep the handshake generation across a store reset resetState spreads initialStore, which zeroed handshakeGeneration while the daemon's closure counter kept climbing. Round 2 stamped the subscription reply with that generation, so a logout inside the subscribe window made the live connection's own reply look superseded and it was dropped. Both the generation and the handshake state track the connection, not the account, so both survive the reset. Also corrects the comment round 2 added about session-before-identity ordering: it claimed the order holds because onLoggedInChanged is registered first, which is not why. setLoggedIn writes the store synchronously, so the read inside applyStatusIdentity sees it whatever the subscriber order is; the requirement is local to onBootstrapStatusChanged and nothing outside it is involved. A reader would have preserved the wrong invariant. --- shared/constants/init/shared.test.ts | 31 +++++++++++++++++++++- shared/constants/init/shared.tsx | 9 +++---- shared/stores/config.tsx | 6 ++--- shared/stores/daemon.tsx | 4 +++ shared/stores/tests/legacy-service.test.ts | 11 ++++---- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 96957d729f4e..ddd09a64f21f 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -197,10 +197,14 @@ describe('onEngineConnected', () => { // status on top of half-applied versioned state stubRegistrations() jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockResolvedValue({ + httpSrvInfo: {address: '127.0.0.1:4242', token: 'token'}, session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, version: {counter: 1, epoch: 4242}, } as never) - spyOnBootstrap() + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({ + httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, + loggedIn: true, + } as never) const originalCurrentUser = useCurrentUserState.getState().dispatch useCurrentUserState.setState({ dispatch: { @@ -216,6 +220,9 @@ describe('onEngineConnected', () => { useCurrentUserState.setState({dispatch: originalCurrentUser}) expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) + // and what the reply had already applied before the throw is left alone, rather than + // re-decided by the status the fallback would have replayed + expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:4242') }) test('a reply from a connection a later handshake replaced writes nothing', async () => { @@ -238,6 +245,28 @@ describe('onEngineConnected', () => { expect(useConfigState.getState().loggedIn).toBe(false) }) + test('a logout during the subscribe window does not discard the live reply', async () => { + // resetAllStores zeroes the store's copy of handshakeGeneration while the daemon's closure + // counter keeps climbing, so a logout under an in-flight subscribe must not make that + // connection's own reply look like it came from a replaced one + stubRegistrations() + const subscribed = deferredSubscription() + spyOnBootstrap() + + onEngineConnected() + useConfigState.getState().dispatch.setLoggedIn(true) + useConfigState.getState().dispatch.setLoggedIn(false) // resetAllStores runs here + + subscribed({ + session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, + version: {counter: 1, epoch: 4244}, + }) + await new Promise(resolve => setImmediate(resolve)) + + expect(useConfigState.getState().loggedIn).toBe(true) + expect(useCurrentUserState.getState().username).toBe('testuser') + }) + test('a failed subscription leaves the session to the bootstrap status', async () => { // no reply and no channels either: if the status cannot own the session here, a provisioned // user lands on the login screen with nothing left that could put them back diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 6c258b750b58..31e373413591 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -245,9 +245,10 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus if (!bootstrap) { return } - // The session first: the identity below is applied only if it agrees with it. That holds - // because onLoggedInChanged is registered first on useConfigState in initSharedSubscriptions, - // so setLoggedIn's fan-out has already run by the time applyStatusIdentity reads the session. + // The session first, then the identity, which is applied only if it agrees with the session: + // the line below may set the session this status describes, and setLoggedIn writes the store + // synchronously, so the read inside applyStatusIdentity sees it. Nothing outside this function + // is involved -- swapping these two lines is what would break it. applyUnversionedStatusSession(bootstrap) applyStatusIdentity(bootstrap) } @@ -411,8 +412,6 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.loggedIn, onLoggedInChanged), subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 6c748fbb10e3..372db912e770 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -576,9 +576,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { Z.resetAllStores() } }, - setSessionIsUnversioned: unversioned => { - sessionIsUnversioned = unversioned - }, setLoginError: error => { set(s => { s.loginError = error @@ -592,6 +589,9 @@ export const useConfigState = Z.createZustand('config', (set, get) => { Object.assign(s.outOfDate, outOfDate) }) }, + setSessionIsUnversioned: unversioned => { + sessionIsUnversioned = unversioned + }, setStartupDetails: st => { set(s => { if (s.startup.loaded) { diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index 216ca3b75314..d9df2ac87a1e 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -91,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, })) }, diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts index badea5fb958b..09b5252a47e4 100644 --- a/shared/stores/tests/legacy-service.test.ts +++ b/shared/stores/tests/legacy-service.test.ts @@ -28,15 +28,16 @@ const status = (over: Partial = {}) => ...over, }) as T.RPCGen.BootstrapStatus +// the applied versions live outside the store and survive resetAllStores on purpose, so each +// test gets its own epoch rather than a counter that has to beat every earlier test's +let testEpoch = 1000 + const snapshot = (over: Partial = {}): T.RPCGen.ClientState => ({ session: {deviceID: 'd2', deviceName: 'testuser-other', loggedIn: true, uid: 'u2', username: 'testuser-mac'}, - version: {counter: 1, epoch: 1000}, + version: {counter: 1, epoch: testEpoch}, ...over, }) -// the applied versions live outside the store and survive resetAllStores on purpose, so each -// test gets its own epoch rather than a counter that has to beat every earlier test's -let testEpoch = 1000 beforeEach(() => { testEpoch++ useConfigState.setState(st => { @@ -92,7 +93,7 @@ describe('a service that cannot settle the session', () => { }) test('owns the session again after a downgrade under a live client', () => { - applyClientState(snapshot({version: {counter: 9, epoch: 1000}})) + applyClientState(snapshot({version: {counter: 9, epoch: testEpoch}})) expect(useConfigState.getState().loggedIn).toBe(true) // the service is stopped and an older one starts; the reconnect answers with no snapshot From 765b30ceb26203136ac0d2c40410132539f34f2c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 11:37:14 -0400 Subject: [PATCH 080/127] feat(protocol): announce the app's lifecycle state to clients The service already derives MobileAppState from the UI reports native makes (go/libkb/lifecycle), but had no way to tell a client about it, so clients derived their own -- on iOS from a different OS notification stream, with nothing ordering the two against each other. Adds one notification on the existing App channel and one field to W2's subscribe snapshot, so a client that started late has nothing to order against. MobileAppState.Update announces from the one place the value changes, which is also before lifecycle's Flush hook runs. The notification carries a StateVersion because the fan-out is one goroutine per connection: two of these can arrive in either order, and applying the older one last would leave a client permanently wrong. --- go/libkb/appstate.go | 15 +++++++++++-- go/libkb/appstate_test.go | 21 +++++++++++++++++ go/libkb/notify_router.go | 17 ++++++++++++++ go/protocol/keybase1/notify_app.go | 26 ++++++++++++++++++++++ go/protocol/keybase1/notify_ctl.go | 2 ++ go/service/notify.go | 2 +- go/service/notify_test.go | 23 +++++++++++++++++++ protocol/avdl/keybase1/notify_app.avdl | 10 +++++++++ protocol/avdl/keybase1/notify_ctl.avdl | 7 ++++++ protocol/bin/enabled-calls.json | 1 + protocol/json/keybase1/notify_app.json | 25 ++++++++++++++++++++- protocol/json/keybase1/notify_ctl.json | 8 +++++++ shared/constants/init/shared.test.ts | 3 +++ shared/constants/rpc/index.tsx | 1 + shared/constants/rpc/rpc-gen.tsx | 8 +++++-- shared/stores/tests/client-state.test.ts | 3 ++- shared/stores/tests/legacy-service.test.ts | 10 +++++---- 17 files changed, 171 insertions(+), 11 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 0cc141e639a7..5e66aec756ff 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -1,6 +1,7 @@ package libkb import ( + "context" "fmt" "runtime" "sync" @@ -105,11 +106,21 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo // 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, and +// before lifecycle's Flush hook runs: on iOS the whole background transition +// happens inside a UIBackgroundTask native holds open across the bind call, so +// a client still has time to act on the notification. The announce is outside +// the lock because it fans out to every connection. 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() - return a.updateLocked(state) + changed = a.updateLocked(state) + a.Unlock() + if changed { + a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) + } + return changed } // State returns the current app state diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index 7217b878abd8..5e74ea7abb16 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -76,3 +76,24 @@ func TestMobileAppStateBackgroundCancelsRPCsOnlyOnChange(t *testing.T) { require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) requireOpen(t, second.Done()) } + +// Clients are told from the one place the value changes, so no writer can add a +// path that moves the state without announcing it. The announce is observable +// from here as the state version it stamps. +func TestMobileAppStateAnnouncesOnlyOnChange(t *testing.T) { + tc := SetupTest(t, "MobileAppStateAnnounce", 0) + defer tc.Cleanup() + tc.G.SetService() + a := NewMobileAppState(tc.G) + + before := tc.G.StateVersion() + require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + announced := tc.G.StateVersion() + require.Equal(t, before.Counter+1, announced.Counter, "one stamp for the change") + + require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) + require.Equal(t, announced.Counter, tc.G.StateVersion().Counter, "nothing announced for a same-value update") + + require.True(t, a.Update(keybase1.MobileAppState_FOREGROUND)) + require.Equal(t, announced.Counter+1, tc.G.StateVersion().Counter) +} diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index 4b8996d5c84e..5df72b56c554 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -2838,6 +2838,23 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase }) } +// HandleMobileAppState announces the app lifecycle state the service derived +// from native's UI reports. It is the client's only source for it: deriving it +// a second time from the OS would mean two answers -- on iOS from two different +// notification streams -- with nothing ordering them against each other. +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(xp rpc.Transporter, version keybase1.StateVersion) { + _ = (keybase1.NotifyAppClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).MobileAppStateChanged(ctx, keybase1.MobileAppStateChangedArg{State: state, Version: version}) + }) +} + func (n *NotifyRouter) HandleHandleKeybaseLink(ctx context.Context, link string, deferred bool) { if n == nil { return diff --git a/go/protocol/keybase1/notify_app.go b/go/protocol/keybase1/notify_app.go index 19120a54d2d3..4fe0b9d2ae81 100644 --- a/go/protocol/keybase1/notify_app.go +++ b/go/protocol/keybase1/notify_app.go @@ -13,8 +13,14 @@ import ( type ExitArg struct { } +type MobileAppStateChangedArg struct { + State MobileAppState `codec:"state" json:"state"` + Version StateVersion `codec:"version" json:"version"` +} + type NotifyAppInterface interface { Exit(context.Context) error + MobileAppStateChanged(context.Context, MobileAppStateChangedArg) error } func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { @@ -31,6 +37,21 @@ func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { return }, }, + "mobileAppStateChanged": { + MakeArg: func() any { + var ret [1]MobileAppStateChangedArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + typedArgs, ok := args.(*[1]MobileAppStateChangedArg) + if !ok { + err = rpc.NewTypeError((*[1]MobileAppStateChangedArg)(nil), args) + return + } + err = i.MobileAppStateChanged(ctx, typedArgs[0]) + return + }, + }, }, } } @@ -43,3 +64,8 @@ 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, __arg MobileAppStateChangedArg) (err error) { + err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.mobileAppStateChanged", []any{__arg}, 0*time.Millisecond) + return +} diff --git a/go/protocol/keybase1/notify_ctl.go b/go/protocol/keybase1/notify_ctl.go index b2a8ba1da700..9e7d4bd6e963 100644 --- a/go/protocol/keybase1/notify_ctl.go +++ b/go/protocol/keybase1/notify_ctl.go @@ -110,6 +110,7 @@ type ClientState struct { Version StateVersion `codec:"version" json:"version"` 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 { @@ -129,6 +130,7 @@ func (o ClientState) DeepCopy() ClientState { tmp := x.DeepCopy() return &tmp })(o.HttpSrvInfo), + AppState: o.AppState.DeepCopy(), } } diff --git a/go/service/notify.go b/go/service/notify.go index 1d87923b6111..32649efc7cd2 100644 --- a/go/service/notify.go +++ b/go/service/notify.go @@ -41,7 +41,7 @@ func (h *NotifyCtlHandler) SetNotifications(ctx context.Context, n keybase1.Noti // Read the version before the state it describes. NextStateVersion is stamped // after a change is readable, so this snapshot is never newer than its label // and a client can drop it on a tie without losing anything. - res := keybase1.ClientState{Version: h.G().StateVersion()} + res := keybase1.ClientState{Version: h.G().StateVersion(), AppState: h.G().MobileAppState.State()} // 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 client would have to be corrected out of by a notification it diff --git a/go/service/notify_test.go b/go/service/notify_test.go index 34f14df3068f..01a1b84b4881 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -79,3 +79,26 @@ func TestSetNotificationsRegistersChannelsAndLabelsTheRead(t *testing.T) { g.NotifyRouter.HandleHTTPSrvInfoUpdate(context.Background(), keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "t"}) require.Greater(t, g.StateVersion().Counter, res.Version.Counter) } + +// The app state is derived here and nowhere else, so a client that started late +// -- on iOS JS never starts on a background launch -- has no earlier reading to +// order against: the reply is its first and only catch-up. +func TestSetNotificationsCarriesTheAppState(t *testing.T) { + tc := libkb.SetupTest(t, "notify", 0) + defer tc.Cleanup() + g := tc.G + g.SetService() + + h, _, _ := newTestNotifyCtlHandler(t, g) + + res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) + require.NoError(t, err) + require.Equal(t, g.MobileAppState.State(), res.AppState) + + g.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + res, err = h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) + require.NoError(t, err) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, res.AppState) + require.GreaterOrEqual(t, res.Version.Counter, g.StateVersion().Counter-1, + "labelled no earlier than the change it reports") +} diff --git a/protocol/avdl/keybase1/notify_app.avdl b/protocol/avdl/keybase1/notify_app.avdl index 31dfac72e695..147a100d8300 100644 --- a/protocol/avdl/keybase1/notify_app.avdl +++ b/protocol/avdl/keybase1/notify_app.avdl @@ -1,7 +1,17 @@ @namespace("keybase.1") protocol NotifyApp { + import idl "common.avdl"; + import idl "appstate.avdl"; void exit() oneway; + // The app's lifecycle state changed. The service derives it from the UI + // reports native makes, so this and the appState in setNotifications' reply + // 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. Versioned + // because the fan-out is one goroutine per connection, so two of these can + // arrive in either order. + void mobileAppStateChanged(MobileAppState state, StateVersion version) oneway; + } diff --git a/protocol/avdl/keybase1/notify_ctl.avdl b/protocol/avdl/keybase1/notify_ctl.avdl index 3b0d79c0f8ed..06fa37db3b45 100644 --- a/protocol/avdl/keybase1/notify_ctl.avdl +++ b/protocol/avdl/keybase1/notify_ctl.avdl @@ -4,6 +4,7 @@ protocol notifyCtl { import idl "common.avdl"; import idl "notify_service.avdl"; + import idl "appstate.avdl"; record NotificationChannels { boolean session; @@ -66,6 +67,12 @@ protocol notifyCtl { // rather than read a null as logged out. 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, then returns the state, so anything that changes diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index fe8ca33784d5..70c7ec885c5d 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -152,6 +152,7 @@ "chat.1.local.updateUnsentText": {"promise":true}, "chat.1.local.userEmojis": {"promise":true}, "keybase.1.NotifyApp.exit": {"custom":true}, + "keybase.1.NotifyApp.mobileAppStateChanged": {"incoming":true}, "keybase.1.NotifyAudit.boxAuditError": {"incoming":true}, "keybase.1.NotifyAudit.rootAuditError": {"incoming":true}, "keybase.1.NotifyBadges.badgeState": {"incoming":true}, diff --git a/protocol/json/keybase1/notify_app.json b/protocol/json/keybase1/notify_app.json index c1a6ab87bd48..4f404526fa62 100644 --- a/protocol/json/keybase1/notify_app.json +++ b/protocol/json/keybase1/notify_app.json @@ -1,12 +1,35 @@ { "protocol": "NotifyApp", - "imports": [], + "imports": [ + { + "path": "common.avdl", + "type": "idl" + }, + { + "path": "appstate.avdl", + "type": "idl" + } + ], "types": [], "messages": { "exit": { "request": [], "response": null, "oneway": true + }, + "mobileAppStateChanged": { + "request": [ + { + "name": "state", + "type": "MobileAppState" + }, + { + "name": "version", + "type": "StateVersion" + } + ], + "response": null, + "oneway": true } }, "namespace": "keybase.1" diff --git a/protocol/json/keybase1/notify_ctl.json b/protocol/json/keybase1/notify_ctl.json index 9ef52f19af19..c7d7fa79dbd0 100644 --- a/protocol/json/keybase1/notify_ctl.json +++ b/protocol/json/keybase1/notify_ctl.json @@ -8,6 +8,10 @@ { "path": "notify_service.avdl", "type": "idl" + }, + { + "path": "appstate.avdl", + "type": "idl" } ], "types": [ @@ -204,6 +208,10 @@ "HttpSrvInfo" ], "name": "httpSrvInfo" + }, + { + "type": "MobileAppState", + "name": "appState" } ] } diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index ddd09a64f21f..84e57c545e1c 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -137,6 +137,7 @@ describe('onEngineConnected', () => { expect(bootstrap).toHaveBeenCalledTimes(1) subscribed({ + appState: T.RPCGen.MobileAppState.foreground, httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, version: {counter: 1, epoch: 7}, @@ -237,6 +238,7 @@ describe('onEngineConnected', () => { useConfigState.setState({loggedIn: false}) subscribed({ + appState: T.RPCGen.MobileAppState.foreground, session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, version: {counter: 1, epoch: 4243}, }) @@ -258,6 +260,7 @@ describe('onEngineConnected', () => { useConfigState.getState().dispatch.setLoggedIn(false) // resetAllStores runs here subscribed({ + appState: T.RPCGen.MobileAppState.foreground, session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, version: {counter: 1, epoch: 4244}, }) diff --git a/shared/constants/rpc/index.tsx b/shared/constants/rpc/index.tsx index fa83b6fa3e0e..7dfc80c6502f 100644 --- a/shared/constants/rpc/index.tsx +++ b/shared/constants/rpc/index.tsx @@ -71,6 +71,7 @@ type Chat1ResponseActionMap = { } type Keybase1IncomingAction = + 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index 29b77a7b751f..8298da1a310f 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -15,6 +15,10 @@ export type MessageTypes = { inParam: undefined, outParam: void, }, + 'keybase.1.NotifyApp.mobileAppStateChanged': { + inParam: {readonly state: MobileAppState,readonly version: StateVersion}, + outParam: void, + }, 'keybase.1.NotifyAudit.boxAuditError': { inParam: {readonly message: string}, outParam: void, @@ -2538,7 +2542,7 @@ export type CheckResult = {readonly proofResult: ProofResult,readonly time: Time 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 version: StateVersion,readonly session?: ClientSession | null,readonly httpSrvInfo?: HttpSrvInfo | null,} +export type ClientState = {readonly version: StateVersion,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,} @@ -3120,7 +3124,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.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent' +type IncomingMethod = 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | 'keybase.1.NotifyDeviceHistory.deviceHistoryChanged' | 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | 'keybase.1.NotifyUsers.userChanged' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.pgpUi.finished' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.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' diff --git a/shared/stores/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts index 70d1e0420233..4562115e8a9c 100644 --- a/shared/stores/tests/client-state.test.ts +++ b/shared/stores/tests/client-state.test.ts @@ -1,5 +1,5 @@ /// -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '../config' import {useCurrentUserState} from '../current-user' @@ -12,6 +12,7 @@ const clientState = ( session: Partial = {}, over: Partial = {} ): T.RPCGen.ClientState => ({ + appState: T.RPCGen.MobileAppState.foreground, session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser', ...session}, version: version(1), ...over, diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts index 09b5252a47e4..fd3f8035c43e 100644 --- a/shared/stores/tests/legacy-service.test.ts +++ b/shared/stores/tests/legacy-service.test.ts @@ -1,5 +1,5 @@ /// -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '../config' import {useCurrentUserState} from '../current-user' @@ -33,6 +33,7 @@ const status = (over: Partial = {}) => let testEpoch = 1000 const snapshot = (over: Partial = {}): T.RPCGen.ClientState => ({ + appState: T.RPCGen.MobileAppState.foreground, session: {deviceID: 'd2', deviceName: 'testuser-other', loggedIn: true, uid: 'u2', username: 'testuser-mac'}, version: {counter: 1, epoch: testEpoch}, ...over, @@ -109,7 +110,7 @@ describe('a service that cannot settle the session', () => { // can subscribe before there is any session to report. A reply that said "logged out" there // would bar the settled status for the life of the process, repairable only by a notification // whose send is fire-and-forget. - applyClientState({version: {counter: 4, epoch: 1000}}) + applyClientState({appState: T.RPCGen.MobileAppState.foreground, version: {counter: 4, epoch: 1000}}) onBootstrapStatusChanged(status()) @@ -119,6 +120,7 @@ describe('a service that cannot settle the session', () => { test('still takes the http address from an unsettled reply', () => { applyClientState({ + appState: T.RPCGen.MobileAppState.foreground, httpSrvInfo: {address: '127.0.0.1:3', token: 'token'}, version: {counter: 4, epoch: 1000}, }) @@ -130,7 +132,7 @@ describe('a service that cannot settle the session', () => { // login notification arrives the service has settled it, and the versioned stream owns the // session from there -- otherwise an unversioned write outranks every notification for the // life of the connection. - applyClientState({version: {counter: 4, epoch: testEpoch}}) + applyClientState({appState: T.RPCGen.MobileAppState.foreground, version: {counter: 4, epoch: testEpoch}}) expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(true) notifySession('loggedIn', {counter: 5, epoch: testEpoch}) @@ -142,7 +144,7 @@ describe('a service that cannot settle the session', () => { test('a status spanning a logout cannot resurrect the session it retired', () => { // GetBootstrapStatus does network work after a wait of up to 30s, and no generation is // bumped by a logout, so a read started before it resolves afterwards saying loggedIn:true - applyClientState({version: {counter: 4, epoch: testEpoch}}) + applyClientState({appState: T.RPCGen.MobileAppState.foreground, version: {counter: 4, epoch: testEpoch}}) notifySession('loggedIn', {counter: 5, epoch: testEpoch}) notifySession('loggedOut', {counter: 6, epoch: testEpoch}) expect(useConfigState.getState().loggedIn).toBe(false) From 197e6c45f0b52d91d413f78f27d99dd44dd5a8f1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 11:37:23 -0400 Subject: [PATCH 081/127] refactor(app): take the app state from the service, not from the OS twice JS derived the app's lifecycle state independently of Go: on iOS from UIScene notifications observed in a load-time constructor in Kb.mm and aggregated across scenes, on Android from RN's AppState. Nothing ordered that against the UIApplication delegate callbacks Go derives from, and the aggregation rules differ, so the two could disagree steadily rather than only transiently. Now the service is the only deriver. The mobileAppState -> appFocused translation stays exactly where it was, so mark-read gating and desktop's independent appFocused writer are untouched. Deletes watch-app-state and its test, the appStateSource ternary that existed only because there were two sources, onAppStateChange/getAppState from the TurboModule and both implementations, and the scene table, its mutex, the constructor observer and the max-across-scenes aggregation in Kb.mm -- unreachable generality in any case, since Info.plist sets UIApplicationSupportsMultipleScenes=false. On Android this also stops an Activity pause (a photo picker, a permission dialog) reading as a backgrounded app: only the process lifecycle counts, which is what AppLifecycleReporter already documents. --- .../main/java/com/reactnativekb/KbModule.kt | 4 - rnmodules/react-native-kb/ios/Kb.mm | 74 --------------- rnmodules/react-native-kb/src/NativeKb.ts | 4 - rnmodules/react-native-kb/src/index.tsx | 10 -- shared/app/index.native.tsx | 36 ++----- shared/app/watch-app-state.test.ts | 93 ------------------- shared/app/watch-app-state.tsx | 24 ----- shared/constants/init/app-state.test.ts | 79 ++++++++++++++++ shared/constants/init/index.tsx | 3 +- shared/constants/init/shared.tsx | 41 +++++++- shared/stores/config.tsx | 13 ++- .../tests/e2e/ios-appium/helpers/lifecycle.ts | 14 +-- 12 files changed, 146 insertions(+), 249 deletions(-) delete mode 100644 shared/app/watch-app-state.test.ts delete mode 100644 shared/app/watch-app-state.tsx create mode 100644 shared/constants/init/app-state.test.ts 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 84cf9a0c8099..b8578f8adfde 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 @@ -187,10 +187,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } - // Only iOS needs a scene-based app state; JS uses RN's AppState on Android. - @ReactMethod(isBlockingSynchronousMethod = true) - override fun getAppState(): String = "" - // Sharing @ReactMethod override fun androidShare(uriPath: String, mimeType: String, promise: Promise) { diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 77a4e5a8c598..b858c88c73d9 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -245,80 +245,6 @@ @implementation Kb { RCT_EXPORT_MODULE() -// UIApplication.applicationState lags under scenes (it still reads inactive in -// didBecomeActive), and RN's AppState reads it, so JS takes the app state from -// here instead: derived from the scene activation notifications, active if any -// scene is active, inactive if any is in the foreground, background otherwise. -// Observed from a load-time constructor (RCT_EXPORT_MODULE owns +load) so -// every transition since launch is seen before any module (or JS) exists. kbSceneStates is main-thread only; kbAppState is what -// getAppState reads from the JS thread. -static NSMapTable *kbSceneStates = nil; -static std::mutex kbAppStateMutex; -static NSString *kbAppState = @"background"; - -__attribute__((constructor)) static void kbObserveSceneStates(void) { - kbSceneStates = [NSMapTable weakToStrongObjectsMapTable]; - NSDictionary *states = @{ - UISceneWillEnterForegroundNotification : @"inactive", - UISceneDidActivateNotification : @"active", - UISceneWillDeactivateNotification : @"inactive", - UISceneDidEnterBackgroundNotification : @"background", - }; - NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; - for (NSNotificationName name in states) { - NSString *state = states[name]; - [center addObserverForName:name - object:nil - queue:nil - usingBlock:^(NSNotification *note) { - [Kb scene:note.object changedToState:state]; - }]; - } - [center addObserverForName:UISceneDidDisconnectNotification - object:nil - queue:nil - usingBlock:^(NSNotification *note) { - [Kb scene:note.object changedToState:nil]; - }]; -} - -+ (void)scene:(UIScene *)scene changedToState:(NSString *)state { - if (![scene isKindOfClass:[UIScene class]]) { - return; - } - if (state) { - [kbSceneStates setObject:state forKey:scene]; - } else { - [kbSceneStates removeObjectForKey:scene]; - } - NSString *next = @"background"; - for (NSString *sceneState in kbSceneStates.objectEnumerator) { - if ([sceneState isEqualToString:@"active"]) { - next = sceneState; - break; - } - if ([sceneState isEqualToString:@"inactive"]) { - next = sceneState; - } - } - { - std::lock_guard lock(kbAppStateMutex); - if ([next isEqualToString:kbAppState]) { - return; - } - kbAppState = next; - } - Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { - [instance emitOnAppStateChange:next]; - } -} - -- (NSString *)getAppState { - std::lock_guard lock(kbAppStateMutex); - return kbAppState; -} - - (NSString *)takePushTap { std::lock_guard lock(kbPushTapMutex); NSString *payload = kbPushTapPayload ?: @""; diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index 3d3748c927e7..d3ea648c3d86 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -9,8 +9,6 @@ export interface Spec extends TurboModule { readonly onPushTap: EventEmitter readonly onPushToken: EventEmitter readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> - // iOS only: 'active' | 'inactive' | 'background', from the scene activation notifications - readonly onAppStateChange: EventEmitter getTypedConstants(): { androidIsDeviceSecure: boolean androidIsTestDevice: boolean @@ -70,8 +68,6 @@ export interface Spec extends TurboModule { engineReset(): void notifyJSReady(): void shareListenersRegistered(): void - // iOS only: the current value onAppStateChange reports; '' on Android - getAppState(): string setEnablePasteImage(enabled: boolean): void clearLocalLogs(): Promise } diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index 1289e4c5423f..763408b0b7da 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -170,16 +170,6 @@ export const notifyJSReady = (): void => { export const shareListenersRegistered = (): void => { return Kb.shareListenersRegistered() } -// iOS only. UIApplication.applicationState (and so RN's AppState) lags under scenes, reading -// inactive while the scene is already active; these follow the scene activation notifications. -export const iosOnAppStateChange = (callback: (state: string) => void): EventSubscription => { - return Kb.onAppStateChange(callback) -} - -export const iosGetAppState = (): string => { - return Kb.getAppState() -} - export const clearLocalLogs = (): Promise => { return Kb.clearLocalLogs() } diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 9e13a223ab1a..6f5fa831ffd9 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' @@ -15,12 +15,11 @@ import {Image as ExpoImage} from 'expo-image' import {setServiceDecoration} from '@/common-adapters/markdown/react' import ServiceDecoration from '@/common-adapters/markdown/service-decoration' import {useUnmountAll} from '@/util/debug-react' -import {darkModeSupported, guiConfig, iosGetAppState, iosOnAppStateChange} from 'react-native-kb' +import {darkModeSupported, guiConfig} from 'react-native-kb' import * as DarkMode from '@/stores/darkmode' import {colors, darkColors} from '@/styles/colors' import {initPlatformListener, onEngineConnected, onEngineDisconnected, onEngineIncoming} from '@/constants/init/index' import logger from '@/logger' -import {watchAppState, type AppStateSource} from './watch-app-state' logger.info('INIT App index module load') @@ -57,34 +56,17 @@ const initDarkMode = () => { } catch {} } -// UIApplication.applicationState lags under iOS scenes, so RN's AppState can sit at inactive while -// the app is active; iOS reports the scene state itself. Android has no such lag. -const appStateSource: AppStateSource = isIOS - ? { - current: iosGetAppState, - subscribe: listener => { - const sub = iosOnAppStateChange(listener) - return () => sub.remove() - }, - } - : { - current: () => AppState.currentState, - subscribe: listener => { - const sub = AppState.addEventListener('change', listener) - return () => sub.remove() - }, - } - const useDarkHookup = () => { + // The store starts at 'unknown' and only the service can move it off that, which is later than + // this mounts; assume active until told otherwise so an early theme change is not dropped. const appStateRef = React.useRef('active') const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode) - const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState) React.useEffect(() => { - const stopWatchingAppState = watchAppState(appStateSource, nextAppState => { - appStateRef.current = nextAppState - 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') } }) @@ -100,7 +82,7 @@ const useDarkHookup = () => { stopWatchingAppState() darkSub.remove() } - }, [setSystemDarkMode, setMobileAppState]) + }, [setSystemDarkMode]) } const StoreHelper = (p: {children: React.ReactNode}): React.ReactNode => { diff --git a/shared/app/watch-app-state.test.ts b/shared/app/watch-app-state.test.ts deleted file mode 100644 index 6f018fe9df86..000000000000 --- a/shared/app/watch-app-state.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/// -import {resetAllStores} from '@/util/zustand' -import {useShellState} from '@/stores/shell' -import {watchAppState} from './watch-app-state' - -const makeSource = (initial: string) => { - let state = initial - let listener: ((state: string) => void) | undefined - const unsubscribe = jest.fn(() => { - listener = undefined - }) - return { - emit: (next: string) => { - state = next - listener?.(next) - }, - source: { - current: () => state, - subscribe: (l: (state: string) => void) => { - listener = l - return unsubscribe - }, - }, - unsubscribe, - } -} - -const watchIntoStore = (source: Parameters[0]) => - watchAppState(source, useShellState.getState().dispatch.setMobileAppState) - -afterEach(() => { - resetAllStores() - useShellState.setState({mobileAppState: 'unknown'}) -}) - -test('the store is seeded from the current native state', () => { - const {source} = makeSource('active') - const stop = watchIntoStore(source) - expect(useShellState.getState().mobileAppState).toBe('active') - stop() -}) - -test('the store follows every native transition, including a return to a state it already had', () => { - const {emit, source} = makeSource('active') - const stop = watchIntoStore(source) - const seen = new Array() - const unsub = useShellState.subscribe(s => seen.push(s.mobileAppState)) - - emit('inactive') - emit('active') - emit('inactive') - emit('background') - emit('inactive') - emit('active') - - expect(seen).toEqual(['inactive', 'active', 'inactive', 'background', 'inactive', 'active']) - unsub() - stop() -}) - -test('a change between subscribing and seeding is not lost', () => { - const {emit, source} = makeSource('inactive') - const stop = watchAppState( - { - current: source.current, - // native changes while the listener is being registered, and the event misses it - subscribe: l => { - emit('active') - return source.subscribe(l) - }, - }, - useShellState.getState().dispatch.setMobileAppState - ) - expect(useShellState.getState().mobileAppState).toBe('active') - stop() -}) - -test('states that are not app states are ignored', () => { - const {emit, source} = makeSource('unknown') - const stop = watchIntoStore(source) - expect(useShellState.getState().mobileAppState).toBe('unknown') - emit('extension') - expect(useShellState.getState().mobileAppState).toBe('unknown') - emit('background') - expect(useShellState.getState().mobileAppState).toBe('background') - stop() -}) - -test('stopping unsubscribes from native', () => { - const {source, unsubscribe} = makeSource('active') - watchIntoStore(source)() - expect(unsubscribe).toHaveBeenCalled() -}) diff --git a/shared/app/watch-app-state.tsx b/shared/app/watch-app-state.tsx deleted file mode 100644 index 2b9f6fd2495d..000000000000 --- a/shared/app/watch-app-state.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export type MobileAppState = 'active' | 'background' | 'inactive' - -export type AppStateSource = { - current: () => string | null | undefined - subscribe: (listener: (state: string) => void) => () => void -} - -const asMobileAppState = (state: string | null | undefined): MobileAppState | undefined => - state === 'active' || state === 'background' || state === 'inactive' ? state : undefined - -// Subscribes before reading the current state, so a change in between is never missed. -export const watchAppState = (source: AppStateSource, onState: (state: MobileAppState) => void) => { - const unsubscribe = source.subscribe(state => { - const next = asMobileAppState(state) - if (next) { - onState(next) - } - }) - const seeded = asMobileAppState(source.current()) - if (seeded) { - onState(seeded) - } - return unsubscribe -} diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts new file mode 100644 index 000000000000..8b637d564d9f --- /dev/null +++ b/shared/constants/init/app-state.test.ts @@ -0,0 +1,79 @@ +/// +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} + +// The applied versions live outside the store and survive resetAllStores on purpose, so each test +// gets its own epoch rather than a counter that has to beat every earlier test's. +let testEpoch = 500 +const version = (counter: number, epoch = testEpoch): T.RPCGen.StateVersion => ({counter, epoch}) + +beforeEach(() => { + testEpoch++ + 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, version(1)) + expect(useShellState.getState().mobileAppState).toBe(expected) + }) + + test('arrives through the notification', () => { + _onEngineIncoming({ + payload: {params: {state: T.RPCGen.MobileAppState.background, version: version(1)}}, + type: 'keybase.1.NotifyApp.mobileAppStateChanged', + } as never) + expect(useShellState.getState().mobileAppState).toBe('background') + }) + + test('an older notification than the one applied is dropped', () => { + applyMobileAppState(T.RPCGen.MobileAppState.background, version(5)) + // the fan-out is one goroutine per connection, so this can land after the one above + applyMobileAppState(T.RPCGen.MobileAppState.foreground, version(4)) + expect(useShellState.getState().mobileAppState).toBe('background') + + applyMobileAppState(T.RPCGen.MobileAppState.foreground, version(6)) + expect(useShellState.getState().mobileAppState).toBe('active') + }) + + test('a new service process wins whatever its counter says', () => { + applyMobileAppState(T.RPCGen.MobileAppState.background, version(9)) + applyMobileAppState(T.RPCGen.MobileAppState.foreground, version(1, testEpoch + 1000)) + expect(useShellState.getState().mobileAppState).toBe('active') + }) + + test('arrives in the subscribe snapshot, which is what catches a late-started JS up', () => { + applyClientState({appState: T.RPCGen.MobileAppState.background, version: version(1)}) + expect(useShellState.getState().mobileAppState).toBe('background') + }) + + test('a service too old to send one leaves the state unknown and burns no version', () => { + applyMobileAppState(undefined, version(1)) + expect(useShellState.getState().mobileAppState).toBe('unknown') + applyMobileAppState(T.RPCGen.MobileAppState.background, version(1)) + 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, version(1)) + expect(useShellState.getState().mobileAppState).toBe('unknown') + }) +}) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index e6051629c995..fe4e529f75af 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -375,7 +375,8 @@ const _initNativePlatformListener = () => { appFocused = false } - // 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) })) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 31e373413591..4eaf55bd5a59 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -253,6 +253,37 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus applyStatusIdentity(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, version?: T.RPCGen.StateVersion) => { + if (!isMobile || state === undefined) { + return + } + if (!useConfigState.getState().dispatch.acceptAppStateVersion(version)) { + logger.info('[AppState] older than the applied state, ignoring') + 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: + } +} + // The reply to setNotifications: the state as of the moment this connection subscribed, so there // is no read to order against the subscription. An old service returns nothing here and the // bootstrap status keeps that job -- see applyUnversionedStatusSession. @@ -279,7 +310,10 @@ export const applyClientState = (clientState?: T.RPCGen.ClientState, generation? if (!clientState) { return } - const {httpSrvInfo, version} = clientState + const {appState, httpSrvInfo, version} = 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, and there is no earlier reading to order against. + applyMobileAppState(appState, version) const configDispatch = useConfigState.getState().dispatch if (httpSrvInfo) { configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token, version) @@ -439,6 +473,11 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { } switch (action.type) { + case 'keybase.1.NotifyApp.mobileAppStateChanged': { + const {state, version} = action.payload.params + applyMobileAppState(state, version) + break + } case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 372db912e770..d2adb66b7d7e 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -89,6 +89,10 @@ const initialStore: Store = { export type State = Store & { dispatch: { + // an app lifecycle state notification or snapshot: applied only if it is newer than the last + // applied one. The fan-out is one goroutine per connection, so two of these can arrive in + // either order, and applying the older one last would leave us permanently wrong. + acceptAppStateVersion: (version?: T.RPCGen.StateVersion) => boolean // a login or logout notification: applied only if it is newer than the last applied one acceptSessionVersion: (version?: T.RPCGen.StateVersion) => boolean // whether the connected service has told us it cannot settle the session -- see the closure @@ -145,8 +149,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { // labelled before the state it carries, so it is never newer than its label: dropping it on a // tie loses nothing, because anything it holds beyond its label is a change already on its way // as its own notification. - const applied: {http?: T.RPCGen.StateVersion; session?: T.RPCGen.StateVersion} = {} - const acceptVersion = (kind: 'http' | 'session', version?: T.RPCGen.StateVersion) => { + const applied: { + appState?: T.RPCGen.StateVersion + http?: T.RPCGen.StateVersion + session?: T.RPCGen.StateVersion + } = {} + const acceptVersion = (kind: 'appState' | 'http' | 'session', version?: T.RPCGen.StateVersion) => { // a service too old to send a version gives us nothing to order by, so everything it sends is // applied in the order it arrives, as it was before versions existed if (!isComparableVersion(version)) return true @@ -211,6 +219,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { + acceptAppStateVersion: version => acceptVersion('appState', version), acceptSessionVersion: version => { const accepted = acceptVersion('session', version) if (accepted && isComparableVersion(version)) { diff --git a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts index 342a81b818a4..5d20fe8c46b0 100644 --- a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts +++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts @@ -162,38 +162,34 @@ export const jsEval = async (body: string, device = deviceName()): Promise export type AppSnapshot = { loggedIn: boolean mobileAppState: string - nativeAppState: string httpSrv: {address: string; token: string} screen?: {name?: string; params?: Record} } -// JS app state (shell store, fed by native scene notifications), the native value it -// was fed from, the http server address JS uses for images, and the visible screen. +// 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 kb = kbModule('node_modules/react-native-kb/src/index.tsx') const screen = kbModule('constants/router.tsx').getVisibleScreen() return { httpSrv: config.httpSrv, loggedIn: config.loggedIn, mobileAppState: shell.mobileAppState, - nativeAppState: kb.iosGetAppState(), screen: screen ? {name: screen.name, params: screen.params} : undefined, }`, device ) -// Waits for a relaunched JS runtime to be logged in and report `state` from both JS and native. +// 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.nativeAppState === state && s.httpSrv.address - ? s - : undefined + return s.loggedIn && s.mobileAppState === state && s.httpSrv.address ? s : undefined }, {interval: 500, timeout} ) From 87f21b9f220dc75df5e3ae84bf187b716a037e4e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 11:57:05 -0400 Subject: [PATCH 082/127] fix(appstate): stamp the app state's version under the lock that wrote it Update released the lock before announcing, and the version is stamped at announce time, so two concurrent Updates could publish out of order and a client's accept-if-newer gate would keep the older state forever. It cannot happen today -- the only caller is lifecycle's applyLocked, under its own lock -- but Update is exported and tests call it from goroutines, so make the ordering structural rather than documented. The fan-out only reads the connection table and spawns a goroutine per send, and nothing it touches reads app state, so it cannot re-enter the lock. Also corrects the comment about iOS background time: native ends the background task as soon as AppUIBackground returns 0, which is the ordinary backgrounding, so a client acting on the notification is racing the OS rather than covered by a task Go holds open. --- go/libkb/appstate.go | 30 ++++++++++++++++--------- go/libkb/appstate_test.go | 22 ++++++++++++++++++ go/libkb/notify_router.go | 6 +++++ shared/app/index.native.tsx | 4 +++- shared/constants/init/app-state.test.ts | 6 +++++ shared/constants/init/shared.tsx | 5 +++++ 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 5e66aec756ff..c9950ddd24fe 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -101,26 +101,34 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo default: // Nothing to do for other states. } + + // Tell connected clients, still under the lock, so the state version is + // stamped in the same critical section that wrote the state. Two concurrent + // Updates then publish in the order they wrote, and a client's + // accept-if-newer gate can never be handed an older state last and keep it + // forever. Cheap to hold: the fan-out reads the connection table and starts + // one goroutine per connection, and every send happens on those goroutines. + // Nothing it touches reads app state, so it cannot re-enter this lock. + a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) return true } // Update sets the current app state and returns whether the value changed; // only a change wakes NextUpdate callers and has side effects. // -// Connected clients are told from here, the one place the value changes, and -// before lifecycle's Flush hook runs: on iOS the whole background transition -// happens inside a UIBackgroundTask native holds open across the bind call, so -// a client still has time to act on the notification. The announce is outside -// the lock because it fans out to every connection. +// 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 only keeps the app alive past this call when Go asked it to +// (AppDelegate.swift ends the background task as soon as AppUIBackground +// returns 0, which is the ordinary backgrounding). 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() - changed = a.updateLocked(state) - a.Unlock() - if changed { - a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) - } - return changed + defer a.Unlock() + return a.updateLocked(state) } // State returns the current app state diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index 5e74ea7abb16..61bcc7f1544f 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -97,3 +97,25 @@ func TestMobileAppStateAnnouncesOnlyOnChange(t *testing.T) { require.True(t, a.Update(keybase1.MobileAppState_FOREGROUND)) require.Equal(t, announced.Counter+1, tc.G.StateVersion().Counter) } + +// The stamp lands in the same critical section as the state write, so two +// concurrent Updates publish 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 the version been stamped yet", +// and the answer must be yes before the lock is released. +func TestMobileAppStateStampsUnderTheLock(t *testing.T) { + tc := SetupTest(t, "MobileAppStateStamp", 0) + defer tc.Cleanup() + tc.G.SetService() + a := NewMobileAppState(tc.G) + + before := tc.G.StateVersion().Counter + a.Lock() + changed := a.updateLocked(keybase1.MobileAppState_BACKGROUND) + stamped := tc.G.StateVersion().Counter + a.Unlock() + + require.True(t, changed) + require.Equal(t, before+1, stamped, + "the change was announced before the lock that wrote it was released") +} diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index 5df72b56c554..ad900c41cec4 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -2842,6 +2842,12 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase // 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 diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 6f5fa831ffd9..a1bd3ad7614d 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -58,7 +58,9 @@ const initDarkMode = () => { const useDarkHookup = () => { // The store starts at 'unknown' and only the service can move it off that, which is later than - // this mounts; assume active until told otherwise so an early theme change is not dropped. + // 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) diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts index 8b637d564d9f..bf2d16b9efbe 100644 --- a/shared/constants/init/app-state.test.ts +++ b/shared/constants/init/app-state.test.ts @@ -64,6 +64,12 @@ describe('the app state the service derives', () => { expect(useShellState.getState().mobileAppState).toBe('background') }) + test('a state we do not map leaves the app state alone rather than guessing', () => { + applyMobileAppState(T.RPCGen.MobileAppState.background, version(1)) + applyMobileAppState(99 as T.RPCGen.MobileAppState, version(2)) + expect(useShellState.getState().mobileAppState).toBe('background') + }) + test('a service too old to send one leaves the state unknown and burns no version', () => { applyMobileAppState(undefined, version(1)) expect(useShellState.getState().mobileAppState).toBe('unknown') diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 4eaf55bd5a59..24928ff368db 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -281,6 +281,9 @@ export const applyMobileAppState = (state?: T.RPCGen.MobileAppState, version?: T useShellState.getState().dispatch.setMobileAppState('background') break default: + // a fifth state the service grew and we have not mapped: it has already taken the version, + // so 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`) } } @@ -313,6 +316,8 @@ export const applyClientState = (clientState?: T.RPCGen.ClientState, generation? const {appState, httpSrvInfo, version} = 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, and there is no earlier reading to order against. + // appState is generated as required, but a service older than it omits the field, so it really + // can be undefined here -- applyMobileAppState is what treats that as "nothing was said". applyMobileAppState(appState, version) const configDispatch = useConfigState.getState().dispatch if (httpSrvInfo) { From a7a9849a2221a55cb5dd82268348bed4c2e39947 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 12:22:06 -0400 Subject: [PATCH 083/127] feat(protocol): hand a resolved push-tap route to clients A tapped notification's payload is currently marshalled to the client as JSON so the client can parse it, map five fields to a keybase:// URL and pick out the account the notification belongs to. The service already owns every fact in that middle: the conversation, which account a uid names, and which accounts have a stored secret. Adds the shape for the service to answer with instead. takePushTapRoute returns the route a tap resolved to and clears it, so it is the one taker and a tap is handed out exactly once however many times a client reconnects. It is deliberately its own call rather than a field in setNotifications' reply: that reply goes to every subscriber, kbfs inside this same process among them, and a destructive read there would let the wrong one consume a tap. pushTapRouteAvailable carries nothing for the same reason -- it is a nudge to take, not a second way to be told, which is what keeps the pair from delivering one tap twice. --- go/protocol/keybase1/appstate.go | 31 ++++++++++++++++++++++++++ go/protocol/keybase1/notify_app.go | 19 ++++++++++++++++ protocol/avdl/keybase1/appstate.avdl | 18 +++++++++++++++ protocol/avdl/keybase1/notify_app.avdl | 8 +++++++ protocol/bin/enabled-calls.json | 2 ++ protocol/json/keybase1/appstate.json | 21 +++++++++++++++++ protocol/json/keybase1/notify_app.json | 5 +++++ shared/constants/rpc/index.tsx | 1 + shared/constants/rpc/rpc-gen.tsx | 14 ++++++++++-- 9 files changed, 117 insertions(+), 2 deletions(-) diff --git a/go/protocol/keybase1/appstate.go b/go/protocol/keybase1/appstate.go index 9850958b713e..7558ae1c85b2 100644 --- a/go/protocol/keybase1/appstate.go +++ b/go/protocol/keybase1/appstate.go @@ -78,16 +78,32 @@ 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"` +} + +func (o PushTapRoute) DeepCopy() PushTapRoute { + return PushTapRoute{ + Url: o.Url, + TargetUID: o.TargetUID, + } +} + type UpdateMobileNetStateArg struct { State string `codec:"state" json:"state"` } +type TakePushTapRouteArg struct { +} + type PowerMonitorEventArg struct { Event string `codec:"event" json:"event"` } type AppStateInterface interface { UpdateMobileNetState(context.Context, string) error + TakePushTapRoute(context.Context) (*PushTapRoute, error) PowerMonitorEvent(context.Context, string) error } @@ -110,6 +126,16 @@ func AppStateProtocol(i AppStateInterface) rpc.Protocol { return }, }, + "takePushTapRoute": { + MakeArg: func() any { + var ret [1]TakePushTapRouteArg + return &ret + }, + Handler: func(ctx context.Context, args any) (ret any, err error) { + ret, err = i.TakePushTapRoute(ctx) + return + }, + }, "powerMonitorEvent": { MakeArg: func() any { var ret [1]PowerMonitorEventArg @@ -139,6 +165,11 @@ func (c AppStateClient) UpdateMobileNetState(ctx context.Context, state string) return } +func (c AppStateClient) TakePushTapRoute(ctx context.Context) (res *PushTapRoute, err error) { + err = c.Cli.Call(ctx, "keybase.1.appState.takePushTapRoute", []any{TakePushTapRouteArg{}}, &res, 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 4fe0b9d2ae81..302d8a59da68 100644 --- a/go/protocol/keybase1/notify_app.go +++ b/go/protocol/keybase1/notify_app.go @@ -18,9 +18,13 @@ type MobileAppStateChangedArg struct { Version StateVersion `codec:"version" json:"version"` } +type PushTapRouteAvailableArg struct { +} + type NotifyAppInterface interface { Exit(context.Context) error MobileAppStateChanged(context.Context, MobileAppStateChangedArg) error + PushTapRouteAvailable(context.Context) error } func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { @@ -52,6 +56,16 @@ func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { 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 + }, + }, }, } } @@ -69,3 +83,8 @@ func (c NotifyAppClient) MobileAppStateChanged(ctx context.Context, __arg Mobile err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.mobileAppStateChanged", []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/protocol/avdl/keybase1/appstate.avdl b/protocol/avdl/keybase1/appstate.avdl index 8cc21333147d..8d6cb01974c3 100644 --- a/protocol/avdl/keybase1/appstate.avdl +++ b/protocol/avdl/keybase1/appstate.avdl @@ -17,10 +17,28 @@ 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; + } + // gui -> service // mobile only void updateMobileNetState(string state); + // gui -> service + // mobile only + // Returns the route a tapped notification resolved to and clears it, or null + // when no tap is waiting. Destructive on purpose: this is the only taker, so + // a tap is delivered exactly once however many times a client reconnects. + union { null, PushTapRoute } takePushTapRoute(); + // 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 147a100d8300..d7d8caeea11f 100644 --- a/protocol/avdl/keybase1/notify_app.avdl +++ b/protocol/avdl/keybase1/notify_app.avdl @@ -14,4 +14,12 @@ protocol NotifyApp { // arrive in either order. void mobileAppStateChanged(MobileAppState state, StateVersion version) oneway; + // A notification tap resolved to a route and it is waiting to be taken. A + // nudge, not a delivery: the route rides takePushTapRoute's reply, so the + // taker is the same one whether the tap happened before this client existed + // or while it was connected, and neither path can hand out the same tap + // twice. Carries nothing for that reason -- acting on this without taking + // would be a second delivery path. + void pushTapRouteAvailable() oneway; + } diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index 70c7ec885c5d..7f4892f5ded6 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -153,6 +153,7 @@ "chat.1.local.userEmojis": {"promise":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}, @@ -252,6 +253,7 @@ "keybase.1.apiserver.Post": {"promise":true}, "keybase.1.apiserver.PostJSON": {"promise":true}, "keybase.1.appState.powerMonitorEvent": {"promise":true}, + "keybase.1.appState.takePushTapRoute": {"promise":true}, "keybase.1.appState.updateMobileNetState": {"promise":true}, "keybase.1.config.appendGUILogs": {"promise":true}, "keybase.1.config.generateWebAuthToken": {"promise":true}, diff --git a/protocol/json/keybase1/appstate.json b/protocol/json/keybase1/appstate.json index 4d096179e475..67e02b6c9d58 100644 --- a/protocol/json/keybase1/appstate.json +++ b/protocol/json/keybase1/appstate.json @@ -22,6 +22,20 @@ "UNKNOWN_3", "NOTAVAILABLE_4" ] + }, + { + "type": "record", + "name": "PushTapRoute", + "fields": [ + { + "type": "string", + "name": "url" + }, + { + "type": "string", + "name": "targetUID" + } + ] } ], "messages": { @@ -34,6 +48,13 @@ ], "response": null }, + "takePushTapRoute": { + "request": [], + "response": [ + null, + "PushTapRoute" + ] + }, "powerMonitorEvent": { "request": [ { diff --git a/protocol/json/keybase1/notify_app.json b/protocol/json/keybase1/notify_app.json index 4f404526fa62..0a224c0c0e07 100644 --- a/protocol/json/keybase1/notify_app.json +++ b/protocol/json/keybase1/notify_app.json @@ -30,6 +30,11 @@ ], "response": null, "oneway": true + }, + "pushTapRouteAvailable": { + "request": [], + "response": null, + "oneway": true } }, "namespace": "keybase.1" diff --git a/shared/constants/rpc/index.tsx b/shared/constants/rpc/index.tsx index 7dfc80c6502f..e08abf03d354 100644 --- a/shared/constants/rpc/index.tsx +++ b/shared/constants/rpc/index.tsx @@ -72,6 +72,7 @@ type Chat1ResponseActionMap = { type Keybase1IncomingAction = 'keybase.1.NotifyApp.mobileAppStateChanged' | + 'keybase.1.NotifyApp.pushTapRouteAvailable' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index 8298da1a310f..176add83c98b 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -19,6 +19,10 @@ export type MessageTypes = { inParam: {readonly state: MobileAppState,readonly version: StateVersion}, outParam: void, }, + 'keybase.1.NotifyApp.pushTapRouteAvailable': { + inParam: undefined, + outParam: void, + }, 'keybase.1.NotifyAudit.boxAuditError': { inParam: {readonly message: string}, outParam: void, @@ -415,6 +419,10 @@ export type MessageTypes = { inParam: {readonly event: string}, outParam: void, }, + 'keybase.1.appState.takePushTapRoute': { + inParam: undefined, + outParam: PushTapRoute | null, + }, 'keybase.1.appState.updateMobileNetState': { inParam: {readonly state: string}, outParam: void, @@ -1276,7 +1284,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.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.powerMonitorEvent' | 'keybase.1.appState.takePushTapRoute' | '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> @@ -2843,6 +2851,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,} 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,} @@ -3124,7 +3133,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.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | 'keybase.1.NotifyDeviceHistory.deviceHistoryChanged' | 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | 'keybase.1.NotifyUsers.userChanged' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.pgpUi.finished' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent' +type IncomingMethod = '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 +3202,7 @@ export const apiserverGetWithSessionRpcPromise = createRpc('keybase.1.apiserver. export const apiserverPostJSONRpcPromise = createRpc('keybase.1.apiserver.PostJSON') export const apiserverPostRpcPromise = createRpc('keybase.1.apiserver.Post') export const appStatePowerMonitorEventRpcPromise = createRpc('keybase.1.appState.powerMonitorEvent') +export const appStateTakePushTapRouteRpcPromise = createRpc('keybase.1.appState.takePushTapRoute') export const appStateUpdateMobileNetStateRpcPromise = createRpc('keybase.1.appState.updateMobileNetState') export const configAppendGUILogsRpcPromise = createRpc('keybase.1.config.appendGUILogs') export const configGenerateWebAuthTokenRpcPromise = createRpc('keybase.1.config.generateWebAuthToken') From 689f89b084a99946cfe783e5f230f3ed430d7de4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 12:22:21 -0400 Subject: [PATCH 084/127] feat(service): resolve a tapped notification to a route in the service The service now owns where a tap opens. DeliverPushTap is the one door a tap comes through -- native calls it from its notification-tap handler and nowhere else -- so it is also the only thing anywhere that can name an account to switch to. A URL another app, a web page or a universal link opens goes through Linking and cannot reach it, and a silent or background push is HandleBackgroundNotification, which never routes. The resolved route waits in PendingPushTap until a client takes it. That is the whole of the exactly-once property, and it is the same property the native slot used to provide, moved to the one party that always exists: a tap that arrives before any client is running (on iOS a background launch never starts one at all) is still there when one connects, and because the take clears, a reconnect or a reload finds nothing left to act on again. ResolvePushTap's table is the one the client carried, case for case, and its test is that table. The URL escaping is spelled out rather than taken from net/url: the result is compared against URLs built with JavaScript's encodeURIComponent, and each of Go's escapers differs from it somewhere. --- go/bind/keybase.go | 25 ++++++ go/libkb/globals.go | 2 + go/libkb/notify_router.go | 17 ++++ go/libkb/pushtap.go | 152 ++++++++++++++++++++++++++++++++++++ go/libkb/pushtap_test.go | 129 ++++++++++++++++++++++++++++++ go/service/appstate.go | 10 +++ go/service/appstate_test.go | 61 +++++++++++++++ 7 files changed, 396 insertions(+) create mode 100644 go/libkb/pushtap.go create mode 100644 go/libkb/pushtap_test.go create mode 100644 go/service/appstate_test.go diff --git a/go/bind/keybase.go b/go/bind/keybase.go index fbdbeb6a4b51..32f1bb510b42 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -918,6 +918,31 @@ func locationUpdate(tracker types.LiveLocationTracker, lat, lon float64, accurac tracker.LocationUpdate(context.Background(), chat1.Coordinate{Lat: lat, Lon: lon, Accuracy: float64(accuracy)}) } +// 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 + } + 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 { if isInited() { return nil diff --git a/go/libkb/globals.go b/go/libkb/globals.go index 20733917e04c..f597bbb84fb6 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -72,6 +72,7 @@ type GlobalContext struct { 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 @@ -314,6 +315,7 @@ func (g *GlobalContext) Init() *GlobalContext { 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() diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index ad900c41cec4..ded6cf3895d1 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -2861,6 +2861,23 @@ func (n *NotifyRouter) HandleMobileAppState(ctx context.Context, state keybase1. }) } +// HandlePushTapRouteAvailable nudges clients that a notification tap resolved +// to a route. It carries nothing: the route rides takePushTapRoute's reply, so +// the taker is the same one whether the tap happened before a client existed or +// while it was connected, and a tap can be handed out only once. +func (n *NotifyRouter) HandlePushTapRouteAvailable(ctx context.Context) { + if n == nil { + return + } + n.announce(ctx, "HandlePushTapRouteAvailable", + func(ch keybase1.NotificationChannels) bool { return ch.App }, + func(xp rpc.Transporter, version keybase1.StateVersion) { + _ = (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/pushtap.go b/go/libkb/pushtap.go new file mode 100644 index 000000000000..e36b2d7286b7 --- /dev/null +++ b/go/libkb/pushtap.go @@ -0,0 +1,152 @@ +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 takes 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. Take is the only reader +// and it clears, so a client that reconnects, or a fresh one after a reload, +// finds nothing left to act on a second time. +type PendingPushTap struct { + Contextified + sync.Mutex + route *keybase1.PushTapRoute +} + +func NewPendingPushTap(g *GlobalContext) *PendingPushTap { + return &PendingPushTap{Contextified: NewContextified(g)} +} + +// Set stores the route a tap resolved to and nudges connected clients. A tap +// that has not been taken yet is replaced: the newest tap is the one the user +// just made, and queueing them would navigate through a backlog. +func (p *PendingPushTap) Set(ctx context.Context, route keybase1.PushTapRoute) { + p.Lock() + p.route = &route + p.Unlock() + p.G().NotifyRouter.HandlePushTapRouteAvailable(ctx) +} + +// Take returns the waiting route and clears it, or nil when no tap is waiting. +func (p *PendingPushTap) Take() *keybase1.PushTapRoute { + p.Lock() + defer p.Unlock() + route := p.route + p.route = nil + return route +} + +// 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..398a0338b67c --- /dev/null +++ b/go/libkb/pushtap_test.go @@ -0,0 +1,129 @@ +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 TestPendingPushTapTakeClears(t *testing.T) { + tc := SetupTest(t, "pushtap", 1) + defer tc.Cleanup() + g := tc.G + + require.Nil(t, g.PendingPushTap.Take()) + + first := keybase1.PushTapRoute{Url: "keybase://convid/0000ab", TargetUID: "u1"} + g.PendingPushTap.Set(context.Background(), first) + require.Equal(t, &first, g.PendingPushTap.Take()) + // A second taker gets nothing: this is what keeps a reconnect, or a fresh + // client after a reload, from acting on the same tap again. + require.Nil(t, g.PendingPushTap.Take()) + + // An untaken tap is replaced rather than queued. + g.PendingPushTap.Set(context.Background(), first) + second := keybase1.PushTapRoute{Url: "keybase://devices", TargetUID: "u2"} + g.PendingPushTap.Set(context.Background(), second) + require.Equal(t, &second, g.PendingPushTap.Take()) + require.Nil(t, g.PendingPushTap.Take()) +} diff --git a/go/service/appstate.go b/go/service/appstate.go index 97d05a86a66c..b1ff0f506095 100644 --- a/go/service/appstate.go +++ b/go/service/appstate.go @@ -45,6 +45,16 @@ func (a *appStateHandler) UpdateMobileNetState(ctx context.Context, stateStr str return nil } +// TakePushTapRoute hands over the route a tapped notification resolved to, and +// clears it. Deliberately not folded into setNotifications' snapshot: that +// reply goes to every subscriber, including kbfs inside this same process, and +// a destructive read there would let the wrong one consume the tap. +func (a *appStateHandler) TakePushTapRoute(ctx context.Context) (*keybase1.PushTapRoute, error) { + route := a.G().PendingPushTap.Take() + a.G().Log.CDebugf(ctx, "TakePushTapRoute: waiting tap: %v", route != nil) + return route, 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..a0d6c47d9944 --- /dev/null +++ b/go/service/appstate_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + "testing" + + "github.com/keybase/client/go/libkb" + keybase1 "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// The take is what makes a tap exactly-once: it is the only reader of the +// pending tap, and it clears. A client that reconnects, or a fresh one after a +// reload, gets nothing rather than the tap it already acted on. +func TestTakePushTapRouteClearsTheTap(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.TakePushTapRoute(ctx) + require.NoError(t, err) + require.Nil(t, got, "no tap has happened") + + route := keybase1.PushTapRoute{Url: "keybase://convid/0000ab", TargetUID: "u1"} + g.PendingPushTap.Set(ctx, route) + + got, err = h.TakePushTapRoute(ctx) + require.NoError(t, err) + require.Equal(t, &route, got) + + got, err = h.TakePushTapRoute(ctx) + require.NoError(t, err) + require.Nil(t, got, "the tap was already handed out") +} + +// A tap must ride its own call and nothing else. setNotifications answers every +// subscriber -- kbfs subscribes from inside this same process -- so a tap +// carried in that reply would be consumed by whichever one subscribed 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) + + n, svc, _ := newTestNotifyCtlHandler(t, g) + svc.initialLoginAttemptOnce.Do(func() { close(svc.initialLoginAttemptDone) }) + _, err := n.SetNotifications(ctx, keybase1.NotificationChannels{App: true}) + require.NoError(t, err) + + got, err := newAppStateHandler(nil, g).TakePushTapRoute(ctx) + require.NoError(t, err) + require.Equal(t, &route, got, "the subscribe did not consume the tap") +} From 41b62669a642c9f097dbfa7811e57a2cb835e5a9 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 12:22:30 -0400 Subject: [PATCH 085/127] refactor(push): take the tap route from the service A tap was native's payload, parked in a native slot, taken by JS, parsed out of stringly-typed JSON by pushTapTarget, and mapped to a URL against a JS mirror of which account is current. All of that is gone: native hands the payload straight to the service, and JS takes the route the service resolved. Deletes pushTapTarget and its five-case table, enqueuePushTap, subscribePushTaps, takePushTap/onPushTap from the TurboModule spec and both native implementations, both native slots (kbPushTapMutex/kbPushTapPayload and pushTapSlot), KbDeliverPushTap, and PushTapData -- a hand-rolled Kotlin JSON encoder, a five-field projection and its test that existed only to feed the TypeScript parser. Android now puts the push in the tap Intent's extras and a digest of it in the data. Two notifications with different payloads must still differ in the data, since extras are not part of filterEquals and PendingIntent reuses by it -- but a digest rather than the payload, so the payload leaves `dumpsys activity`, where the projection used to be printed. PushTapActivity runs initOnce first, because a tap can be what starts the process; it is the same call MainActivity makes a moment later and runs at most once. The account switch stays in JS. The service resolves where to go and for which account; account-link-switch still performs the switch, still only for an intent carrying a targetUid, and still drops the tap when the switch fails. --- .../main/java/com/reactnativekb/KbModule.kt | 22 --- rnmodules/react-native-kb/ios/Kb.h | 2 - rnmodules/react-native-kb/ios/Kb.mm | 32 ---- rnmodules/react-native-kb/src/NativeKb.ts | 4 - rnmodules/react-native-kb/src/index.tsx | 10 -- .../io/keybase/ossifrage/KBPushNotifier.kt | 27 +++- .../io/keybase/ossifrage/PushTapActivity.kt | 32 +++- .../java/io/keybase/ossifrage/PushTapData.kt | 71 --------- .../io/keybase/ossifrage/PushTapDataTest.kt | 90 ----------- shared/constants/init/index.tsx | 3 +- .../init/push-listener.native.test.ts | 90 ----------- .../constants/init/push-listener.native.tsx | 23 +-- shared/constants/init/push-tap.test.ts | 145 ++++++++++++++++++ shared/constants/init/shared.tsx | 25 +++ shared/ios/Keybase/AppDelegate.swift | 9 +- shared/router-v2/account-link-switch.test.ts | 5 +- shared/router-v2/account-link-switch.tsx | 5 +- shared/router-v2/deep-link-emitter.test.ts | 73 ++------- shared/router-v2/deep-link-emitter.tsx | 72 ++------- shared/router-v2/intent-consumption.test.ts | 4 +- shared/router-v2/linking-initial-url.test.ts | 8 +- shared/router-v2/linking.test.ts | 8 +- 22 files changed, 263 insertions(+), 497 deletions(-) delete mode 100644 shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt delete mode 100644 shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt delete mode 100644 shared/constants/init/push-listener.native.test.ts create mode 100644 shared/constants/init/push-tap.test.ts 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 b8578f8adfde..799738a5b636 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 @@ -33,7 +33,6 @@ import java.io.FileReader import java.io.IOException import java.lang.reflect.Method import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference import keybase.Keybase import keybase.Keybase.readArr import keybase.Keybase.version @@ -376,15 +375,6 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // Android manages badge counts automatically via notification channels. } - @ReactMethod(isBlockingSynchronousMethod = true) - override fun takePushTap(): String = pushTapSlot.getAndSet(null) ?: "" - - private fun emitPushTapInternal() { - if (reactContext.hasActiveReactInstance() && canEmit()) { - emitOnPushTap("") - } - } - internal fun emitShareDataInternal(data: WritableMap) { if (reactContext.hasActiveReactInstance() && canEmit()) { try { @@ -758,23 +748,11 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T // visibility guarantee so the reader never sees a stale instance. @Volatile var instance: KbModule? = null - // The payload of the last tapped notification, until JS takes it. Only - // io.keybase.ossifrage.PushTapActivity, which is not exported, fills it, so it alone - // may carry an account switch. - private val pushTapSlot = AtomicReference(null) - @JvmStatic fun keyPressed(keyName: String) { instance?.sendHardwareKeyEvent(keyName) } - // Called only by io.keybase.ossifrage.PushTapActivity. - @JvmStatic - fun deliverPushTap(payload: String) { - pushTapSlot.set(payload) - instance?.emitPushTapInternal() - } - @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 cfd210a2c80d..aec81655919a 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -22,5 +22,3 @@ // Push notification helpers - can be called from AppDelegate FOUNDATION_EXPORT void KbSetDeviceToken(NSString *token); -// Hands a tapped notification's payload to JS (the tap slot; see Kb.mm). -FOUNDATION_EXPORT void KbDeliverPushTap(NSString *payload); diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index b858c88c73d9..a05a7fbbaa46 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -49,11 +49,6 @@ + (id)sharedFsPathsHolder { static std::mutex kbSharedInstanceMutex; static BOOL kbPasteImageEnabled = NO; static NSString *kbStoredDeviceToken = nil; -// The payload of the last tapped notification, until JS takes it. Only the native -// notification-tap handler writes it (never a URL another app opens), so it alone may -// carry an account switch. -static std::mutex kbPushTapMutex; -static NSString *kbPushTapPayload = 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 @@ -245,13 +240,6 @@ @implementation Kb { RCT_EXPORT_MODULE() -- (NSString *)takePushTap { - std::lock_guard lock(kbPushTapMutex); - NSString *payload = kbPushTapPayload ?: @""; - kbPushTapPayload = nil; - return payload; -} - + (BOOL)requiresMainQueueSetup { return YES; } @@ -893,22 +881,6 @@ + (void)setDeviceToken:(NSString *)token { }); } -// Keeps the latest tap for JS and tells JS if it is listening. The event carries nothing: -// JS takes the payload from the slot, at startup and on the event, so a tap is taken -// exactly once. -+ (void)deliverPushTap:(NSString *)payload { - { - std::lock_guard lock(kbPushTapMutex); - kbPushTapPayload = payload; - } - dispatch_async(dispatch_get_main_queue(), ^{ - Kb *instance = kbSharedInstance; - if (instance && [instance canEmit]) { - [instance emitOnPushTap:@""]; - } - }); -} - - (void)handleHardwareKeyPressed:(NSNotification *)notification { NSString *keyName = notification.userInfo[@"pressedKey"]; if (keyName && [self canEmit]) { @@ -954,7 +926,3 @@ - (void)kb_paste:(id)sender { void KbSetDeviceToken(NSString *token) { [Kb setDeviceToken:token]; } - -void KbDeliverPushTap(NSString *payload) { - [Kb deliverPushTap:payload]; -} diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index d3ea648c3d86..13f9f3728c06 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -5,8 +5,6 @@ export interface Spec extends TurboModule { readonly onMetaEvent: EventEmitter readonly onHardwareKeyPressed: EventEmitter readonly onPasteImage: EventEmitter> - // A tapped notification's payload is waiting in native's tap slot; call takePushTap. Carries nothing. - readonly onPushTap: EventEmitter readonly onPushToken: EventEmitter readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> getTypedConstants(): { @@ -61,8 +59,6 @@ export interface Spec extends TurboModule { requestPushPermissions(): Promise getRegistrationToken(): Promise setApplicationIconBadgeNumber(n: number): void - // Returns the waiting tap payload and clears it, or '' when there is none. - takePushTap(): string removeAllPendingNotificationRequests(): void addNotificationRequest(config: {body: string; id: string}): Promise engineReset(): void diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index 763408b0b7da..348663a8922a 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -140,16 +140,6 @@ export const onMetaEvent = (callback: (payload: string) => void): EventSubscript // Push events -// A tapped notification's payload waits in native until takePushTap reads it; subscribe first, -// then take, and take again on every event. -export const onPushTap = (callback: () => void): EventSubscription => { - return Kb.onPushTap(() => callback()) -} - -export const takePushTap = (): string => { - return Kb.takePushTap() -} - export const onPushToken = (callback: (token: string) => void): EventSubscription => { return Kb.onPushToken(callback) } 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 6ffcd5ed00f0..2507cd4702f8 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 @@ -22,6 +22,7 @@ import keybase.PushNotifier import java.io.BufferedInputStream 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 @@ -35,15 +36,31 @@ class KBPushNotifier internal constructor(private val context: Context, private this.convMsgCache = convMsgCache } - // A tap goes through PushTapActivity, which hands the push's payload to JS. The payload must - // be the Intent's data for each notification to get its own PendingIntent (see PushTapData), - // so the Intent is never built without it. Immutable, so whoever holds this PendingIntent - // can't substitute another payload. + // 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. private fun tapIntent(bundle: Bundle): Intent = Intent(context, PushTapActivity::class.java) - .setData(Uri.parse(PushTapData.tapIntentData(bundleTapFields(bundle)))) + .setData(Uri.parse("kbpushtap:" + payloadDigest(bundle))) + .putExtras(bundle) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + 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) 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 index 6ad4fe3e9a57..3409130f2741 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt @@ -3,19 +3,24 @@ package io.keybase.ossifrage import android.app.Activity import android.content.Intent import android.os.Bundle -import com.reactnativekb.KbModule +import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime +import io.keybase.ossifrage.modules.NativeLogger +import keybase.Keybase +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 tap payload it hands to JS, which may switch accounts, can't -// come from another app. MainActivity, which any app can start, never reads it. +// 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) - // A malformed data URI must still open the app, so a failed decode can't escape here. - runCatching { PushTapData.decode(intent.dataString) } - .getOrDefault("") - .takeIf { it.isNotEmpty() } - ?.let { KbModule.deliverPushTap(it) } + // A tap can be what starts this process, so the service may not be running yet. initOnce + // is the same call MainActivity makes below and runs at most once, so the cost is moved + // rather than added. + runCatching { + setupKBRuntime(this, false) + Keybase.deliverPushTap(payloadJSON(intent.extras)) + }.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) @@ -23,4 +28,15 @@ class PushTapActivity : Activity() { // Theme.NoDisplay requires finishing before onResume. 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/PushTapData.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt deleted file mode 100644 index 2b8fa8c3d3ba..000000000000 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapData.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.keybase.ossifrage - -import android.os.Bundle -import java.net.URLDecoder -import java.net.URLEncoder - -// A push's Bundle reduced to the payload a tap carries. Kept beside PushTapData so both halves -// of the encode have one home. -internal fun bundleTapFields(bundle: Bundle): Map = - PushTapData.tapFields { key -> - @Suppress("DEPRECATION") - bundle.get(key)?.toString() - } - -object PushTapData { - private const val SCHEME = "kbpushtap:" - - // The only fields pushTapTarget (shared/router-v2/deep-link-emitter.tsx) reads. The rest of a - // push payload stays out of the tap Intent: its data URI is printed by `dumpsys activity`, - // where an extra was not. - private val TAP_FIELDS = listOf("type", "convID", "uid", "targetUID", "username") - - // pushTapTarget only tests this prefix, so the rest of a contact message never leaves the app. - private const val CONTACT_PREFIX = "Your contact" - - fun tapFields(read: (String) -> String?): Map { - val fields = LinkedHashMap() - for (key in TAP_FIELDS) { - read(key)?.takeIf { it.isNotEmpty() }?.let { fields[key] = it } - } - if (read("message")?.startsWith(CONTACT_PREFIX) == true) { - fields["message"] = CONTACT_PREFIX - } - return fields - } - - // The tap Intent's data. Two notifications opening different targets must produce different - // data: PendingIntent.getActivity hands back an existing PendingIntent for any Intent that - // filterEquals the new one, and extras are not part of filterEquals. - fun tapIntentData(fields: Map): String = encode(json(fields)) - - fun encode(payloadJSON: String): String = SCHEME + URLEncoder.encode(payloadJSON, "UTF-8") - - fun decode(dataString: String?): String = - if (dataString != null && dataString.startsWith(SCHEME)) { - URLDecoder.decode(dataString.substring(SCHEME.length), "UTF-8") - } else { - "" - } - - // Hand-rolled rather than org.json: these values are all plain strings, the field order stays - // deterministic, and org.json is an android.jar stub that throws in JVM unit tests. - private fun json(fields: Map): String = - fields.entries.joinToString(",", "{", "}") { quoted(it.key) + ":" + quoted(it.value) } - - private fun quoted(value: String): String { - val out = StringBuilder("\"") - for (c in value) { - when { - c == '"' -> out.append("\\\"") - c == '\\' -> out.append("\\\\") - c == '\n' -> out.append("\\n") - c == '\r' -> out.append("\\r") - c == '\t' -> out.append("\\t") - c < ' ' -> out.append(String.format("\\u%04x", c.code)) - else -> out.append(c) - } - } - return out.append('"').toString() - } -} diff --git a/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt b/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt deleted file mode 100644 index 307993bc3796..000000000000 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/PushTapDataTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.keybase.ossifrage - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Test - -class PushTapDataTest { - // A chat push's data fields, as the FCM Bundle carries them. - private fun chatPush(convID: String, messageID: String) = - mapOf( - "type" to "chat.newmessage", - "convID" to convID, - "uid" to "u1", - "d" to messageID, - "m" to "encrypted payload", - "t" to "1", - "badge" to "3" - ) - - private fun fieldsOf(push: Map) = PushTapData.tapFields { push[it] } - - private fun dataFor(push: Map) = PushTapData.tapIntentData(fieldsOf(push)) - - // W7: buildPendingIntent reused one PendingIntent per second, and extras are not part of - // filterEquals, so two notifications built together shared one and the second one's tap - // opened the first one's conversation. The payload rides in the Intent's data instead. - @Test - fun twoNotificationsInTheSameSecondGetDistinctTapTargets() { - val first = dataFor(chatPush("conv-a", "1")) - val second = dataFor(chatPush("conv-b", "2")) - - assertNotEquals(first, second) - assertEquals("""{"type":"chat.newmessage","convID":"conv-a","uid":"u1"}""", PushTapData.decode(first)) - assertEquals("""{"type":"chat.newmessage","convID":"conv-b","uid":"u1"}""", PushTapData.decode(second)) - } - - // Deliberate: both notifications open the same conversation, so sharing a PendingIntent is - // correct. Only the target has to be distinct, not the message. - @Test - fun twoMessagesInOneConversationShareOneTapTarget() { - assertEquals(dataFor(chatPush("conv-a", "1")), dataFor(chatPush("conv-a", "2"))) - } - - @Test - fun onlyTheFieldsATapNeedsAreEncoded() { - assertEquals( - mapOf("type" to "chat.newmessage", "convID" to "conv-a", "uid" to "u1"), - fieldsOf(chatPush("conv-a", "1")) - ) - assertEquals( - mapOf("type" to "follow", "targetUID" to "u2", "username" to "testuser"), - PushTapData.tapFields( - mapOf("type" to "follow", "targetUID" to "u2", "username" to "testuser", "message" to "x")::get - ) - ) - } - - // pushTapTarget only tests the prefix, so the contact's name never reaches the data URI. - @Test - fun aContactMessageIsTruncatedToItsPrefix() { - val fields = PushTapData.tapFields(mapOf("message" to "Your contact testuser joined Keybase")::get) - - assertEquals(mapOf("message" to "Your contact"), fields) - } - - @Test - fun anEmptyFieldIsLeftOut() { - assertEquals( - mapOf("type" to "device.new"), - PushTapData.tapFields(mapOf("type" to "device.new", "uid" to "", "username" to "")::get) - ) - } - - @Test - fun aPayloadRoundTripsThroughTheDataUri() { - val fields = mapOf("type" to "follow", "username" to """a"b\c ü+%/&""") - - assertEquals( - """{"type":"follow","username":"a\"b\\c ü+%/&"}""", - PushTapData.decode(PushTapData.tapIntentData(fields)) - ) - } - - @Test - fun aDataUriFromAnywhereElseDecodesToNothing() { - assertEquals("", PushTapData.decode(null)) - assertEquals("", PushTapData.decode("keybase://convid/0000ab")) - assertEquals("", PushTapData.decode("")) - } -} diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index fe4e529f75af..366fe69c19b0 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -159,7 +159,8 @@ const loadStartupDetails = async () => { routeState = config?.ui?.routeState2 ?? '' } catch {} - // A tapped push doesn't pass through here: subscribePushTaps queues it as a navigation intent. + // 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') diff --git a/shared/constants/init/push-listener.native.test.ts b/shared/constants/init/push-listener.native.test.ts deleted file mode 100644 index ec798ea7a495..000000000000 --- a/shared/constants/init/push-listener.native.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/// -let mockSlot = '' -const mockCalls = new Array() -let mockFire: (() => void) | undefined - -jest.mock('react-native-kb', () => ({ - onPushTap: (cb: () => void) => { - mockCalls.push('onPushTap') - mockFire = cb - return { - remove: () => { - mockCalls.push('remove') - mockFire = undefined - }, - } - }, - takePushTap: () => { - mockCalls.push('takePushTap') - const payload = mockSlot - mockSlot = '' - return payload - }, -})) - -import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {subscribePushTaps} from './push-listener.native' - -const chatTap = '{"type":"chat.newmessage","convID":"0000ab","uid":"uid-other"}' - -beforeEach(() => { - mockSlot = '' - mockCalls.length = 0 - mockFire = undefined -}) - -afterEach(() => { - const {intent, dispatch} = useNavigationIntentsState.getState() - if (intent) dispatch.acknowledge(intent.id) - useNavigationIntentsState.setState({lastHandledIntent: undefined}) -}) - -test('subscribes before it takes the startup tap', () => { - const unsub = subscribePushTaps() - - expect(mockCalls).toEqual(['onPushTap', 'takePushTap']) - - unsub() -}) - -test('a startup tap queues a tap intent', () => { - mockSlot = chatTap - const unsub = subscribePushTaps() - - expect(useNavigationIntentsState.getState().intent).toMatchObject({ - targetUid: 'uid-other', - url: 'keybase://convid/0000ab', - }) - - unsub() -}) - -test('a tap that arrives later is taken on its event', () => { - const unsub = subscribePushTaps() - expect(useNavigationIntentsState.getState().intent).toBeUndefined() - - mockSlot = '{"type":"device.new","uid":"uid-other"}' - mockFire?.() - - expect(useNavigationIntentsState.getState().intent).toMatchObject({ - targetUid: 'uid-other', - url: 'keybase://devices', - }) - - unsub() -}) - -test('no tap queues nothing', () => { - const unsub = subscribePushTaps() - - expect(useNavigationIntentsState.getState().intent).toBeUndefined() - - unsub() -}) - -test('unsubscribing removes the listener', () => { - const unsub = subscribePushTaps() - unsub() - - expect(mockCalls.at(-1)).toBe('remove') -}) diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index a4fab529c7ea..8905316889fb 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -1,36 +1,20 @@ import * as T from '@/constants/types' import {ignorePromise} from '@/constants/utils' import logger from '@/logger' -import {emitDeepLink, enqueuePushTap} from '@/router-v2/deep-link-emitter' +import {emitDeepLink} from '@/router-v2/deep-link-emitter' import {subscribeIntentAccountSwitch} from '@/router-v2/account-link-switch' import { getRegistrationToken, setApplicationIconBadgeNumber, - onPushTap, onPushToken, onShareData, removeAllPendingNotificationRequests, - takePushTap, } from 'react-native-kb' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {usePushState} from '@/stores/push' import {useShellState} from '@/stores/shell' -// Native keeps a tapped notification's payload in a slot until it is taken. Subscribe first, then -// take: a tap from before the subscription is read now, a later one on its event, and the slot's -// clear-on-read keeps one tap from being taken twice. -export const subscribePushTaps = () => { - const take = () => { - const payload = takePushTap() - if (!payload) return - enqueuePushTap(payload) - } - const sub = onPushTap(take) - take() - return () => sub.remove() -} - export const initPushListener = () => { const unsubs: Array<() => void> = [] // Permissions @@ -81,8 +65,9 @@ export const initPushListener = () => { usePushState.getState().dispatch.initialPermissionsCheck() - // The switch subscriber goes first, so a tap taken right below already sees it. - unsubs.push(subscribeIntentAccountSwitch(), subscribePushTaps()) + // 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()) try { // Token and share listeners diff --git a/shared/constants/init/push-tap.test.ts b/shared/constants/init/push-tap.test.ts new file mode 100644 index 000000000000..2c1b7f45b9f4 --- /dev/null +++ b/shared/constants/init/push-tap.test.ts @@ -0,0 +1,145 @@ +/// +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} + +const chatRoute: T.RPCGen.PushTapRoute = {targetUID: 'uid-other', url: 'keybase://convid/0000ab'} + +const nudge = () => + _onEngineIncoming({ + payload: {params: undefined}, + type: 'keybase.1.NotifyApp.pushTapRouteAvailable', + } as never) + +const spyOnTake = (...routes: Array) => { + const spy = jest.spyOn(T.RPCGen, 'appStateTakePushTapRouteRpcPromise') + for (const route of routes) { + spy.mockResolvedValueOnce(route) + } + return spy.mockResolvedValue(null) +} + +const settle = async () => new Promise(resolve => setImmediate(resolve)) + +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 + jest.restoreAllMocks() + useConfigState.setState({dispatch: originalConfigDispatch}) + const {intent, dispatch} = useNavigationIntentsState.getState() + if (intent) dispatch.acknowledge(intent.id) + resetAllStores() +}) + +test('the nudge takes the route and queues it as a tap', async () => { + const take = spyOnTake(chatRoute) + + nudge() + await settle() + + expect(take).toHaveBeenCalledTimes(1) + 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 take = spyOnTake(chatRoute) + + onEngineConnected() + await settle() + + expect(take).toHaveBeenCalledTimes(1) + expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') +}) + +// The nudge carries nothing on purpose: acting on it rather than on what the take returns would +// be a second delivery path, and the pair could then hand out one tap twice. +test('a second nudge for a tap already taken queues nothing more', async () => { + const take = spyOnTake(chatRoute, null) + + nudge() + await settle() + const first = useNavigationIntentsState.getState().intent + nudge() + await settle() + + expect(take).toHaveBeenCalledTimes(2) + expect(useNavigationIntentsState.getState().intent).toBe(first) +}) + +test('no waiting tap queues nothing', async () => { + spyOnTake(null) + + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +test('a route with no account is not a targeted intent', async () => { + spyOnTake({targetUID: '', url: 'keybase://tabs.peopleTab'}) + + nudge() + await settle() + + const {intent} = useNavigationIntentsState.getState() + expect(intent?.url).toBe('keybase://tabs.peopleTab') + expect(intent?.targetUid).toBeUndefined() +}) + +test('a failed take queues nothing and does not throw', async () => { + jest + .spyOn(T.RPCGen, 'appStateTakePushTapRouteRpcPromise') + .mockRejectedValue(new Error('disconnected')) + + nudge() + await settle() + + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +test('desktop never asks for a tap', async () => { + g.isMobile = false + const take = spyOnTake(chatRoute) + + nudge() + await settle() + + expect(take).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 24928ff368db..be3591796d8d 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' @@ -287,6 +288,26 @@ export const applyMobileAppState = (state?: T.RPCGen.MobileAppState, version?: T } } +// A tapped notification's route waits in the service until it is taken, and the take clears it. +// That is the whole of the exactly-once property: a tap survives a client that is not running +// yet (on iOS, a background launch never starts one at all), and a reconnect or a reload finds +// nothing left to act on again. Taken here on connect for a tap from before this connection, and +// on pushTapRouteAvailable for one during it -- one taker either way, so neither path can hand +// out a tap the other already did. +const takePushTapRoute = async () => { + if (!isMobile) { + return + } + try { + const route = await T.RPCGen.appStateTakePushTapRouteRpcPromise() + if (route) { + enqueuePushTapRoute(route) + } + } catch (error) { + logger.warn('[PushTap] failed to take a tap route: ', error) + } +} + // The reply to setNotifications: the state as of the moment this connection subscribed, so there // is no read to order against the subscription. An old service returns nothing here and the // bootstrap status keeps that job -- see applyUnversionedStatusSession. @@ -421,6 +442,7 @@ export const onEngineConnected = () => { } // a new connection has told us nothing yet; the reply is what settles it useConfigState.getState().dispatch.setSessionIsUnversioned(false) + ignorePromise(takePushTapRoute()) // startHandshake first so this connection has its generation before the subscribe goes out. // Nothing orders the two RPCs any more: the subscription reply is what carries the session and // the http address, so the bootstrap read has nothing left to race with. @@ -478,6 +500,9 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { } switch (action.type) { + case 'keybase.1.NotifyApp.pushTapRouteAvailable': + ignorePromise(takePushTapRoute()) + break case 'keybase.1.NotifyApp.mobileAppStateChanged': { const {state, version} = action.payload.params applyMobileAppState(state, version) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 292e90f831d8..61f4df729873 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -356,16 +356,17 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } } - // The only way a tap reaches JS. 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 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 let payload = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) if JSONSerialization.isValidJSONObject(payload), let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) { - KbDeliverPushTap(json) + Keybasego.KeybaseDeliverPushTap(json) } else { log.error("Dropped a notification tap: its payload could not be serialized") } diff --git a/shared/router-v2/account-link-switch.test.ts b/shared/router-v2/account-link-switch.test.ts index cfd2b598f5f1..1c346c698901 100644 --- a/shared/router-v2/account-link-switch.test.ts +++ b/shared/router-v2/account-link-switch.test.ts @@ -2,7 +2,7 @@ import RPCError from '@/util/rpcerror' import {resetAllStores} from '@/util/zustand' import {subscribeIntentAccountSwitch} from './account-link-switch' -import {enqueuePushTap, emitDeepLink} from './deep-link-emitter' +import {enqueuePushTapRoute, emitDeepLink} from './deep-link-emitter' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' @@ -13,7 +13,8 @@ const otherAccount = {hasStoredSecret: true, uid: 'uid-other', username: 'testus const noSecretAccount = {hasStoredSecret: false, uid: 'uid-nosecret', username: 'testuser-nosecret'} const allAccounts = [currentAccount, otherAccount, noSecretAccount] -const tapFor = (uid: string) => enqueuePushTap(`{"type":"chat.newmessage","convID":"0000ab","uid":"${uid}"}`) +const tapFor = (uid: string) => + enqueuePushTapRoute({targetUID: uid, url: 'keybase://convid/0000ab'}) let login = jest.fn() let unsub: (() => void) | undefined diff --git a/shared/router-v2/account-link-switch.tsx b/shared/router-v2/account-link-switch.tsx index b982889a8578..9993d767b82e 100644 --- a/shared/router-v2/account-link-switch.tsx +++ b/shared/router-v2/account-link-switch.tsx @@ -13,8 +13,9 @@ const tapForOtherAccount = () => { // 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 enqueuePushTap sets targetUid, so no link -// another app opens can switch accounts. +// 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. 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 diff --git a/shared/router-v2/deep-link-emitter.test.ts b/shared/router-v2/deep-link-emitter.test.ts index ea3bc56cbdbc..486961befd43 100644 --- a/shared/router-v2/deep-link-emitter.test.ts +++ b/shared/router-v2/deep-link-emitter.test.ts @@ -1,6 +1,6 @@ /// import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {emitDeepLink, enqueuePushTap, pushTapTarget, setInitialURLOnce} from './deep-link-emitter' +import {emitDeepLink, enqueuePushTapRoute, setInitialURLOnce} from './deep-link-emitter' const resetNavigationIntents = () => { const {intent, dispatch} = useNavigationIntentsState.getState() @@ -55,63 +55,6 @@ test('removes a queued deep link when the initial URL handles it', () => { expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) -describe('pushTapTarget', () => { - const cases: Array<[string, string, ReturnType]> = [ - [ - 'chat with account', - '{"type":"chat.newmessage","convID":"0000ab","uid":"u1"}', - {targetUid: 'u1', url: 'keybase://convid/0000ab'}, - ], - [ - 'chat without account', - '{"type":"chat.newmessage","convID":"0000ab"}', - {url: 'keybase://convid/0000ab'}, - ], - ['chat without conversation', '{"type":"chat.newmessage"}', undefined], - [ - 'apns chat with numbers and aps', - '{"type":"chat.newmessage","convID":"0000ab","uid":"u1","t":1,"aps":{"alert":{"body":"hi"}}}', - {targetUid: 'u1', url: 'keybase://convid/0000ab'}, - ], - [ - 'a numeric convID becomes a string', - '{"type":"chat.newmessage","convID":1234}', - {url: 'keybase://convid/1234'}, - ], - [ - 'the uid is kept verbatim', - '{"type":"chat.newmessage","convID":"0000ab","uid":"u 1&x"}', - {targetUid: 'u 1&x', url: 'keybase://convid/0000ab'}, - ], - [ - 'follow with uid', - '{"type":"follow","username":"testuser","uid":"u1"}', - {targetUid: 'u1', url: 'keybase://profile/show/testuser'}, - ], - [ - 'follow with targetUID', - '{"type":"follow","username":"testuser","targetUID":"u2"}', - {targetUid: 'u2', url: 'keybase://profile/show/testuser'}, - ], - ['follow without username', '{"type":"follow","uid":"u1"}', undefined], - ['new device', '{"type":"device.new","uid":"u1","device_id":"d1"}', {targetUid: 'u1', url: 'keybase://devices'}], - ['revoked device without account', '{"type":"device.revoked","device_id":"d1"}', undefined], - ['contacts joined', '{"message":"Your contact testuser joined Keybase"}', {url: 'keybase://tabs.peopleTab'}], - ['read receipt', '{"type":"chat.readmessage","b":0,"message":"Your contact x"}', undefined], - ['silent chat', '{"type":"chat.newmessageSilent_2","c":"0000ab"}', undefined], - ['extension', '{"type":"chat.extension","convID":"0000ab"}', undefined], - ['autoreset', '{"type":"autoreset","uid":"u1"}', undefined], - ['failed pending', '{"type":"chat.failedpending","convID":"0000ab","uid":""}', undefined], - ['an unknown type opens nothing', '{"type":"something.new","uid":"u1"}', undefined], - ['not json', 'not json', undefined], - ['json that is not an object', '"just a string"', undefined], - ] - - test.each(cases)('%s', (_name, payload, want) => { - expect(pushTapTarget(payload)).toEqual(want) - }) -}) - test('a foreign link never targets an account', () => { emitDeepLink('keybase://convid/0000ab') @@ -121,7 +64,7 @@ test('a foreign link never targets an account', () => { }) test('a tap targets its account', () => { - enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"uid-other"}') + enqueuePushTapRoute({targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) const {intent} = useNavigationIntentsState.getState() expect(intent?.url).toBe('keybase://convid/0000ab') @@ -130,13 +73,17 @@ test('a tap targets its account', () => { test('a tap for a link a foreign open already queued upgrades that intent', () => { emitDeepLink('keybase://convid/0000ab') - enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"uid-other"}') + enqueuePushTapRoute({targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) expect(useNavigationIntentsState.getState().intent?.targetUid).toBe('uid-other') }) -test('a tap with nothing to open queues nothing', () => { - enqueuePushTap('{"type":"autoreset","uid":"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({targetUID: '', url: 'keybase://tabs.peopleTab'}) - expect(useNavigationIntentsState.getState().intent).toBeUndefined() + 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 5708ca5d75ca..21ca92ec286a 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -66,7 +66,7 @@ export const setInitialURLOnce = (url: string) => { // the intended account is active and its NavigationContainer is ready. // // A link here can come from any app, web page or typed URL, so it never carries -// a targetUid: only enqueuePushTap may target (and so switch) an account. +// a targetUid: only enqueuePushTapRoute may target (and so switch) an account. export const emitDeepLink = (url: string) => { const normalized = normalizeUrl(url) if (!normalized) return @@ -75,65 +75,13 @@ export const emitDeepLink = (url: string) => { // ---- Notification taps ---- -// Where a tap on a notification with this payload opens, or undefined when the -// tap only opens the app. `targetUid` names the account the notification is for. -export const pushTapTarget = (payload: string): {url: string; targetUid?: string} | undefined => { - let parsed: unknown - try { - parsed = JSON.parse(payload) - } catch { - return undefined - } - if (typeof parsed !== 'object' || parsed === null) return undefined - const fields = parsed as Record - const get = (key: string): string => { - const value = fields[key] - if (typeof value === 'string') return value - if (typeof value === 'number') return String(value) - return '' - } - const forAccount = (url: string, uid: string) => (uid ? {targetUid: uid, url} : {url}) - - switch (get('type')) { - case 'chat.newmessage': { - const convID = get('convID') - return convID ? forAccount(`keybase://convid/${encodeURIComponent(convID)}`, get('uid')) : undefined - } - case 'follow': { - const username = get('username') - return username - ? forAccount( - `keybase://profile/show/${encodeURIComponent(username)}`, - get('uid') || get('targetUID') - ) - : undefined - } - case 'device.new': - case 'device.revoked': { - const uid = get('uid') - return uid ? forAccount('keybase://devices', uid) : undefined - } - // Nothing to open: these are handled natively and in Go. - case 'chat.readmessage': - case 'chat.newmessageSilent_2': - case 'autoreset': - case 'chat.extension': - case 'chat.failedpending': - return undefined - default: - return get('message').startsWith('Your contact') ? {url: 'keybase://tabs.peopleTab'} : undefined - } -} - -// For payloads from native's notification-tap channel only (see -// constants/init/push-listener.native). A targetUid marks an intent as a tap, -// and nothing else can set one, so no link another app opens can switch accounts. -export const enqueuePushTap = (payload: string) => { - const target = pushTapTarget(payload) - if (!target) { - logger.info('[PushTap] took a tap with nothing to open') - return - } - logger.info('[PushTap] took a tap link:', target.url) - useNavigationIntentsState.getState().dispatch.enqueue(target.url, {targetUid: target.targetUid}) +// For routes taken 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. +export const enqueuePushTapRoute = (route: {url: string; targetUID: string}) => { + logger.info('[PushTap] took a tap link:', route.url) + useNavigationIntentsState + .getState() + .dispatch.enqueue(route.url, {targetUid: route.targetUID || undefined}) } diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index aabc92f8b28d..ba2f7f122fda 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -3,7 +3,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' import {resetAllStores} from '@/util/zustand' -import {emitDeepLink, enqueuePushTap} from './deep-link-emitter' +import {emitDeepLink, enqueuePushTapRoute} from './deep-link-emitter' import {subscribeNavigationIntents} from './linking' const setCurrentUser = (uid: string) => { @@ -139,7 +139,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) - enqueuePushTap('{"type":"chat.newmessage","convID":"switch-target-conversation","uid":"target-uid"}') + enqueuePushTapRoute({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 8e7ebc291bba..c307db7fbecc 100644 --- a/shared/router-v2/linking-initial-url.test.ts +++ b/shared/router-v2/linking-initial-url.test.ts @@ -7,7 +7,7 @@ import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' import {usePushState} from '@/stores/push' import {createLinkingConfig} from './linking' -import {enqueuePushTap} from './deep-link-emitter' +import {enqueuePushTapRoute} from './deep-link-emitter' const setCurrentUser = (uid: string) => { useCurrentUserState.getState().dispatch.setBootstrap({ @@ -97,7 +97,7 @@ test('a conversation persisted by this account is kept', async () => { test('a cold tap for the current account is the startup route, ahead of saved state', async () => { setStartup({conversation: 'conv-1'}) - enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"current-uid"}') + enqueuePushTapRoute({targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) await expect(getInitialURL()).resolves.toBe('keybase://convid/0000ab') expect(useNavigationIntentsState.getState().intent).toBeUndefined() @@ -105,7 +105,7 @@ test('a cold tap for the current account is the startup route, ahead of saved st test('a cold tap for another account opens saved state and waits for the switch', async () => { setStartup({conversation: 'conv-1'}) - enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"other-uid"}') + enqueuePushTapRoute({targetUID: 'other-uid', url: 'keybase://convid/0000ab'}) await expect(getInitialURL()).resolves.toBe('keybase://convid/conv-1') expect(useNavigationIntentsState.getState().intent?.targetUid).toBe('other-uid') @@ -166,7 +166,7 @@ test('the returned initial url is recorded so the same deep link is not re-enque test('a queued tap older than the intent lifetime is not the startup route', async () => { setStartup({conversation: 'conv-1'}) - enqueuePushTap('{"type":"chat.newmessage","convID":"0000ab","uid":"current-uid"}') + enqueuePushTapRoute({targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) const intent = useNavigationIntentsState.getState().intent useNavigationIntentsState.setState({intent: {...intent!, createdAt: Date.now() - 6 * 60_000}}) diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts index d2bfcf67534b..bba077d87da1 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -2,7 +2,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {emitDeepLink, enqueuePushTap} from './deep-link-emitter' +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' @@ -66,7 +66,7 @@ test('waits until the intended account is active', () => { const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - enqueuePushTap('{"type":"chat.newmessage","convID":"target-account-conversation","uid":"target-uid"}') + enqueuePushTapRoute({targetUID: 'target-uid', url: 'keybase://convid/target-account-conversation'}) expect(listener).not.toHaveBeenCalled() setCurrentUser('target-uid') @@ -86,7 +86,7 @@ test('waits for an account switch to finish', () => { const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - enqueuePushTap('{"type":"chat.newmessage","convID":"account-switch-conversation","uid":"current-uid"}') + enqueuePushTapRoute({targetUID: 'current-uid', url: 'keybase://convid/account-switch-conversation'}) expect(listener).not.toHaveBeenCalled() useConfigState.getState().dispatch.setUserSwitching(false) @@ -102,7 +102,7 @@ test('waits for the replacement router after the current account changes', () => const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - enqueuePushTap('{"type":"chat.newmessage","convID":"replacement-router-conversation","uid":"target-uid"}') + enqueuePushTapRoute({targetUID: 'target-uid', url: 'keybase://convid/replacement-router-conversation'}) setCurrentUser('target-uid') // The bootstrap UID can change before React commits the keyed router remount. From 0ffc8d8f9c92d1042705d4131f92ffe7e4f080c4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:22:57 -0400 Subject: [PATCH 086/127] fix(push): retire a tap when the client says it acted, not when it is read takePushTapRoute cleared the holder as it built the reply, so a reply lost on the way out took the tap with it. That is not a narrow window: the call fires from the client's engine-connect, which on a cold launch is the youngest socket in the process, and the only trace left behind was a warning in a ring buffer that never reaches a log file. The user would open on the wrong screen and nothing anywhere would say why. The read no longer clears. peekPushTapRoute reports what is armed; ackPushTapRoute(id) retires it, and the client sends it after queuing the route, so a failure anywhere before that leaves the tap armed for the next peek. Each route carries an id, so an ack crossing a newer tap retires nothing -- the user tapped again and that tap has not been acted on. The trade is now "a repeat rather than a loss", and the repeat is bounded three ways: the ack, an id the client remembers for the life of the module (so a lost ack re-sends the ack instead of navigating again), and the intent store's own duplicate window. What is left is a second navigation if the client restarts with a route still armed, which is the right failure to have. Also moves Android's setupKBRuntime + deliverPushTap off the main thread and starts MainActivity at once. Init is a slow path here (leveldb, keychain) and PushTapActivity is Theme.NoDisplay, so it must finish before onResume; ChatBroadcastReceiver already does this setup on a thread of its own. Nothing races: a delivery landing after the client connected is picked up by the nudge, one landing before it by the connect-time peek. --- go/libkb/notify_router.go | 7 +- go/libkb/pushtap.go | 43 ++++-- go/libkb/pushtap_test.go | 57 +++++-- go/protocol/keybase1/appstate.go | 42 ++++- go/service/appstate.go | 24 ++- go/service/appstate_test.go | 59 +++++-- protocol/avdl/keybase1/appstate.avdl | 19 ++- protocol/avdl/keybase1/notify_app.avdl | 12 +- protocol/bin/enabled-calls.json | 3 +- protocol/json/keybase1/appstate.json | 15 +- .../io/keybase/ossifrage/KBPushNotifier.kt | 5 + .../io/keybase/ossifrage/PushTapActivity.kt | 22 ++- shared/constants/init/push-tap.test.ts | 145 ++++++++++++++---- shared/constants/init/shared.tsx | 32 ++-- shared/constants/rpc/rpc-gen.tsx | 17 +- 15 files changed, 379 insertions(+), 123 deletions(-) diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index ded6cf3895d1..ece5ae597aee 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -2862,9 +2862,10 @@ func (n *NotifyRouter) HandleMobileAppState(ctx context.Context, state keybase1. } // HandlePushTapRouteAvailable nudges clients that a notification tap resolved -// to a route. It carries nothing: the route rides takePushTapRoute's reply, so -// the taker is the same one whether the tap happened before a client existed or -// while it was connected, and a tap can be handed out only once. +// 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 diff --git a/go/libkb/pushtap.go b/go/libkb/pushtap.go index e36b2d7286b7..50b816564f16 100644 --- a/go/libkb/pushtap.go +++ b/go/libkb/pushtap.go @@ -10,41 +10,60 @@ import ( ) // PendingPushTap holds the route a tapped notification resolved to until a -// client takes it. +// 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. Take is the only reader -// and it clears, so a client that reconnects, or a fresh one after a reload, -// finds nothing left to act on a second time. +// 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 + route *keybase1.PushTapRoute + lastID int } func NewPendingPushTap(g *GlobalContext) *PendingPushTap { return &PendingPushTap{Contextified: NewContextified(g)} } -// Set stores the route a tap resolved to and nudges connected clients. A tap -// that has not been taken yet is replaced: the newest tap is the one the user -// just made, and queueing them would navigate through a backlog. +// 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. 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) } -// Take returns the waiting route and clears it, or nil when no tap is waiting. -func (p *PendingPushTap) Take() *keybase1.PushTapRoute { +// 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. +func (p *PendingPushTap) Peek() *keybase1.PushTapRoute { + p.Lock() + defer p.Unlock() + return p.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() - route := p.route + if p.route == nil || p.route.Id != id { + return false + } p.route = nil - return route + return true } // pushTapNoRouteTypes are the push types a tap never opens anything for: they diff --git a/go/libkb/pushtap_test.go b/go/libkb/pushtap_test.go index 398a0338b67c..857a005c926e 100644 --- a/go/libkb/pushtap_test.go +++ b/go/libkb/pushtap_test.go @@ -106,24 +106,53 @@ func TestEncodeURIComponent(t *testing.T) { require.Empty(t, encodeURIComponent("")) } -func TestPendingPushTapTakeClears(t *testing.T) { +func TestPendingPushTapPeekIsNotDestructive(t *testing.T) { tc := SetupTest(t, "pushtap", 1) defer tc.Cleanup() g := tc.G + ctx := context.Background() - require.Nil(t, g.PendingPushTap.Take()) + require.Nil(t, g.PendingPushTap.Peek()) - first := keybase1.PushTapRoute{Url: "keybase://convid/0000ab", TargetUID: "u1"} - g.PendingPushTap.Set(context.Background(), first) - require.Equal(t, &first, g.PendingPushTap.Take()) - // A second taker gets nothing: this is what keeps a reconnect, or a fresh - // client after a reload, from acting on the same tap again. - require.Nil(t, g.PendingPushTap.Take()) + 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") - // An untaken tap is replaced rather than queued. - g.PendingPushTap.Set(context.Background(), first) - second := keybase1.PushTapRoute{Url: "keybase://devices", TargetUID: "u2"} - g.PendingPushTap.Set(context.Background(), second) - require.Equal(t, &second, g.PendingPushTap.Take()) - require.Nil(t, g.PendingPushTap.Take()) + // 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/protocol/keybase1/appstate.go b/go/protocol/keybase1/appstate.go index 7558ae1c85b2..ebf300c5986f 100644 --- a/go/protocol/keybase1/appstate.go +++ b/go/protocol/keybase1/appstate.go @@ -81,12 +81,14 @@ func (o MobileNetworkState) String() string { 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, } } @@ -94,7 +96,11 @@ type UpdateMobileNetStateArg struct { State string `codec:"state" json:"state"` } -type TakePushTapRouteArg struct { +type PeekPushTapRouteArg struct { +} + +type AckPushTapRouteArg struct { + Id int `codec:"id" json:"id"` } type PowerMonitorEventArg struct { @@ -103,7 +109,8 @@ type PowerMonitorEventArg struct { type AppStateInterface interface { UpdateMobileNetState(context.Context, string) error - TakePushTapRoute(context.Context) (*PushTapRoute, error) + PeekPushTapRoute(context.Context) (*PushTapRoute, error) + AckPushTapRoute(context.Context, int) error PowerMonitorEvent(context.Context, string) error } @@ -126,13 +133,28 @@ func AppStateProtocol(i AppStateInterface) rpc.Protocol { return }, }, - "takePushTapRoute": { + "peekPushTapRoute": { MakeArg: func() any { - var ret [1]TakePushTapRouteArg + var ret [1]PeekPushTapRouteArg return &ret }, Handler: func(ctx context.Context, args any) (ret any, err error) { - ret, err = i.TakePushTapRoute(ctx) + 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 }, }, @@ -165,8 +187,14 @@ func (c AppStateClient) UpdateMobileNetState(ctx context.Context, state string) return } -func (c AppStateClient) TakePushTapRoute(ctx context.Context) (res *PushTapRoute, err error) { - err = c.Cli.Call(ctx, "keybase.1.appState.takePushTapRoute", []any{TakePushTapRouteArg{}}, &res, 0*time.Millisecond) +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 } diff --git a/go/service/appstate.go b/go/service/appstate.go index b1ff0f506095..87ff14624d7a 100644 --- a/go/service/appstate.go +++ b/go/service/appstate.go @@ -45,16 +45,26 @@ func (a *appStateHandler) UpdateMobileNetState(ctx context.Context, stateStr str return nil } -// TakePushTapRoute hands over the route a tapped notification resolved to, and -// clears it. Deliberately not folded into setNotifications' snapshot: that -// reply goes to every subscriber, including kbfs inside this same process, and -// a destructive read there would let the wrong one consume the tap. -func (a *appStateHandler) TakePushTapRoute(ctx context.Context) (*keybase1.PushTapRoute, error) { - route := a.G().PendingPushTap.Take() - a.G().Log.CDebugf(ctx, "TakePushTapRoute: waiting tap: %v", route != 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 index a0d6c47d9944..bde0c1e409b7 100644 --- a/go/service/appstate_test.go +++ b/go/service/appstate_test.go @@ -9,10 +9,10 @@ import ( "github.com/stretchr/testify/require" ) -// The take is what makes a tap exactly-once: it is the only reader of the -// pending tap, and it clears. A client that reconnects, or a fresh one after a -// reload, gets nothing rather than the tap it already acted on. -func TestTakePushTapRouteClearsTheTap(t *testing.T) { +// 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 @@ -21,20 +21,52 @@ func TestTakePushTapRouteClearsTheTap(t *testing.T) { h := newAppStateHandler(nil, g) ctx := context.Background() - got, err := h.TakePushTapRoute(ctx) + got, err := h.PeekPushTapRoute(ctx) require.NoError(t, err) require.Nil(t, got, "no tap has happened") - route := keybase1.PushTapRoute{Url: "keybase://convid/0000ab", TargetUID: "u1"} - g.PendingPushTap.Set(ctx, route) + 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") - got, err = h.TakePushTapRoute(ctx) + require.NoError(t, h.AckPushTapRoute(ctx, first.Id)) + + got, err = h.PeekPushTapRoute(ctx) require.NoError(t, err) - require.Equal(t, &route, got) + 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)) - got, err = h.TakePushTapRoute(ctx) + survived, err := h.PeekPushTapRoute(ctx) require.NoError(t, err) - require.Nil(t, got, "the tap was already handed out") + require.NotNil(t, survived) + require.Equal(t, "keybase://devices", survived.Url) } // A tap must ride its own call and nothing else. setNotifications answers every @@ -55,7 +87,8 @@ func TestSetNotificationsLeavesTheTapAlone(t *testing.T) { _, err := n.SetNotifications(ctx, keybase1.NotificationChannels{App: true}) require.NoError(t, err) - got, err := newAppStateHandler(nil, g).TakePushTapRoute(ctx) + got, err := newAppStateHandler(nil, g).PeekPushTapRoute(ctx) require.NoError(t, err) - require.Equal(t, &route, got, "the subscribe did not consume the tap") + require.NotNil(t, got, "the subscribe did not consume the tap") + require.Equal(t, route.Url, got.Url) } diff --git a/protocol/avdl/keybase1/appstate.avdl b/protocol/avdl/keybase1/appstate.avdl index 8d6cb01974c3..d6062abad1af 100644 --- a/protocol/avdl/keybase1/appstate.avdl +++ b/protocol/avdl/keybase1/appstate.avdl @@ -26,6 +26,9 @@ protocol appState { // 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 @@ -34,10 +37,18 @@ protocol appState { // gui -> service // mobile only - // Returns the route a tapped notification resolved to and clears it, or null - // when no tap is waiting. Destructive on purpose: this is the only taker, so - // a tap is delivered exactly once however many times a client reconnects. - union { null, PushTapRoute } takePushTapRoute(); + // 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 diff --git a/protocol/avdl/keybase1/notify_app.avdl b/protocol/avdl/keybase1/notify_app.avdl index d7d8caeea11f..b72e663a5ed3 100644 --- a/protocol/avdl/keybase1/notify_app.avdl +++ b/protocol/avdl/keybase1/notify_app.avdl @@ -14,12 +14,12 @@ protocol NotifyApp { // arrive in either order. void mobileAppStateChanged(MobileAppState state, StateVersion version) oneway; - // A notification tap resolved to a route and it is waiting to be taken. A - // nudge, not a delivery: the route rides takePushTapRoute's reply, so the - // taker is the same one whether the tap happened before this client existed - // or while it was connected, and neither path can hand out the same tap - // twice. Carries nothing for that reason -- acting on this without taking - // would be a second delivery path. + // 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/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index 7f4892f5ded6..e3f572536b0b 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -253,7 +253,8 @@ "keybase.1.apiserver.Post": {"promise":true}, "keybase.1.apiserver.PostJSON": {"promise":true}, "keybase.1.appState.powerMonitorEvent": {"promise":true}, - "keybase.1.appState.takePushTapRoute": {"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}, diff --git a/protocol/json/keybase1/appstate.json b/protocol/json/keybase1/appstate.json index 67e02b6c9d58..94a212f08962 100644 --- a/protocol/json/keybase1/appstate.json +++ b/protocol/json/keybase1/appstate.json @@ -34,6 +34,10 @@ { "type": "string", "name": "targetUID" + }, + { + "type": "int", + "name": "id" } ] } @@ -48,13 +52,22 @@ ], "response": null }, - "takePushTapRoute": { + "peekPushTapRoute": { "request": [], "response": [ null, "PushTapRoute" ] }, + "ackPushTapRoute": { + "request": [ + { + "name": "id", + "type": "int" + } + ], + "response": null + }, "powerMonitorEvent": { "request": [ { 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 2507cd4702f8..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 @@ -43,6 +43,11 @@ class KBPushNotifier internal constructor(private val context: Context, private // 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))) 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 index 3409130f2741..eba6b737ead5 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt @@ -6,6 +6,7 @@ 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 @@ -14,18 +15,23 @@ import org.json.JSONObject class PushTapActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // A tap can be what starts this process, so the service may not be running yet. initOnce - // is the same call MainActivity makes below and runs at most once, so the cost is moved - // rather than added. - runCatching { - setupKBRuntime(this, false) - Keybase.deliverPushTap(payloadJSON(intent.extras)) - }.onFailure { NativeLogger.error("PushTapActivity: failed to deliver a tap", it) } + // 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) }.getOrDefault("{}") + 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) ) - // Theme.NoDisplay requires finishing before onResume. finish() } diff --git a/shared/constants/init/push-tap.test.ts b/shared/constants/init/push-tap.test.ts index 2c1b7f45b9f4..1b8571130273 100644 --- a/shared/constants/init/push-tap.test.ts +++ b/shared/constants/init/push-tap.test.ts @@ -7,7 +7,14 @@ import {onEngineConnected, _onEngineIncoming} from './shared' const g = globalThis as unknown as {isMobile: boolean} -const chatRoute: T.RPCGen.PushTapRoute = {targetUID: 'uid-other', url: 'keybase://convid/0000ab'} +// shared.tsx remembers the last id it queued 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({ @@ -15,12 +22,27 @@ const nudge = () => type: 'keybase.1.NotifyApp.pushTapRouteAvailable', } as never) -const spyOnTake = (...routes: Array) => { - const spy = jest.spyOn(T.RPCGen, 'appStateTakePushTapRouteRpcPromise') - for (const route of routes) { - spy.mockResolvedValueOnce(route) - } - return spy.mockResolvedValue(null) +// 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. +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)) @@ -63,13 +85,16 @@ afterEach(() => { resetAllStores() }) -test('the nudge takes the route and queues it as a tap', async () => { - const take = spyOnTake(chatRoute) +test('the nudge queues the armed route and acks it', async () => { + const route = chatRoute() + const service = serviceHolding(route) nudge() await settle() - expect(take).toHaveBeenCalledTimes(1) + expect(service.peek).toHaveBeenCalledTimes(1) + expect(service.ack).toHaveBeenCalledWith({id: route.id}) + expect(service.isArmed()).toBe(false) expect(useNavigationIntentsState.getState().intent).toMatchObject({ targetUid: 'uid-other', url: 'keybase://convid/0000ab', @@ -78,68 +103,124 @@ test('the nudge takes the route and queues it as a tap', async () => { test('a tap waiting from before this connection is taken on connect', async () => { stubConnect() - const take = spyOnTake(chatRoute) + const route = chatRoute() + const service = serviceHolding(route) onEngineConnected() await settle() - expect(take).toHaveBeenCalledTimes(1) + expect(service.peek).toHaveBeenCalledTimes(1) expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') + expect(service.isArmed()).toBe(false) }) -// The nudge carries nothing on purpose: acting on it rather than on what the take returns would -// be a second delivery path, and the pair could then hand out one tap twice. -test('a second nudge for a tap already taken queues nothing more', async () => { - const take = spyOnTake(chatRoute, null) +// The point of splitting peek from ack: 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() - const first = useNavigationIntentsState.getState().intent + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + expect(service.isArmed()).toBe(true) + + // the next connection picks it up nudge() await settle() - expect(take).toHaveBeenCalledTimes(2) - expect(useNavigationIntentsState.getState().intent).toBe(first) + expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') + expect(service.isArmed()).toBe(false) }) -test('no waiting tap queues nothing', async () => { - spyOnTake(null) +// 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 only while the intent is still queued or inside its 1.5s +// duplicate window; once the router has navigated and that window has passed, re-queueing would +// navigate a second time. So the id is remembered here too, and only the ack is retried. +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('a route with no account is not a targeted intent', async () => { - spyOnTake({targetUID: '', url: 'keybase://tabs.peopleTab'}) +// The nudge carries nothing on purpose: acting on it rather than on what the peek reports would +// be a second delivery path, and the pair could then act on one tap twice. +test('a second nudge after the ack queues nothing more', async () => { + const service = serviceHolding(chatRoute()) + nudge() + await settle() + const first = useNavigationIntentsState.getState().intent nudge() await settle() - const {intent} = useNavigationIntentsState.getState() - expect(intent?.url).toBe('keybase://tabs.peopleTab') - expect(intent?.targetUid).toBeUndefined() + expect(service.peek).toHaveBeenCalledTimes(2) + expect(useNavigationIntentsState.getState().intent).toBe(first) +}) + +test('a newer tap queued while the older one is still pending upgrades nothing away', async () => { + const service = serviceHolding(chatRoute()) + + 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: devices.id}) + expect(service.isArmed()).toBe(false) }) -test('a failed take queues nothing and does not throw', async () => { - jest - .spyOn(T.RPCGen, 'appStateTakePushTapRouteRpcPromise') - .mockRejectedValue(new Error('disconnected')) +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 take = spyOnTake(chatRoute) + const service = serviceHolding(chatRoute()) nudge() await settle() - expect(take).not.toHaveBeenCalled() + expect(service.peek).not.toHaveBeenCalled() expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index be3591796d8d..e8cc94f33dc6 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -288,23 +288,37 @@ export const applyMobileAppState = (state?: T.RPCGen.MobileAppState, version?: T } } -// A tapped notification's route waits in the service until it is taken, and the take clears it. -// That is the whole of the exactly-once property: a tap survives a client that is not running -// yet (on iOS, a background launch never starts one at all), and a reconnect or a reload finds -// nothing left to act on again. Taken here on connect for a tap from before this connection, and -// on pushTapRouteAvailable for one during it -- one taker either way, so neither path can hand -// out a tap the other already did. +// A tapped notification's route waits in the service until this says it has been acted on, which +// is what makes a tap exactly-once. Reading it does not retire it: the peek's reply can be lost on +// the way here, and losing it would lose the tap with nothing anywhere to say so -- the app would +// simply open on the wrong screen. So queue first, then ack, and a peek that never came back +// leaves the route armed for the next one. +// +// 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. +let enqueuedPushTapID = 0 const takePushTapRoute = async () => { if (!isMobile) { return } try { - const route = await T.RPCGen.appStateTakePushTapRouteRpcPromise() - if (route) { + const route = await T.RPCGen.appStatePeekPushTapRouteRpcPromise() + if (!route) { + return + } + // A repeat of a tap this run already queued means only that the ack did not land; re-queueing + // would navigate a second time, long after the intent store's own duplicate window has passed. + // A reload resets this, which is right: the intent store was reset with it. + if (route.id !== enqueuedPushTapID) { + enqueuedPushTapID = route.id enqueuePushTapRoute(route) } + await T.RPCGen.appStateAckPushTapRouteRpcPromise({id: route.id}) } catch (error) { - logger.warn('[PushTap] failed to take a tap route: ', error) + // Nothing is lost by failing here: the route is retired only by an ack that arrived. + logger.warn('[PushTap] failed to take a tap route, leaving it armed: ', error) } } diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index 176add83c98b..5094563d8f75 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -415,14 +415,18 @@ 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.powerMonitorEvent': { - inParam: {readonly event: string}, + 'keybase.1.appState.ackPushTapRoute': { + inParam: {readonly id: number}, outParam: void, }, - 'keybase.1.appState.takePushTapRoute': { + 'keybase.1.appState.peekPushTapRoute': { inParam: undefined, outParam: PushTapRoute | null, }, + 'keybase.1.appState.powerMonitorEvent': { + inParam: {readonly event: string}, + outParam: void, + }, 'keybase.1.appState.updateMobileNetState': { inParam: {readonly state: string}, outParam: void, @@ -1284,7 +1288,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.takePushTapRoute' | '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' +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> @@ -2851,7 +2855,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,} +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,} @@ -3201,8 +3205,9 @@ 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 appStateTakePushTapRouteRpcPromise = createRpc('keybase.1.appState.takePushTapRoute') export const appStateUpdateMobileNetStateRpcPromise = createRpc('keybase.1.appState.updateMobileNetState') export const configAppendGUILogsRpcPromise = createRpc('keybase.1.config.appendGUILogs') export const configGenerateWebAuthTokenRpcPromise = createRpc('keybase.1.config.generateWebAuthToken') From 4a42951511f4a7c11bc5756f8c1f31402262b20c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:36:47 -0400 Subject: [PATCH 087/127] fix(push): mark a tap queued only once the queue has taken it The id that suppresses a re-queue after a lost ack was recorded before the enqueue it guards. An enqueue that threw would then leave the route armed and marked as already queued, so the next peek would skip the queue and ack anyway -- retiring a tap that never reached the router. That is the silent loss the peek/ack split exists to remove, reintroduced by the guard meant to finish it. Both statements are synchronous and both still run before the ack's await, so two peeks in flight are ordered exactly as they were; only the order of the two lines changes. Also: PushTapActivity logs a payload it could not read instead of silently substituting an empty one; Peek hands back a copy rather than the holder's own route; the client-side function is drainPushTapRoute, since peek-queue-ack is not a take; and the invariant that ids only mean anything within one service process now sits next to the guard that relies on it. --- go/libkb/pushtap.go | 12 +++++- .../io/keybase/ossifrage/PushTapActivity.kt | 7 ++- shared/constants/init/push-tap.test.ts | 43 +++++++++++++++++++ shared/constants/init/shared.tsx | 29 ++++++++----- shared/router-v2/deep-link-emitter.tsx | 4 +- 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/go/libkb/pushtap.go b/go/libkb/pushtap.go index 50b816564f16..e44993fe9d70 100644 --- a/go/libkb/pushtap.go +++ b/go/libkb/pushtap.go @@ -36,6 +36,9 @@ func NewPendingPushTap(g *GlobalContext) *PendingPushTap { // 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++ @@ -46,11 +49,16 @@ func (p *PendingPushTap) Set(ctx context.Context, route keybase1.PushTapRoute) { } // 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. +// 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() - return p.route + 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 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 index eba6b737ead5..806aef117b72 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/PushTapActivity.kt @@ -20,7 +20,12 @@ class PushTapActivity : Activity() { // 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) }.getOrDefault("{}") + 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 { diff --git a/shared/constants/init/push-tap.test.ts b/shared/constants/init/push-tap.test.ts index 1b8571130273..f4fc887818bb 100644 --- a/shared/constants/init/push-tap.test.ts +++ b/shared/constants/init/push-tap.test.ts @@ -47,6 +47,24 @@ const serviceHolding = (route?: T.RPCGen.PushTapRoute) => { 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 ack +// 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 @@ -163,6 +181,31 @@ test('a lost ack retries the ack without navigating again', async () => { expect(service.isArmed()).toBe(false) }) +// The id must be recorded only once the queue has taken the route. Recording it first would leave +// a throw here with the route armed AND marked as queued, so the next peek would skip the queue +// and ack anyway -- retiring a tap that never reached the router, which is the silent loss this +// whole split exists to prevent. +test('an enqueue that throws does not let the next peek retire the route', 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') + expect(service.ack).toHaveBeenCalledWith({id: route.id}) + expect(service.isArmed()).toBe(false) +}) + // The nudge carries nothing on purpose: acting on it rather than on what the peek reports would // be a second delivery path, and the pair could then act on one tap twice. test('a second nudge after the ack queues nothing more', async () => { diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index e8cc94f33dc6..d356b26339e2 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -288,18 +288,22 @@ export const applyMobileAppState = (state?: T.RPCGen.MobileAppState, version?: T } } -// A tapped notification's route waits in the service until this says it has been acted on, which -// is what makes a tap exactly-once. Reading it does not retire it: the peek's reply can be lost on -// the way here, and losing it would lose the tap with nothing anywhere to say so -- the app would -// simply open on the wrong screen. So queue first, then ack, and a peek that never came back -// leaves the route armed for the next one. +// Peek, queue, ack. A tapped notification's route waits in the service until the ack says it has +// been queued, which is what makes a tap exactly-once. Reading it does not retire it: the peek's +// reply can be lost on the way here, and losing it would lose the tap with nothing anywhere to say +// so -- the app would simply open on the wrong screen. So queue first, then ack, and a peek that +// never came back leaves the route armed for the next one. // // 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. +// Ids number the taps of one service process, and on mobile the service is this process, so an id +// means nothing across a restart of either side. That is why the sentinel is 0, which the service +// never assigns, and why this is module state rather than anything durable: it must be forgotten +// exactly when the ids it refers to stop meaning anything. let enqueuedPushTapID = 0 -const takePushTapRoute = async () => { +const drainPushTapRoute = async () => { if (!isMobile) { return } @@ -312,13 +316,18 @@ const takePushTapRoute = async () => { // would navigate a second time, long after the intent store's own duplicate window has passed. // A reload resets this, which is right: the intent store was reset with it. if (route.id !== enqueuedPushTapID) { - enqueuedPushTapID = route.id + // Recorded only once the queue actually took it. Recording first would mean a throw here + // left the route armed AND marked as queued, so the next peek would skip the queue and ack + // anyway -- retiring a tap that never reached the router, which is the loss this whole + // split exists to prevent. Both statements run before the await below, so two peeks in + // flight are still ordered by it. enqueuePushTapRoute(route) + enqueuedPushTapID = route.id } await T.RPCGen.appStateAckPushTapRouteRpcPromise({id: route.id}) } catch (error) { // Nothing is lost by failing here: the route is retired only by an ack that arrived. - logger.warn('[PushTap] failed to take a tap route, leaving it armed: ', error) + logger.warn('[PushTap] failed to drain a tap route, leaving it armed: ', error) } } @@ -456,7 +465,7 @@ export const onEngineConnected = () => { } // a new connection has told us nothing yet; the reply is what settles it useConfigState.getState().dispatch.setSessionIsUnversioned(false) - ignorePromise(takePushTapRoute()) + ignorePromise(drainPushTapRoute()) // startHandshake first so this connection has its generation before the subscribe goes out. // Nothing orders the two RPCs any more: the subscription reply is what carries the session and // the http address, so the bootstrap read has nothing left to race with. @@ -515,7 +524,7 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { switch (action.type) { case 'keybase.1.NotifyApp.pushTapRouteAvailable': - ignorePromise(takePushTapRoute()) + ignorePromise(drainPushTapRoute()) break case 'keybase.1.NotifyApp.mobileAppStateChanged': { const {state, version} = action.payload.params diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index 21ca92ec286a..6721c35aecbc 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -75,12 +75,12 @@ export const emitDeepLink = (url: string) => { // ---- Notification taps ---- -// For routes taken from the service's pending-tap holder only (see +// 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. export const enqueuePushTapRoute = (route: {url: string; targetUID: string}) => { - logger.info('[PushTap] took a tap link:', route.url) + logger.info('[PushTap] queued a tap link:', route.url) useNavigationIntentsState .getState() .dispatch.enqueue(route.url, {targetUid: route.targetUID || undefined}) From 863e40c18625210965406b953c0b38b04b73349a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:47:17 -0400 Subject: [PATCH 088/127] refactor(native): delete the dead engineReset verb Both implementations carried a "No current caller" comment and nothing in JS ever imported it. The bridge's own desync path still resets unconditionally; only the unused module entry point goes. --- go/bind/keybase.go | 2 +- go/bind/keybase_test.go | 4 ++-- .../src/main/java/com/reactnativekb/KbModule.kt | 12 ------------ rnmodules/react-native-kb/ios/Kb.mm | 15 --------------- rnmodules/react-native-kb/src/NativeKb.ts | 1 - rnmodules/react-native-kb/src/index.tsx | 3 --- 6 files changed, 3 insertions(+), 34 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 32f1bb510b42..0037fbdc39ee 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -749,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 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/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 799738a5b636..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 @@ -435,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") diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index a05a7fbbaa46..76231a5e19df 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -495,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 diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index 13f9f3728c06..dc12a7416c51 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -61,7 +61,6 @@ export interface Spec extends TurboModule { setApplicationIconBadgeNumber(n: number): void 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 348663a8922a..c5560f4960d6 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -151,9 +151,6 @@ export const onShareData = ( return Kb.onShareData(callback) } -export const engineReset = (): void => { - return Kb.engineReset() -} export const notifyJSReady = (): void => { return Kb.notifyJSReady() } From 04119d4588b2d1b033edfb560da4c6173d01c257 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:50:56 -0400 Subject: [PATCH 089/127] refactor(native): drop dead branches and fields in the push path The push service kept a buildStyle nobody called (KBPushNotifier has the live one), two empty notification-display branches plus an unreachable trailing one, an empty else on the follow branch, and two seenChatNotifications.add calls for a key line 90 already added. AppLifecycleReporter's started field was written and never meaningfully read: started implies reported, so reportHeadlessStart's !reported already covers it. AppDelegate builds the tap payload with uniquingKeysWith instead of uniqueKeysWithValues, which traps on a duplicate key. --- .../keybase/ossifrage/AppLifecycleReporter.kt | 5 +--- .../KeybasePushNotificationListenerService.kt | 30 ++----------------- shared/ios/Keybase/AppDelegate.swift | 4 ++- 3 files changed, 7 insertions(+), 32 deletions(-) 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 index 6960a319ebff..982d8f64dba4 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -43,11 +43,9 @@ internal class AppLifecycleReporter( private val log: (String) -> Unit, ) : DefaultLifecycleObserver { private var reported = false - private var started = false @Synchronized override fun onStart(owner: LifecycleOwner) { - started = true reported = true enqueue("uiInactive") { bind.uiInactive() } } @@ -59,7 +57,6 @@ internal class AppLifecycleReporter( @Synchronized override fun onStop(owner: LifecycleOwner) { - started = false reportBackground("process stop") } @@ -77,7 +74,7 @@ internal class AppLifecycleReporter( // nothing to end it; report the background, unless the UI got there first. @Synchronized fun reportHeadlessStart() { - if (!reported && !started) { + if (!reported) { reportBackground("started without UI") } } 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 24c4c21461be..6d218d2c0b1e 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 @@ -7,7 +7,6 @@ import android.os.Build import android.os.Bundle 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 @@ -26,17 +25,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { 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 - } - private val lifecycleReporter get() = (application as MainApplication).lifecycleReporter override fun onCreate() { @@ -128,9 +116,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { 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) @@ -158,14 +143,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() @@ -191,13 +171,10 @@ 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) { - } } @@ -207,7 +184,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { val m = bundle.getString("message") if (username != null && m != null) { notifier.followNotification(username, m) - } else { } } diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 61f4df729873..26b5065a74ad 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -362,7 +362,9 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // 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 - let payload = Dictionary(uniqueKeysWithValues: userInfo.map { (String(describing: $0.key), $0.value) }) + // 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) { From 13aeffe48abdb3fcff93ef7e6bbe39100432461e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:50:56 -0400 Subject: [PATCH 090/127] refactor(android): flush a share intent when JS asks, not on a poll tryHandleIntentWithRetry reposted handleIntent every 500ms for up to 10s and then gave up silently, but every path it was waiting on ends in JS calling shareListenersRegistered, which already re-ran the flush itself. The retry loop was therefore pure duplication of its own success condition; a share now parks in the activity until JS says it is ready to route one, which is the same park-and-drain shape the push tap uses. captureIntent no longer caches non-share intents, so a plain launch parks nothing. The file copy takes the activity's own Context, so the flush no longer waits on a live ReactContext either. Also drops the always-null permission listener and the dead isTestDevice (KbModule has the live copy). --- .../java/io/keybase/ossifrage/MainActivity.kt | 168 +++++++----------- 1 file changed, 63 insertions(+), 105 deletions(-) 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 d3976e6f190a..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,7 +10,6 @@ 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 @@ -18,10 +17,8 @@ import android.webkit.MimeTypeMap import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate 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 @@ -39,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() { @@ -74,8 +70,6 @@ class MainActivity : ReactActivity() { super.onCreate(null) KeybasePushNotificationListenerService.createNotificationChannel(this) updateIsUsingHardwareKeyboard() - - scheduleHandleIntent() } override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { @@ -84,11 +78,6 @@ 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() @@ -110,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 @@ -128,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) @@ -140,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 @@ -165,6 +154,9 @@ class MainActivity : ReactActivity() { (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 @@ -172,15 +164,16 @@ class MainActivity : ReactActivity() { private var pendingShareText: String? = null // 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 consume them until much - // later (see tryHandleIntentWithRetry). + // 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) + 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) { @@ -192,9 +185,11 @@ class MainActivity : ReactActivity() { 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 fun extractSharedUris(intent: Intent): List { @@ -228,87 +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}") - 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()) { + 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) + } + } 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) { @@ -404,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 { From 2c5eea6c0a07dffd1d2370a827c71d4953215845 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:53:15 -0400 Subject: [PATCH 091/127] fix(settings): keep a notification that lands during the settings load userLoadMySettings' reply was applied unconditionally, so an emailsChanged or phoneNumbersChanged notification arriving while the RPC was in flight was overwritten by the older list the RPC had already read. Apply each half only if the store still holds the value the RPC was read against. --- shared/settings/load-settings.tsx | 32 ++++++++++------------------ shared/stores/tests/settings.test.ts | 28 ++++++++++++++++++------ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/shared/settings/load-settings.tsx b/shared/settings/load-settings.tsx index 8770fd103a4a..f5461cd09179 100644 --- a/shared/settings/load-settings.tsx +++ b/shared/settings/load-settings.tsx @@ -1,40 +1,30 @@ -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 } + // An emailsChanged/phoneNumbersChanged notification can land while this RPC is in + // flight, and it carries the newer list. Apply the reply only to the value it was read + // against, the same rule the versioned session write follows. + 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() + 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/tests/settings.test.ts b/shared/stores/tests/settings.test.ts index ae699f7d314f..3714b88e838a 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,26 @@ 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 = [{phoneNumber: '+15550000000', superseded: false, verified: true, visibility: 0}] + const notified = [{phoneNumber: '+15551111111', superseded: false, verified: true, visibility: 0}] + const staleEmails = [{email: 'stale@example.com', isPrimary: true, isVerified: true, visibility: 0}] + const notifiedEmails = [{email: 'fresh@example.com', isPrimary: true, isVerified: true, visibility: 0}] + + useConfigState.setState({loggedIn: true}) + jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockImplementation((async () => { + // the notifications win the race: they carry the newer server state + useSettingsPhoneState.getState().dispatch.notifyPhoneNumberPhoneNumbersChanged(notified) + useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(notifiedEmails) + return {emails: staleEmails, phoneNumbers: stale} + }) as never) + + loadSettings() + await Promise.resolve() + await Promise.resolve() + + expect([...useSettingsPhoneState.getState().phones!.keys()]).toEqual(['+15551111111']) + expect([...useSettingsEmailState.getState().emails.keys()]).toEqual(['fresh@example.com']) + }) }) From b668e107f17850d984b67684981ecef22cf9babe Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:53:15 -0400 Subject: [PATCH 092/127] fix(links): route /phone-app through the linking config Nothing carved phone-app out of normalizeHttpUrl's single-segment username rule, so our own invite install link also resolved to a profile for a user that does not exist. It now normalizes to keybase://settingsAddPhone, which the linking config opens as a modal over the settings tab, and the "only when the user has no phone number yet" condition rides along in the normalizer. That was startup.link's only consumer, so the field, its two setStartupDetails sites and the bespoke once-per-process check in load-settings go with it. The launch-URL read stays: it still decides whether the saved route may be restored. --- shared/constants/init/index.tsx | 9 +++------ shared/router-v2/deep-link-emitter.tsx | 11 +++++++++++ shared/router-v2/linking-initial-url.test.ts | 2 -- shared/router-v2/linking-state.test.ts | 11 +++++++++++ shared/router-v2/linking.tsx | 5 +++++ shared/router-v2/url-normalize.test.ts | 14 ++++++++++++++ shared/stores/config.tsx | 2 -- shared/stores/tests/config.test.ts | 1 - 8 files changed, 44 insertions(+), 11 deletions(-) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 366fe69c19b0..2922190a40c7 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -176,12 +176,11 @@ const loadStartupDetails = async () => { let conversation: T.Chat.ConversationIDKey | undefined let conversationUid = '' - let link = '' let tab = '' - if (initialUrl) { - 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 @@ -214,7 +213,6 @@ const loadStartupDetails = async () => { useConfigState.getState().dispatch.setStartupDetails({ conversation: conversation ?? noConversationIDKey, conversationUid, - link, tab: tab as Tabs.Tab, }) @@ -611,7 +609,6 @@ const _initDesktopPlatformListener = () => { if (s.handshakeState !== old.handshakeState && s.handshakeState === 'done') { useConfigState.getState().dispatch.setStartupDetails({ conversation: Chat.noConversationIDKey, - link: '', tab: undefined, }) } diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index 6721c35aecbc..89e3c2cd1231 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -1,5 +1,6 @@ import logger from '@/logger' import {useNavigationIntentsState} from '@/stores/navigation-intents' +import {useSettingsPhoneState} from '@/stores/settings-phone' // Deep-link emission + URL normalization. Kept separate from './linking' // (which imports the config/push/current-user stores) so stores/push can enqueue @@ -38,6 +39,16 @@ 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. Nudge the invitee to add the number their inviter wrote + // to; skip the nudge once we know they already have one. + if (pathname === '/phone-app' || pathname === '/phone-app/') { + const phones = useSettingsPhoneState.getState().phones + return phones && phones.size > 0 ? undefined : 'keybase://settingsAddPhone' + } + // /username (single path segment) const userMatch = pathname.match(/^\/((?:[a-zA-Z0-9][a-zA-Z0-9_-]?)+)\/?$/) if (userMatch?.[1]) { diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts index c307db7fbecc..59bc14f9b184 100644 --- a/shared/router-v2/linking-initial-url.test.ts +++ b/shared/router-v2/linking-initial-url.test.ts @@ -21,7 +21,6 @@ const setCurrentUser = (uid: string) => { type Startup = { conversation: T.Chat.ConversationIDKey conversationUid?: string - link: string tab?: Tabs.Tab } @@ -31,7 +30,6 @@ const setStartup = (st: Partial) => { useConfigState.setState({ startup: { conversation: T.Chat.noConversationIDKey, - link: '', loaded: true, ...st, }, 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.tsx b/shared/router-v2/linking.tsx index 60e7c6924ead..cc6b0c53c114 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -239,6 +239,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: diff --git a/shared/router-v2/url-normalize.test.ts b/shared/router-v2/url-normalize.test.ts index 6f7707c9cda5..ff5f06f64926 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,16 @@ 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', () => { + useSettingsPhoneState.getState().dispatch.resetState() + 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 is ignored once the user has a phone number', () => { + useSettingsPhoneState.setState({phones: new Map([['+15555555555', {} as never]])}) + expect(normalizeUrl('https://keybase.io/phone-app')).toBeUndefined() + useSettingsPhoneState.getState().dispatch.resetState() +}) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index d2adb66b7d7e..af72f6e83c50 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -44,7 +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 - link: string tab?: Tab } userSwitching: boolean @@ -80,7 +79,6 @@ const initialStore: Store = { revokedTrigger: 0, startup: { conversation: noConversationIDKey, - link: '', loaded: false, }, userSwitching: false, diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index 2a870b66f21a..e5d3d000e4ad 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -16,7 +16,6 @@ const resetConfigState = () => { }, startup: { conversation: noConversationIDKey, - link: '', loaded: false, }, userSwitching: false, From 72bfef838ba70423f8221c140f4b328f020839e3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 14:56:11 -0400 Subject: [PATCH 093/127] refactor(js): collapse the no-behaviour leftovers from the simplification sweep The mobileAppState switch only ever asked whether the state is 'active', and a second subscriber guarded the same condition to reload contact permissions; one subscriber now does both. The two navigation-intent predicates had one call site each, and onEngineConnected's two bare scoping blocks declared no colliding names. The catch in the notification subscribe dropped a falsy throw on the floor. Also re-points two comments that described the push token and the share intent as native-readiness retries: the token is parked natively and read back, and the parked share intent waits on JS being able to route, not on JS existing. --- shared/constants/init/index.tsx | 31 ++---- .../constants/init/push-listener.native.tsx | 12 ++- shared/constants/init/shared.tsx | 102 +++++++++--------- shared/stores/navigation-intents.tsx | 19 ++-- shared/stores/tests/config.test.ts | 10 +- shared/stores/tests/settings.test.ts | 16 +-- 6 files changed, 86 insertions(+), 104 deletions(-) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 2922190a40c7..af9c148bef3e 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -358,26 +358,19 @@ const _initNativePlatformListener = () => { _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) } // 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 = () => { @@ -429,14 +422,6 @@ const _initNativePlatformListener = () => { ignorePromise(f()) })) - _platformUnsubs.push(useShellState.subscribe((s, old) => { - if (s.mobileAppState === old.mobileAppState) return - if (s.mobileAppState === 'active') { - // only reload on foreground - useSettingsContactsState.getState().dispatch.loadContactPermissions() - } - })) - if (isAndroid) { _platformUnsubs.push(useDarkModeState.subscribe((s, old) => { if (s.darkModePreference === old.darkModePreference) return diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index 8905316889fb..2313646d3ed8 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -50,9 +50,10 @@ export const initPushListener = () => { }) ) - // 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. + // 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 @@ -96,8 +97,9 @@ export const initPushListener = () => { emitDeepLink('keybase://incoming-share') }) unsubs.push(() => shareSub.remove()) - // shareListenersRegistered() is deliberately NOT called here: the init/index.tsx - // router subscriber controls when native flushes pending share intents. + // 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) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index d356b26339e2..f762f799c004 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -415,63 +415,59 @@ 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() - { - const subscribe = async (generation: number) => { - let clientState: T.RPCGen.ClientState | undefined - try { - // prettier-ignore - clientState = 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, - }, - }) - } catch (error) { - if (error) { - logger.warn('error in toggling notifications: ', error) - } - // clientState stays undefined: no reply and no channels either, so nothing versioned will - // reach this connection and the bootstrap status is all we have, exactly as for a service - // too old to answer at all - } - // outside the try on purpose: a throw from applying a good reply must not be read as a - // failed subscribe and re-run the unversioned fallback over half-applied versioned state - applyClientState(clientState, generation) + + const subscribe = async (generation: number) => { + let clientState: T.RPCGen.ClientState | undefined + try { + // prettier-ignore + clientState = 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, + }, + }) + } catch (error) { + logger.warn('error in toggling notifications: ', error) + // clientState stays undefined: no reply and no channels either, so nothing versioned will + // reach this connection and the bootstrap status is all we have, exactly as for a service + // too old to answer at all } - // a new connection has told us nothing yet; the reply is what settles it - useConfigState.getState().dispatch.setSessionIsUnversioned(false) - ignorePromise(drainPushTapRoute()) - // startHandshake first so this connection has its generation before the subscribe goes out. - // Nothing orders the two RPCs any more: the subscription reply is what carries the session and - // the http address, so the bootstrap read has nothing left to race with. - useDaemonState.getState().dispatch.startHandshake() - ignorePromise(subscribe(useDaemonState.getState().handshakeGeneration)) - } + // outside the try on purpose: a throw from applying a good reply must not be read as a + // failed subscribe and re-run the unversioned fallback over half-applied versioned state + applyClientState(clientState, generation) + } + // a new connection has told us nothing yet; the reply is what settles it + useConfigState.getState().dispatch.setSessionIsUnversioned(false) + ignorePromise(drainPushTapRoute()) + // startHandshake first so this connection has its generation before the subscribe goes out. + // Nothing orders the two RPCs any more: the subscription reply is what carries the session and + // the http address, so the bootstrap read has nothing left to race with. + useDaemonState.getState().dispatch.startHandshake() + ignorePromise(subscribe(useDaemonState.getState().handshakeGeneration)) } export const onEngineDisconnected = () => { diff --git a/shared/stores/navigation-intents.tsx b/shared/stores/navigation-intents.tsx index 63a06455fea9..b463700030b2 100644 --- a/shared/stores/navigation-intents.tsx +++ b/shared/stores/navigation-intents.tsx @@ -33,15 +33,6 @@ type Store = { const duplicateWindowMs = 1500 -const targetsCouldMatch = (first?: string, second?: string) => - !first || !second || first === second - -// 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 - export const useNavigationIntentsState = Z.createZustand( 'navigation-intents', (set, get) => { @@ -63,7 +54,10 @@ export const useNavigationIntentsState = Z.createZustand( const now = Date.now() const targetUid = options?.targetUid const {intent: pending, lastHandledIntent} = get() - if (pending?.url === url && targetsCouldMatch(pending.targetUid, targetUid)) { + if ( + pending?.url === url && + (!pending.targetUid || !targetUid || pending.targetUid === targetUid) + ) { if (!pending.targetUid && targetUid) { set(s => { if (s.intent?.id === pending.id) { @@ -73,10 +67,13 @@ export const useNavigationIntentsState = Z.createZustand( } 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) ) { return } diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index e5d3d000e4ad..8b9bedd78d46 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -1,4 +1,5 @@ /// +import * as Tabs from '../../constants/tabs' import {noConversationIDKey} from '../../constants/types/chat/common' import {useConfigState} from '../config' @@ -36,20 +37,17 @@ test('setStartupDetails only records the first startup payload', () => { dispatch.setStartupDetails({ conversation: 'first-convo' as any, - link: 'keybase://first', - tab: undefined, + tab: Tabs.chatTab, }) dispatch.setStartupDetails({ conversation: 'second-convo' as any, - link: 'keybase://second', - tab: undefined, + tab: Tabs.peopleTab, }) expect(useConfigState.getState().startup).toEqual({ conversation: 'first-convo', - link: 'keybase://first', loaded: true, - tab: undefined, + tab: Tabs.chatTab, }) }) diff --git a/shared/stores/tests/settings.test.ts b/shared/stores/tests/settings.test.ts index 3714b88e838a..94235ed0548d 100644 --- a/shared/stores/tests/settings.test.ts +++ b/shared/stores/tests/settings.test.ts @@ -47,22 +47,26 @@ describe('settings loading', () => { }) test('a notification that lands while the settings load is in flight is not overwritten', async () => { - const stale = [{phoneNumber: '+15550000000', superseded: false, verified: true, visibility: 0}] - const notified = [{phoneNumber: '+15551111111', superseded: false, verified: true, visibility: 0}] - const staleEmails = [{email: 'stale@example.com', isPrimary: true, isVerified: true, visibility: 0}] - const notifiedEmails = [{email: 'fresh@example.com', isPrimary: true, isVerified: true, visibility: 0}] + 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() - await Promise.resolve() - await Promise.resolve() + 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']) From 195157cdafc0e60622c707fc05289c8de99481eb Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 15:21:01 -0400 Subject: [PATCH 094/127] fix(links): carry an invite launch link across the signup it triggers router.tsx disables the linking config while logged out, and React Navigation reads getInitialURL exactly once at NavigationContainer mount -- it is not retried when loggedIn flips, and login does not remount the container (useUserSwitchNavKey deliberately ignores '' -> username). The invite install link targets someone with no account, so it always launches a logged-out app and was therefore read, discarded, and never seen by the router. startup.link used to carry it because setLoggedIn only resets the stores on true -> false. Hold the launch URL when it arrives logged out and replay it on the first login. The navigation intent's lifetime then starts after the signup rather than at launch, so a slow signup cannot expire it. Per the user's ruling, /phone-app now always opens Add Phone Number: the phones check is gone, along with the dependency on settings having loaded (phones is undefined at every cold launch, which is exactly when the link fires). handleKeybaseLink learns the same destination, since desktop routes every URL through it. --- shared/constants/deeplinks.test.ts | 11 +++ shared/constants/deeplinks.tsx | 6 ++ shared/constants/init/index.tsx | 37 ++++++- shared/constants/init/startup-link.test.ts | 108 +++++++++++++++++++++ shared/router-v2/deep-link-emitter.tsx | 15 ++- shared/router-v2/url-normalize.test.ts | 5 +- shared/settings/load-settings.tsx | 9 +- 7 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 shared/constants/init/startup-link.test.ts diff --git a/shared/constants/deeplinks.test.ts b/shared/constants/deeplinks.test.ts index c2399ebdb5e9..d8c6b83c8f9b 100644 --- a/shared/constants/deeplinks.test.ts +++ b/shared/constants/deeplinks.test.ts @@ -48,3 +48,14 @@ test('a devices link opens the devices screen under settings on mobile', () => { 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 205f959eac40..06f27fb0d213 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -88,6 +88,12 @@ const handleKeybaseLink = (link: string) => { 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/index.tsx b/shared/constants/init/index.tsx index af9c148bef3e..e5d4d23b05a9 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -17,6 +17,7 @@ import {logState, setThreadInputCommandStatus} from '@/constants/router' import {initSharedSubscriptions, _onEngineIncoming, onEngineConnected as onSharedEngineConnected} from './shared' import {noConversationIDKey} from '../types/chat/common' import {dumpLogs, persistRoute} from '@/util/storeless-actions' +import {emitDeepLink} from '@/router-v2/deep-link-emitter' // ─── Platform-specific init helpers (resolved per platform: platform.desktop / // platform.native — native keeps require() for Metro importAll ordering) ───── @@ -149,7 +150,29 @@ const onChatClearWatch = async () => { // ─── Startup details (native only) ─────────────────────────────────────────── -const loadStartupDetails = async () => { +// A launch URL that arrived while logged out. Module-level, not a store field: it must +// outlive the logout reset that a signup does not perform but an account switch does, and +// nothing else may read it. +let _launchLinkAwaitingLogin = '' + +// The invite install link (https://keybase.io/phone-app) is aimed at someone who has no account +// yet, so the app it launches starts logged out with the linking config off. Replay the held URL +// once, on the first login. The intent's 5-minute lifetime runs from here, not from launch, so +// however long the signup took does not eat into it -- all it has to survive is the router +// registering its intent subscriber, which happens in the same commit. +// Exported for startup-link.test.ts; the only production caller is initPlatformListener. +export const _replayLaunchLinkAfterLogin = () => + useConfigState.subscribe((s, old) => { + if (!s.loggedIn || old.loggedIn) return + const link = _launchLinkAwaitingLogin + _launchLinkAwaitingLogin = '' + if (!link) return + logger.info('[Startup] replaying the launch link held across login:', link) + emitDeepLink(link) + }) + +// Exported for startup-link.test.ts; the only production caller is initPlatformListener. +export const loadStartupDetails = async () => { logger.info('[Startup] loadStartupDetails: starting') const {guiConfig, Linking} = _getNative() @@ -174,6 +197,16 @@ const loadStartupDetails = async () => { return url }) + // router.tsx disables the linking config while logged out, and React Navigation reads + // getInitialURL exactly once, when the NavigationContainer mounts -- it is not retried when + // loggedIn flips, and login does not remount the container (useUserSwitchNavKey ignores + // '' -> username). So a URL that launched the app before a signup would never reach the + // router. Hold it and replay it on the first login instead; see _replayLaunchLinkAfterLogin. + // Logged in already: the linking config's own read owns it and this stays empty. + if (initialUrl && !useConfigState.getState().loggedIn) { + _launchLinkAwaitingLogin = initialUrl + } + let conversation: T.Chat.ConversationIDKey | undefined let conversationUid = '' let tab = '' @@ -394,6 +427,8 @@ const _initNativePlatformListener = () => { } } + _platformUnsubs.push(_replayLaunchLinkAfterLogin()) + _platformUnsubs.push(useConfigState.subscribe((s, old) => { if (s.loggedIn === old.loggedIn) return const f = async () => { diff --git a/shared/constants/init/startup-link.test.ts b/shared/constants/init/startup-link.test.ts new file mode 100644 index 000000000000..d7fec0d0b12a --- /dev/null +++ b/shared/constants/init/startup-link.test.ts @@ -0,0 +1,108 @@ +/// +import type * as Init from './index' +import type * as ConfigStore from '@/stores/config' +import type * as IntentsStore from '@/stores/navigation-intents' +import type * as Types from '../types' + +// The init module picks its mobile behavior from the platform globals, so it is loaded fresh +// with them set and the native modules mocked, like location-watch.test.ts does. Everything the +// assertions touch has to come out of the same fresh registry, or the module under test writes +// to a different copy of the stores than the test reads. +const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} + +type Loaded = { + init: typeof Init + useConfigState: (typeof ConfigStore)['useConfigState'] + useNavigationIntentsState: (typeof IntentsStore)['useNavigationIntentsState'] +} + +const load = (initialURL: string | null): Loaded => { + global.isMobile = true + global.isIOS = true + global.isAndroid = false + jest.resetModules() + jest.doMock('./platform', () => ({ + getNative: () => ({ + Linking: {getInitialURL: async () => Promise.resolve(initialURL)}, + guiConfig: '{}', + }), + })) + 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', () => ({})) + const T = require('../types') as typeof Types + jest.spyOn(T.RPCGen, 'configGuiSetValueRpcPromise').mockResolvedValue(undefined as never) + return { + init: require('./index') as typeof Init, + useConfigState: (require('@/stores/config') as typeof ConfigStore).useConfigState, + useNavigationIntentsState: (require('@/stores/navigation-intents') as typeof IntentsStore) + .useNavigationIntentsState, + } +} + +const inviteLink = 'https://keybase.io/phone-app' +const addPhone = 'keybase://settingsAddPhone' + +let unsubscribe: (() => void) | undefined + +afterEach(() => { + unsubscribe?.() + unsubscribe = undefined + jest.restoreAllMocks() + jest.dontMock('./platform') + jest.dontMock('./shared') + jest.dontMock('@/fs/common/lifecycle') + Object.assign(global, originalGlobals) +}) + +test('an invite link that launches a logged-out app opens add-phone after the signup', async () => { + const {init, useConfigState, useNavigationIntentsState} = load(inviteLink) + unsubscribe = init._replayLaunchLinkAfterLogin() + + await init.loadStartupDetails() + // the router's linking config is off while logged out, so nothing may navigate yet + expect(useNavigationIntentsState.getState().intent).toBeUndefined() + + // ...the user signs up, which is the only thing that moves loggedIn off false + useConfigState.getState().dispatch.setLoggedIn(true) + expect(useNavigationIntentsState.getState().intent?.url).toBe(addPhone) + // no targetUid: a link can never switch accounts, only a real notification tap can + expect(useNavigationIntentsState.getState().intent?.targetUid).toBeUndefined() +}) + +test('the held launch link is replayed once, not on every later login', async () => { + const {init, useConfigState, useNavigationIntentsState} = load(inviteLink) + unsubscribe = init._replayLaunchLinkAfterLogin() + + await init.loadStartupDetails() + useConfigState.getState().dispatch.setLoggedIn(true) + const id = useNavigationIntentsState.getState().intent?.id + expect(id).toBeDefined() + useNavigationIntentsState.getState().dispatch.acknowledge(id!) + + // logging out resets the stores; logging back in must not re-fire the nudge + useConfigState.getState().dispatch.setLoggedIn(false) + useConfigState.getState().dispatch.setLoggedIn(true) + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +test('a launch link for an already logged-in app is left to the linking config', async () => { + const {init, useConfigState, useNavigationIntentsState} = load(inviteLink) + unsubscribe = init._replayLaunchLinkAfterLogin() + useConfigState.setState({loggedIn: true}) + + await init.loadStartupDetails() + // getInitialURL reads the URL itself in this case; holding it too would double-navigate + useConfigState.getState().dispatch.setLoggedIn(false) + useConfigState.getState().dispatch.setLoggedIn(true) + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) + +test('no launch url means nothing is replayed', async () => { + const {init, useConfigState, useNavigationIntentsState} = load(null) + unsubscribe = init._replayLaunchLinkAfterLogin() + + await init.loadStartupDetails() + useConfigState.getState().dispatch.setLoggedIn(true) + expect(useNavigationIntentsState.getState().intent).toBeUndefined() +}) diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index 89e3c2cd1231..6a4a534662d4 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -1,10 +1,10 @@ import logger from '@/logger' import {useNavigationIntentsState} from '@/stores/navigation-intents' -import {useSettingsPhoneState} from '@/stores/settings-phone' -// 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 ---- @@ -42,11 +42,10 @@ const normalizeHttpUrl = (url: string): string | undefined => { // /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. Nudge the invitee to add the number their inviter wrote - // to; skip the nudge once we know they already have one. + // 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/') { - const phones = useSettingsPhoneState.getState().phones - return phones && phones.size > 0 ? undefined : 'keybase://settingsAddPhone' + return 'keybase://settingsAddPhone' } // /username (single path segment) diff --git a/shared/router-v2/url-normalize.test.ts b/shared/router-v2/url-normalize.test.ts index ff5f06f64926..5b857c4faf1a 100644 --- a/shared/router-v2/url-normalize.test.ts +++ b/shared/router-v2/url-normalize.test.ts @@ -82,14 +82,13 @@ test('a slash-separated subteam path is not a team-page link', () => { }) test('the invite install link opens add-phone, not a profile for a user named phone-app', () => { - useSettingsPhoneState.getState().dispatch.resetState() 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 is ignored once the user has a phone number', () => { +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')).toBeUndefined() + 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 f5461cd09179..7c727391fa7e 100644 --- a/shared/settings/load-settings.tsx +++ b/shared/settings/load-settings.tsx @@ -12,9 +12,12 @@ export const loadSettings = () => { if (!useConfigState.getState().loggedIn) { return } - // An emailsChanged/phoneNumbersChanged notification can land while this RPC is in - // flight, and it carries the newer list. Apply the reply only to the value it was read - // against, the same rule the versioned session write follows. + // 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 same rule the versioned session write follows. The + // racing writer is usually an emailsChanged/phoneNumbersChanged notification, but it is + // also resetState (a logout), notifyEmailVerified, and sentVerificationEmail -- so a + // resend-verification click mid-load drops that round's server list too, by design. const emailsBefore = useSettingsEmailState.getState().emails const phonesBefore = useSettingsPhoneState.getState().phones try { From f67b8d66e330c850d76d4d3eab309e8153af145d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 15:34:01 -0400 Subject: [PATCH 095/127] fix(settings): drop a settings reply that lands after a logout The identity guard added earlier cannot see a logout: 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, and the reply would repopulate the stores for a logged-out app -- the next account's settings screen could then render the previous account's emails and numbers. Re-read loggedIn after the await, the same check the function already makes before it. Also reverts the carry-a-launch-link-across-signup mechanism: per the user's ruling a launch link that arrives while logged out is ignored, which is what every link other than the one startup.link reader already did. The /phone-app -> settingsAddPhone chain therefore serves only an already logged-in user; it still earns its place because the normalizeHttpUrl carve-out is what stops the single-segment username rule opening a profile for a user that does not exist. --- shared/constants/init/index.tsx | 37 +------ shared/constants/init/startup-link.test.ts | 108 --------------------- shared/settings/load-settings.tsx | 13 ++- shared/stores/tests/settings.test.ts | 24 +++++ 4 files changed, 35 insertions(+), 147 deletions(-) delete mode 100644 shared/constants/init/startup-link.test.ts diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index e5d4d23b05a9..af9c148bef3e 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -17,7 +17,6 @@ import {logState, setThreadInputCommandStatus} from '@/constants/router' import {initSharedSubscriptions, _onEngineIncoming, onEngineConnected as onSharedEngineConnected} from './shared' import {noConversationIDKey} from '../types/chat/common' import {dumpLogs, persistRoute} from '@/util/storeless-actions' -import {emitDeepLink} from '@/router-v2/deep-link-emitter' // ─── Platform-specific init helpers (resolved per platform: platform.desktop / // platform.native — native keeps require() for Metro importAll ordering) ───── @@ -150,29 +149,7 @@ const onChatClearWatch = async () => { // ─── Startup details (native only) ─────────────────────────────────────────── -// A launch URL that arrived while logged out. Module-level, not a store field: it must -// outlive the logout reset that a signup does not perform but an account switch does, and -// nothing else may read it. -let _launchLinkAwaitingLogin = '' - -// The invite install link (https://keybase.io/phone-app) is aimed at someone who has no account -// yet, so the app it launches starts logged out with the linking config off. Replay the held URL -// once, on the first login. The intent's 5-minute lifetime runs from here, not from launch, so -// however long the signup took does not eat into it -- all it has to survive is the router -// registering its intent subscriber, which happens in the same commit. -// Exported for startup-link.test.ts; the only production caller is initPlatformListener. -export const _replayLaunchLinkAfterLogin = () => - useConfigState.subscribe((s, old) => { - if (!s.loggedIn || old.loggedIn) return - const link = _launchLinkAwaitingLogin - _launchLinkAwaitingLogin = '' - if (!link) return - logger.info('[Startup] replaying the launch link held across login:', link) - emitDeepLink(link) - }) - -// Exported for startup-link.test.ts; the only production caller is initPlatformListener. -export const loadStartupDetails = async () => { +const loadStartupDetails = async () => { logger.info('[Startup] loadStartupDetails: starting') const {guiConfig, Linking} = _getNative() @@ -197,16 +174,6 @@ export const loadStartupDetails = async () => { return url }) - // router.tsx disables the linking config while logged out, and React Navigation reads - // getInitialURL exactly once, when the NavigationContainer mounts -- it is not retried when - // loggedIn flips, and login does not remount the container (useUserSwitchNavKey ignores - // '' -> username). So a URL that launched the app before a signup would never reach the - // router. Hold it and replay it on the first login instead; see _replayLaunchLinkAfterLogin. - // Logged in already: the linking config's own read owns it and this stays empty. - if (initialUrl && !useConfigState.getState().loggedIn) { - _launchLinkAwaitingLogin = initialUrl - } - let conversation: T.Chat.ConversationIDKey | undefined let conversationUid = '' let tab = '' @@ -427,8 +394,6 @@ const _initNativePlatformListener = () => { } } - _platformUnsubs.push(_replayLaunchLinkAfterLogin()) - _platformUnsubs.push(useConfigState.subscribe((s, old) => { if (s.loggedIn === old.loggedIn) return const f = async () => { diff --git a/shared/constants/init/startup-link.test.ts b/shared/constants/init/startup-link.test.ts deleted file mode 100644 index d7fec0d0b12a..000000000000 --- a/shared/constants/init/startup-link.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/// -import type * as Init from './index' -import type * as ConfigStore from '@/stores/config' -import type * as IntentsStore from '@/stores/navigation-intents' -import type * as Types from '../types' - -// The init module picks its mobile behavior from the platform globals, so it is loaded fresh -// with them set and the native modules mocked, like location-watch.test.ts does. Everything the -// assertions touch has to come out of the same fresh registry, or the module under test writes -// to a different copy of the stores than the test reads. -const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} - -type Loaded = { - init: typeof Init - useConfigState: (typeof ConfigStore)['useConfigState'] - useNavigationIntentsState: (typeof IntentsStore)['useNavigationIntentsState'] -} - -const load = (initialURL: string | null): Loaded => { - global.isMobile = true - global.isIOS = true - global.isAndroid = false - jest.resetModules() - jest.doMock('./platform', () => ({ - getNative: () => ({ - Linking: {getInitialURL: async () => Promise.resolve(initialURL)}, - guiConfig: '{}', - }), - })) - 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', () => ({})) - const T = require('../types') as typeof Types - jest.spyOn(T.RPCGen, 'configGuiSetValueRpcPromise').mockResolvedValue(undefined as never) - return { - init: require('./index') as typeof Init, - useConfigState: (require('@/stores/config') as typeof ConfigStore).useConfigState, - useNavigationIntentsState: (require('@/stores/navigation-intents') as typeof IntentsStore) - .useNavigationIntentsState, - } -} - -const inviteLink = 'https://keybase.io/phone-app' -const addPhone = 'keybase://settingsAddPhone' - -let unsubscribe: (() => void) | undefined - -afterEach(() => { - unsubscribe?.() - unsubscribe = undefined - jest.restoreAllMocks() - jest.dontMock('./platform') - jest.dontMock('./shared') - jest.dontMock('@/fs/common/lifecycle') - Object.assign(global, originalGlobals) -}) - -test('an invite link that launches a logged-out app opens add-phone after the signup', async () => { - const {init, useConfigState, useNavigationIntentsState} = load(inviteLink) - unsubscribe = init._replayLaunchLinkAfterLogin() - - await init.loadStartupDetails() - // the router's linking config is off while logged out, so nothing may navigate yet - expect(useNavigationIntentsState.getState().intent).toBeUndefined() - - // ...the user signs up, which is the only thing that moves loggedIn off false - useConfigState.getState().dispatch.setLoggedIn(true) - expect(useNavigationIntentsState.getState().intent?.url).toBe(addPhone) - // no targetUid: a link can never switch accounts, only a real notification tap can - expect(useNavigationIntentsState.getState().intent?.targetUid).toBeUndefined() -}) - -test('the held launch link is replayed once, not on every later login', async () => { - const {init, useConfigState, useNavigationIntentsState} = load(inviteLink) - unsubscribe = init._replayLaunchLinkAfterLogin() - - await init.loadStartupDetails() - useConfigState.getState().dispatch.setLoggedIn(true) - const id = useNavigationIntentsState.getState().intent?.id - expect(id).toBeDefined() - useNavigationIntentsState.getState().dispatch.acknowledge(id!) - - // logging out resets the stores; logging back in must not re-fire the nudge - useConfigState.getState().dispatch.setLoggedIn(false) - useConfigState.getState().dispatch.setLoggedIn(true) - expect(useNavigationIntentsState.getState().intent).toBeUndefined() -}) - -test('a launch link for an already logged-in app is left to the linking config', async () => { - const {init, useConfigState, useNavigationIntentsState} = load(inviteLink) - unsubscribe = init._replayLaunchLinkAfterLogin() - useConfigState.setState({loggedIn: true}) - - await init.loadStartupDetails() - // getInitialURL reads the URL itself in this case; holding it too would double-navigate - useConfigState.getState().dispatch.setLoggedIn(false) - useConfigState.getState().dispatch.setLoggedIn(true) - expect(useNavigationIntentsState.getState().intent).toBeUndefined() -}) - -test('no launch url means nothing is replayed', async () => { - const {init, useConfigState, useNavigationIntentsState} = load(null) - unsubscribe = init._replayLaunchLinkAfterLogin() - - await init.loadStartupDetails() - useConfigState.getState().dispatch.setLoggedIn(true) - expect(useNavigationIntentsState.getState().intent).toBeUndefined() -}) diff --git a/shared/settings/load-settings.tsx b/shared/settings/load-settings.tsx index 7c727391fa7e..a3ae8b713746 100644 --- a/shared/settings/load-settings.tsx +++ b/shared/settings/load-settings.tsx @@ -15,13 +15,20 @@ export const loadSettings = () => { // 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 same rule the versioned session write follows. The - // racing writer is usually an emailsChanged/phoneNumbersChanged notification, but it is - // also resetState (a logout), notifyEmailVerified, and sentVerificationEmail -- so a - // resend-verification click mid-load drops that round's server list too, by design. + // 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) + // 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 ?? []) } diff --git a/shared/stores/tests/settings.test.ts b/shared/stores/tests/settings.test.ts index 94235ed0548d..24d0636cd511 100644 --- a/shared/stores/tests/settings.test.ts +++ b/shared/stores/tests/settings.test.ts @@ -71,4 +71,28 @@ describe('settings loading', () => { 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([]) + }) }) From 851945e954c92745471c52b01a22830f3ee044fc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 15:59:58 -0400 Subject: [PATCH 096/127] refactor(service): make setNotifications' register-before-read a compile error The reply to setNotifications must describe state read after the connection is subscribed: a change landing in the other order is announced to nobody and reported stale, and since the client keeps whichever version is newer, it keeps the stale value for good. That order was held by a comment, and the test said outright it could not observe it. SetChannels now returns the StateVersion labelling a read made from there on, and the handler builds ClientState from that return value, so the reply cannot be assembled without having registered first. The version is read inside setNotificationChannels, under the lock announce takes to test registration, so there is no register/read pair left to reorder anywhere. No behaviour or wire change; SetChannels had one caller. --- go/libkb/notify_router.go | 18 ++++++++++++++---- go/service/notify.go | 20 ++++++++++---------- go/service/notify_test.go | 10 ++++------ 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index ece5ae597aee..c8b75b8847a4 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -350,10 +350,15 @@ func (n *NotifyRouter) RemoveListener(id NotifyListenerID) { func (n *NotifyRouter) Shutdown() {} -func (n *NotifyRouter) setNotificationChannels(id ConnectionID, val keybase1.NotificationChannels) { +// setNotificationChannels registers the connection's filter and returns the +// version labelling it. The version is read under the same lock announce takes to +// decide whether this connection is registered, so the registration and its label +// are one step and neither can be taken without the other. +func (n *NotifyRouter) setNotificationChannels(id ConnectionID, val keybase1.NotificationChannels) keybase1.StateVersion { n.Lock() defer n.Unlock() n.state[id] = val + return n.G().StateVersion() } func (n *NotifyRouter) getNotificationChannels(id ConnectionID) keybase1.NotificationChannels { @@ -393,9 +398,14 @@ func (n *NotifyRouter) AddConnection(xp rpc.Transporter, ch chan error) Connecti } // SetChannels sets which notification channels are interested for the connection -// with the given connection ID. -func (n *NotifyRouter) SetChannels(i ConnectionID, nc keybase1.NotificationChannels) { - n.setNotificationChannels(i, nc) +// with the given connection ID, and returns the version that labels a state read +// made from here on. The version comes back from the registration rather than +// from a separate StateVersion call so that a reply describing the state cannot +// be built before the connection is subscribed: a change landing in that window +// would be announced to nobody and reported stale, and the client keeps whichever +// version is newer, so it would keep the stale one for good. +func (n *NotifyRouter) SetChannels(i ConnectionID, nc keybase1.NotificationChannels) keybase1.StateVersion { + return n.setNotificationChannels(i, nc) } // announce stamps one state version and fans a notification out to every diff --git a/go/service/notify.go b/go/service/notify.go index 32649efc7cd2..0a45758f2234 100644 --- a/go/service/notify.go +++ b/go/service/notify.go @@ -31,17 +31,17 @@ func NewNotifyCtlHandler(xp rpc.Transporter, id libkb.ConnectionID, g *libkb.Glo } } -// SetNotifications registers the channels and then reads the client state, in -// that order: a change from here on is announced to this connection, so the -// reply can only miss something the client is about to be told about anyway. -// That is what removes the ordering problem between a subscription and a -// separate read of the same state. +// SetNotifications registers the channels and then reads the client state. The +// order is not a convention here: the version that labels the reply is what +// SetChannels returns, so the state below cannot be read before the connection is +// subscribed. A change from here on is announced to this connection, so the reply +// can only miss something the client is about to be told about anyway. func (h *NotifyCtlHandler) SetNotifications(ctx context.Context, n keybase1.NotificationChannels) (keybase1.ClientState, error) { - h.G().NotifyRouter.SetChannels(h.id, n) - // Read the version before the state it describes. NextStateVersion is stamped - // after a change is readable, so this snapshot is never newer than its label - // and a client can drop it on a tie without losing anything. - res := keybase1.ClientState{Version: h.G().StateVersion(), AppState: h.G().MobileAppState.State()} + // The version is read before the state it describes. NextStateVersion is + // stamped after a change is readable, so this snapshot is never newer than its + // label and a client can drop it on a tie without losing anything. + version := h.G().NotifyRouter.SetChannels(h.id, n) + res := keybase1.ClientState{Version: version, AppState: h.G().MobileAppState.State()} // 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 client would have to be corrected out of by a notification it diff --git a/go/service/notify_test.go b/go/service/notify_test.go index 01a1b84b4881..4f4cc4509abc 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -46,12 +46,10 @@ func TestSetNotificationsHoldsBackAnUnsettledSession(t *testing.T) { // change strictly newer than that label -- which together are what let a client // keep a notification over the reply. // -// The order of the two statements INSIDE SetNotifications is not observable from -// here and this test does not pin it: AddConnection has already registered empty -// channels, so the pre-call assertion is trivially true, and swapping register -// and read still satisfies everything below. That ordering is held by the comment -// on SetNotifications; pinning it would need a recording transport and a send -// that blocks until the channels are set. +// The register-before-read order inside SetNotifications is not observable from +// here and is not pinned here: it is pinned by the compiler instead, because the +// version labelling the reply is SetChannels' return value and there is no reply +// to build without first having called it. func TestSetNotificationsRegistersChannelsAndLabelsTheRead(t *testing.T) { tc := libkb.SetupTest(t, "notify", 0) defer tc.Cleanup() From 483bd3a07c065779349646bf774effc25c266fb4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 17:08:05 -0400 Subject: [PATCH 097/127] fix(chat): don't launch archive jobs after a plain Stop --- go/chat/archive.go | 9 +++-- go/chat/archive_appstate_test.go | 59 ++++++++++++++++++++++++++++++++ go/chat/convloader.go | 6 +++- go/libkb/leveldb_cleaner.go | 6 +++- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/go/chat/archive.go b/go/chat/archive.go index 11cd44bd0e17..4f92e66d5eb1 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -194,10 +194,13 @@ func (r *ChatArchiveRegistry) resumeAllBgJobs(ctx context.Context, stopCh chan s } r.Lock() defer r.Unlock() - // The delay can win over a closed stopCh, and a later Start (possibly for - // another user) can run before the lock is taken. - if r.stopCh != stopCh { + // Stop closes stopCh under this lock, so a closed channel here means this + // run is over, whether or not a later Start (possibly for another user) + // has since replaced r.stopCh. + select { + case <-stopCh: return nil + default: } // Decide under the lock the monitor pauses under: a pause either comes // first and is seen here, or bumps pauseEpoch and pauses what launches. diff --git a/go/chat/archive_appstate_test.go b/go/chat/archive_appstate_test.go index 40b61fc689a5..f90ea828b5fe 100644 --- a/go/chat/archive_appstate_test.go +++ b/go/chat/archive_appstate_test.go @@ -19,6 +19,7 @@ import ( "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" ) // archiveJobRunner stands in for ChatArchiver: a launched job waits for @@ -249,6 +250,64 @@ func TestArchiveStaleResumeAfterRestart(t *testing.T) { } } +// A resume whose delay fires just as a plain Stop, with no Start following +// it, takes the lock must not launch jobs: there is no live run left to +// launch them into. +func TestArchiveResumeAfterPlainStopLaunchesNothing(t *testing.T) { + r, runner, _ := setupAppStateArchive(t, true) + stopCh := make(chan struct{}) + r.Lock() + r.started = true + r.stopCh = stopCh + r.eg = new(errgroup.Group) + r.Unlock() + + reached := make(chan struct{}) + release := make(chan struct{}) + r.beforeResumeDecision = func() { + close(reached) + <-release + } + + resumeDone := make(chan error, 1) + go func() { + resumeDone <- r.resumeAllBgJobs(context.Background(), stopCh) + }() + + <-reached + // Stop takes the lock first, because the resume goroutine is parked in + // beforeResumeDecision, not yet at r.Lock(). + stopDone := make(chan struct{}) + go func() { + <-r.Stop(context.Background()) + close(stopDone) + }() + select { + case <-stopDone: + case <-time.After(10 * time.Second): + require.FailNow(t, "Stop did not finish") + } + close(release) + + // Before the fix, this check's identity comparison can't tell a plain + // Stop apart from a still-live run (Stop closes stopCh but never + // replaces it), so resumeAllBgJobs falls through to initLocked and gets + // a spurious "not started" error instead of cleanly recognizing the run + // is over. + select { + case err := <-resumeDone: + require.NoError(t, err) + case <-time.After(10 * time.Second): + require.FailNow(t, "resumeAllBgJobs did not return") + } + + select { + case id := <-runner.launched: + require.FailNow(t, fmt.Sprintf("resumed %v after a plain Stop", id)) + case <-time.After(500 * time.Millisecond): + } +} + func TestArchiveStartInBackgroundDoesNotResume(t *testing.T) { r, runner, tc := setupAppStateArchive(t, true) for _, state := range []keybase1.MobileAppState{ diff --git a/go/chat/convloader.go b/go/chat/convloader.go index e713e1f44102..4cdb01d11433 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -206,9 +206,13 @@ func (b *BackgroundConvLoader) monitorAppState(w *libkb.AppStateWatcher, stopCh b.Debug(ctx, "monitorAppState: starting up in %v", state) w.Run(state, stopCh, func(keybase1.MobileAppState) bool { b.Lock() - if b.stopCh != stopCh { + // Stop closes stopCh under this lock, so a closed channel here means + // this run is over. + select { + case <-stopCh: b.Unlock() return false + default: } // Read and apply under the lock, so Start and Stop never interleave // with a decision made on a stale state. diff --git a/go/libkb/leveldb_cleaner.go b/go/libkb/leveldb_cleaner.go index 142bd542e5c8..913d2501b24a 100644 --- a/go/libkb/leveldb_cleaner.go +++ b/go/libkb/leveldb_cleaner.go @@ -174,8 +174,12 @@ func (c *levelDbCleaner) monitorAppState(w *AppStateWatcher, stopCh chan struct{ c.log("monitorAppState: attempting cancel, state: %v", state) c.Lock() defer c.Unlock() - if c.stopCh != stopCh { + // Stop closes stopCh under this lock, so a closed channel here means + // this run is over. + select { + case <-stopCh: return false + default: } close(c.cancelCh) c.cancelCh = make(chan struct{}) From b10f1ab13ad882c32d86717bafa12b13728adbcb Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 17:23:13 -0400 Subject: [PATCH 098/127] refactor(chat): run the archive registry from one loop and pause by app state level --- go/chat/archive.go | 174 ++++++++----------- go/chat/archive_appstate_test.go | 276 +++++++++++++++++++------------ 2 files changed, 237 insertions(+), 213 deletions(-) diff --git a/go/chat/archive.go b/go/chat/archive.go index 4f92e66d5eb1..c3adc00444ff 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -42,37 +42,28 @@ type ChatArchiveRegistry struct { flushDelay time.Duration stopCh chan struct{} clock clockwork.Clock - // eg holds the current run's goroutines. 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 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. - launching map[chat1.ArchiveJobID]*archiveLaunch - // pauseEpoch counts background pauses. A launched job that registers - // after one is paused right away, since the pause could not reach it. - pauseEpoch uint64 - // watcher is the current run's app-state watcher, nil between runs. - watcher *libkb.AppStateWatcher + // 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 - // beforeResumeDecision, if set, runs in resumeAllBgJobs after its delay - // and before it takes the lock. Tests only. - beforeResumeDecision func() edb *encrypteddb.EncryptedDB jobHistory chat1.ArchiveChatHistory } -type archiveLaunch struct { - pauseEpoch uint64 -} - type ArchiveJobNotFoundError struct { jobID chat1.ArchiveJobID } @@ -101,7 +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]*archiveLaunch), + launching: make(map[chat1.ArchiveJobID]uint64), jobHistory: chat1.ArchiveChatHistory{JobHistory: make(map[chat1.ArchiveJobID]chat1.ArchiveChatJob)}, edb: encrypteddb.New(g.ExternalG(), dbFn, keyFn), } @@ -157,41 +148,64 @@ func (r *ChatArchiveRegistry) flushLocked(ctx context.Context) error { return nil } -func (r *ChatArchiveRegistry) flushLoop(stopCh chan struct{}) error { +func (r *ChatArchiveRegistry) flush(ctx context.Context) { + var err error + defer r.Trace(ctx, &err, "flush")() + r.Lock() + defer r.Unlock() + err = r.flushLocked(ctx) +} + +func (r *ChatArchiveRegistry) bgPauseAllJobs(ctx context.Context) { + r.Lock() + defer r.Unlock() + _ = 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 state != keybase1.MobileAppState_FOREGROUND { + 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) + 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 state == keybase1.MobileAppState_FOREGROUND { + resume.Reset(r.resumeJobsDelay) + } else { + resume.Stop() + r.bgPauseAllJobs(ctx) + } + 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): - } - if r.beforeResumeDecision != nil { - r.beforeResumeDecision() - } r.Lock() defer r.Unlock() // Stop closes stopCh under this lock, so a closed channel here means this @@ -202,8 +216,6 @@ func (r *ChatArchiveRegistry) resumeAllBgJobs(ctx context.Context, stopCh chan s return nil default: } - // Decide under the lock the monitor pauses under: a pause either comes - // first and is seen here, or bumps pauseEpoch and pauses what launches. if state := r.G().MobileAppState.State(); state != keybase1.MobileAppState_FOREGROUND { r.Debug(ctx, "resumeAllBgJobs: not resuming in %v", state) return nil @@ -228,7 +240,8 @@ func (r *ChatArchiveRegistry) launchLocked(ctx context.Context, req chat1.Archiv r.Debug(ctx, "launch: %v is already starting", jobID) return } - launch := &archiveLaunch{pauseEpoch: r.pauseEpoch} + r.lastLaunch++ + launch := r.lastLaunch r.launching[jobID] = launch uid, runJob := r.uid, r.runJob go func() { @@ -250,43 +263,8 @@ func (r *ChatArchiveRegistry) launchLocked(ctx context.Context, req chat1.Archiv }() } -func (r *ChatArchiveRegistry) monitorAppState(w *libkb.AppStateWatcher, stopCh chan struct{}, - eg *errgroup.Group, state keybase1.MobileAppState, cancelInitialResume context.CancelFunc, -) error { - // cancelResume cancels the resume scheduled for the last FOREGROUND. - cancelResume := cancelInitialResume - defer func() { cancelResume() }() - w.Run(state, stopCh, func(state keybase1.MobileAppState) bool { - r.Debug(context.Background(), "monitorAppState: next state -> %v", state) - cancelResume() - switch state { - case keybase1.MobileAppState_FOREGROUND: - ctx, cancel := context.WithCancel(context.Background()) - cancelResume = cancel - eg.Go(func() error { - if err := r.resumeAllBgJobs(ctx, stopCh); err != nil { - r.Debug(ctx, err.Error()) - } - return nil - }) - default: - cancelResume = func() {} - func() { - ctx := context.Background() - var err error - defer r.Trace(ctx, &err, "monitorAppState")() - r.Lock() - defer r.Unlock() - err = r.bgPauseAllJobsLocked(ctx) - }() - } - return true - }) - return nil -} - // Resumes previously BACKGROUND_PAUSED jobs, after a delay, if the app is in -// the foreground by then. +// the foreground. func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { defer r.Trace(ctx, nil, "Start")() r.Lock() @@ -298,25 +276,15 @@ func (r *ChatArchiveRegistry) Start(ctx context.Context, uid gregor1.UID) { r.started = true r.stopCh = make(chan struct{}) r.eg = new(errgroup.Group) - stopCh, eg := r.stopCh, r.eg + stopCh := r.stopCh state := r.G().MobileAppState.State() - r.watcher = r.G().MobileAppState.NewWatcher() - w := r.watcher - resumeCtx, cancelResume := context.WithCancel(context.Background()) - eg.Go(func() error { - return r.flushLoop(stopCh) - }) - eg.Go(func() error { - return r.resumeAllBgJobs(resumeCtx, stopCh) - }) - eg.Go(func() error { - return r.monitorAppState(w, stopCh, eg, state, cancelResume) + r.eg.Go(func() error { + return r.loop(stopCh, state) }) } func (r *ChatArchiveRegistry) bgPauseAllJobsLocked(ctx context.Context) (err error) { defer r.Trace(ctx, &err, "bgPauseAllJobsLocked")() - r.pauseEpoch++ err = r.initLocked(ctx) if err != nil { return err @@ -354,7 +322,6 @@ func (r *ChatArchiveRegistry) Stop(ctx context.Context) chan struct{} { } r.started = false close(r.stopCh) - r.watcher = nil eg := r.eg go func() { r.Debug(context.Background(), "Stop: waiting for shutdown") @@ -470,14 +437,15 @@ func (r *ChatArchiveRegistry) Set(ctx context.Context, cancel types.PauseArchive if cancel == nil { break } - if launch, ok := r.launching[jobID]; ok { - delete(r.launching, jobID) - if launch.pauseEpoch != r.pauseEpoch { - r.Debug(ctx, "Set: %v was paused while starting", jobID) - cancel() - job.Status = chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED - 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(); state != keybase1.MobileAppState_FOREGROUND { + r.Debug(ctx, "Set: pausing %v in %v", jobID, state) + cancel() + job.Status = chat1.ArchiveChatJobStatus_BACKGROUND_PAUSED + break } r.runningJobs[jobID] = cancel } diff --git a/go/chat/archive_appstate_test.go b/go/chat/archive_appstate_test.go index f90ea828b5fe..24cb8ce0f7c9 100644 --- a/go/chat/archive_appstate_test.go +++ b/go/chat/archive_appstate_test.go @@ -19,7 +19,6 @@ import ( "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/sync/errgroup" ) // archiveJobRunner stands in for ChatArchiver: a launched job waits for @@ -113,20 +112,6 @@ func archiveStatuses(r *ChatArchiveRegistry) (statuses map[chat1.ArchiveJobID]ch return statuses, len(r.runningJobs) } -func waitArchiveMonitor(t *testing.T, r *ChatArchiveRegistry) { - t.Helper() - require.Eventually(t, func() bool { - r.Lock() - w := r.watcher - r.Unlock() - if w == nil { - return false - } - _, caughtUp := w.CaughtUp() - return caughtUp - }, 10*time.Second, time.Millisecond, "monitor did not catch up") -} - func requireArchiveStopped(t *testing.T, r *ChatArchiveRegistry) { t.Helper() select { @@ -199,8 +184,80 @@ func TestArchiveConcurrentResumesLaunchOnce(t *testing.T) { requireArchiveJobsPaused(t, r, runner) } -// A pause that lands after a job launched, but before it registered, pauses -// it on registration. +// 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})) @@ -212,9 +269,7 @@ func TestArchivePauseBeforeRegistration(t *testing.T) { require.FailNow(t, "jobs did not launch") } } - waitArchiveMonitor(t, r) tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitArchiveMonitor(t, r) close(runner.release) requireArchiveJobsPaused(t, r, runner) @@ -222,90 +277,115 @@ func TestArchivePauseBeforeRegistration(t *testing.T) { requireArchiveJobsRunning(t, r) } -// A resume whose delay fired as its run stopped must not launch jobs in the +// 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. The context is left uncanceled: the stopped run's monitor may -// not have exited to cancel it yet. +// own schedule. func TestArchiveStaleResumeAfterRestart(t *testing.T) { - r, runner, _ := setupAppStateArchive(t, true) - oldStopCh := make(chan struct{}) + r, _, _ := setupAppStateArchive(t, true) + r.resumeJobsDelay = time.Hour + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) r.Lock() - r.started = true - r.stopCh = oldStopCh + oldStopCh := r.stopCh r.Unlock() - r.beforeResumeDecision = func() { - r.Lock() - r.started = false - close(oldStopCh) - r.Unlock() - r.resumeJobsDelay = time.Hour - r.Start(context.TODO(), gregor1.UID([]byte{5, 6, 7, 8})) - } - require.NoError(t, r.resumeAllBgJobs(context.Background(), oldStopCh)) + requireArchiveStopped(t, r) + r.Start(context.TODO(), gregor1.UID([]byte{5, 6, 7, 8})) defer requireArchiveStopped(t, r) - select { - case id := <-runner.launched: - require.FailNow(t, fmt.Sprintf("stale resume launched %v", id)) - case <-time.After(300 * time.Millisecond): - } + + 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 delay fires just as a plain Stop, with no Start following -// it, takes the lock must not launch jobs: there is no live run left to +// 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, runner, _ := setupAppStateArchive(t, true) - stopCh := make(chan struct{}) + r, _, _ := setupAppStateArchive(t, true) + r.resumeJobsDelay = time.Hour + r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) r.Lock() - r.started = true - r.stopCh = stopCh - r.eg = new(errgroup.Group) + stopCh := r.stopCh r.Unlock() + requireArchiveStopped(t, r) - reached := make(chan struct{}) - release := make(chan struct{}) - r.beforeResumeDecision = func() { - close(reached) - <-release - } - - resumeDone := make(chan error, 1) - go func() { - resumeDone <- r.resumeAllBgJobs(context.Background(), stopCh) - }() + require.NoError(t, r.resumeAllBgJobs(context.Background(), stopCh)) + r.Lock() + defer r.Unlock() + require.Empty(t, r.launching, "resumed after a plain Stop") +} - <-reached - // Stop takes the lock first, because the resume goroutine is parked in - // beforeResumeDecision, not yet at r.Lock(). - stopDone := make(chan struct{}) - go func() { - <-r.Stop(context.Background()) - close(stopDone) - }() - select { - case <-stopDone: - case <-time.After(10 * time.Second): - require.FailNow(t, "Stop did not finish") +// 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 } - close(release) + stopCh := make(chan struct{}) + r.Lock() + r.started = true + r.stopCh = stopCh + r.Unlock() + defer close(stopCh) + ctx := context.Background() - // Before the fix, this check's identity comparison can't tell a plain - // Stop apart from a still-live run (Stop closes stopCh but never - // replaces it), so resumeAllBgJobs falls through to initLocked and gets - // a spurious "not started" error instead of cleanly recognizing the run - // is over. + 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 err := <-resumeDone: - require.NoError(t, err) + case <-secondLaunched: case <-time.After(10 * time.Second): - require.FailNow(t, "resumeAllBgJobs did not return") + require.FailNow(t, "second launch did not start") } - select { - case id := <-runner.launched: - require.FailNow(t, fmt.Sprintf("resumed %v after a plain Stop", id)) - case <-time.After(500 * time.Millisecond): - } + 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) { @@ -317,7 +397,6 @@ func TestArchiveStartInBackgroundDoesNotResume(t *testing.T) { } { tc.G.MobileAppState.Update(state) r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) - waitArchiveMonitor(t, r) select { case id := <-runner.launched: require.FailNow(t, fmt.Sprintf("resumed %v at a Start in %v", id, state)) @@ -328,7 +407,6 @@ func TestArchiveStartInBackgroundDoesNotResume(t *testing.T) { r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) defer requireArchiveStopped(t, r) - waitArchiveMonitor(t, r) tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) requireArchiveJobsRunning(t, r) launches, _ := runner.counts() @@ -344,7 +422,6 @@ func TestArchiveScenarioReplay(t *testing.T) { 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) { - waitArchiveMonitor(t, r) if step.Want == keybase1.MobileAppState_FOREGROUND { requireArchiveJobsRunning(t, r) } else { @@ -355,28 +432,8 @@ func TestArchiveScenarioReplay(t *testing.T) { } } -// Each FOREGROUND schedules a resume that the next transition cancels while -// it waits out its delay; the canceled resume must still see its own context. -func TestArchiveCanceledResumesKeepTheirContext(t *testing.T) { - r, runner, tc := setupAppStateArchive(t, true) - r.resumeJobsDelay = time.Hour - r.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) - for range 20 { - for _, state := range []keybase1.MobileAppState{ - keybase1.MobileAppState_INACTIVE, - keybase1.MobileAppState_FOREGROUND, - } { - tc.G.MobileAppState.Update(state) - waitArchiveMonitor(t, r) - } - } - requireArchiveStopped(t, r) - launches, _ := runner.counts() - require.Empty(t, launches) -} - -// Rapid transitions race resumes against pauses and the monitor's resume -// contexts against the goroutines using them. +// 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. @@ -431,7 +488,6 @@ func TestArchiveAppStateStress(t *testing.T) { r.Start(context.TODO(), uid) tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitArchiveMonitor(t, r) requireArchiveJobsPaused(t, r, runner) tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) requireArchiveJobsRunning(t, r) From c89ef527593f6b3ac205ecfe9ed798dea4732731 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 17:56:44 -0400 Subject: [PATCH 099/127] refactor(chat): convloader Start waits for the previous run and reads app state in its loop --- go/chat/convloader.go | 334 ++++++++++++------------ go/chat/convloader_appstate_test.go | 380 ++++++++++++++++++++++------ go/chat/convloader_test.go | 19 +- 3 files changed, 470 insertions(+), 263 deletions(-) diff --git a/go/chat/convloader.go b/go/chat/convloader.go index 4cdb01d11433..b0068cafa0c1 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -117,17 +117,20 @@ type BackgroundConvLoader struct { uid gregor1.UID started bool - queue *jobQueue - stopCh chan struct{} - // suspendCh belongs to the current run, so a loop of a stopped run - // cannot take a suspension meant for its successor. - suspendCh chan chan struct{} + // 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 holds the current run's goroutines. 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 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 @@ -136,16 +139,10 @@ type BackgroundConvLoader struct { activeLoads map[string]activeLoad suspendCount int - // appSuspended is the app-state monitor's own suspension, kept apart - // from suspendCount so an unbalanced Resume cannot release it. - appSuspended bool - // watcher is the current run's app-state watcher, nil between runs. - watcher *libkb.AppStateWatcher // 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) @@ -155,6 +152,7 @@ func NewBackgroundConvLoader(g *globals.Context) *BackgroundConvLoader { Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "BackgroundConvLoader", false), stopCh: make(chan struct{}), + suspendCh: make(chan struct{}, 1), eg: new(errgroup.Group), identNotifier: NewCachingIdentifyNotifier(g), clock: clockwork.NewRealClock(), @@ -183,117 +181,62 @@ func suspendInAppState(state keybase1.MobileAppState) bool { return state == keybase1.MobileAppState_BACKGROUND } -func (b *BackgroundConvLoader) setAppStateLocked(ctx context.Context, state keybase1.MobileAppState) { - suspend := suspendInAppState(state) - if suspend == b.appSuspended { +// 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) { + if b.G().GetEnv().GetDisableBgConvLoader() { + b.Debug(ctx, "BackgroundConvLoader disabled, aborting Start") return } - wasSuspended := b.suspendedLocked() - b.appSuspended = suspend - if suspend { - b.Debug(ctx, "setAppState: suspending load thread in %v", state) - b.cancelActiveLoadsLocked() - } else { - b.Debug(ctx, "setAppState: resuming load thread in %v", state) - } - b.signalSuspendLocked(ctx, wasSuspended) -} + b.Debug(ctx, "Start") + b.Lock() + b.gen++ + gen := b.gen + prevRun := b.endRunLocked() + b.Unlock() -func (b *BackgroundConvLoader) monitorAppState(w *libkb.AppStateWatcher, stopCh chan struct{}, - state keybase1.MobileAppState, -) error { - ctx := context.Background() - b.Debug(ctx, "monitorAppState: starting up in %v", state) - w.Run(state, stopCh, func(keybase1.MobileAppState) bool { - b.Lock() - // Stop closes stopCh under this lock, so a closed channel here means - // this run is over. - select { - case <-stopCh: - b.Unlock() - return false - default: - } - // Read and apply under the lock, so Start and Stop never interleave - // with a decision made on a stale state. - b.setAppStateLocked(ctx, b.G().MobileAppState.State()) - b.Unlock() - if b.appStateCh != nil { - select { - case b.appStateCh <- struct{}{}: - case <-stopCh: - return false - } - } - return true - }) - b.Debug(ctx, "monitorAppState: shutting down") - return nil -} + // The previous run's goroutines take b's lock, so wait for them outside it. + _ = prevRun.Wait() -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") + if b.gen != gen { + b.Debug(ctx, "Start: overtaken by a later Start or Stop") return } - b.Debug(ctx, "Start") - var prevRun *errgroup.Group - if b.started { - prevRun = b.endRunLocked() - } b.newQueue() b.started = true b.uid = uid + b.eg = new(errgroup.Group) stopCh, eg, queue, loadCh := b.stopCh, b.eg, b.queue, b.loadCh - if prevRun != nil { - // Stop waits for the replaced run too. - eg.Go(prevRun.Wait) - } - b.suspendCh = make(chan chan struct{}, 10) - suspendCh := b.suspendCh - // Hand a suspension that outlived the last run to this run's loop. - if b.suspendedLocked() && b.resumeCh != nil { - suspendCh <- b.resumeCh - } - state := b.G().MobileAppState.State() - b.setAppStateLocked(ctx, state) - b.watcher = b.G().MobileAppState.NewWatcher() - w := b.watcher - eg.Go(func() error { return b.loop(uid, stopCh, suspendCh, queue, loadCh) }) + eg.Go(func() error { return b.loop(uid, stopCh, queue, loadCh) }) eg.Go(func() error { return b.loadLoop(uid, stopCh, queue, loadCh) }) - eg.Go(func() error { return b.monitorAppState(w, stopCh, state) }) } -// endRunLocked stops the current run's goroutines and returns their group. -// The app-state suspension is left as is; the next Start seeds it again. +// 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 { - eg := b.eg - b.started = false - close(b.stopCh) - b.stopCh = make(chan struct{}) - b.eg = new(errgroup.Group) - b.watcher = nil - return eg + 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 { - eg := b.endRunLocked() - go func() { - _ = eg.Wait() - close(ch) - }() - } else { + go func() { + _ = eg.Wait() close(ch) - } + }() return ch } @@ -326,30 +269,6 @@ func (b *BackgroundConvLoader) cancelActiveLoadsLocked() (canceled bool) { return canceled } -func (b *BackgroundConvLoader) suspendedLocked() bool { - return b.suspendCount > 0 || b.appSuspended -} - -// signalSuspendLocked tells the loop about a change in suspension, given -// whether it was suspended before the change. -func (b *BackgroundConvLoader) signalSuspendLocked(ctx context.Context, wasSuspended bool) { - suspended := b.suspendedLocked() - switch { - case suspended && !wasSuspended: - b.Debug(ctx, "Suspend: sending on suspendCh") - b.resumeCh = make(chan struct{}) - select { - case b.suspendCh <- b.resumeCh: - default: - b.Debug(ctx, "Suspend: failed to suspend loop") - } - case !suspended && wasSuspended && b.resumeCh != nil: - b.Debug(ctx, "Resume: closing resumeCh") - close(b.resumeCh) - b.resumeCh = nil - } -} - func (b *BackgroundConvLoader) Suspend(ctx context.Context) (canceled bool) { defer b.Trace(ctx, nil, "Suspend")() b.Lock() @@ -357,9 +276,15 @@ func (b *BackgroundConvLoader) Suspend(ctx context.Context) (canceled bool) { if !b.started { return false } - wasSuspended := b.suspendedLocked() + if b.suspendCount == 0 { + b.Debug(ctx, "Suspend: waking loop") + b.resumeCh = make(chan struct{}) + select { + case b.suspendCh <- struct{}{}: + default: + } + } b.suspendCount++ - b.signalSuspendLocked(ctx, wasSuspended) return b.cancelActiveLoadsLocked() } @@ -370,10 +295,17 @@ func (b *BackgroundConvLoader) Resume(ctx context.Context) bool { if b.suspendCount == 0 { return false } - wasSuspended := b.suspendedLocked() b.suspendCount-- - b.signalSuspendLocked(ctx, wasSuspended) - return b.suspendCount == 0 + if b.suspendCount > 0 { + return false + } + b.Debug(ctx, "Resume: closing resumeCh") + close(b.resumeCh) + return true +} + +func (b *BackgroundConvLoader) suspendedLocked() bool { + return b.suspendCount > 0 || suspendInAppState(b.G().MobileAppState.State()) } func (b *BackgroundConvLoader) isSuspended() bool { @@ -394,15 +326,9 @@ func (b *BackgroundConvLoader) enqueue(ctx context.Context, task clTask) error { return b.push(ctx, b.queue, task) } -// requeue puts a task back on the queue of the run that loaded it, and drops -// it once that run has stopped, so it never reaches a later run (or user). -func (b *BackgroundConvLoader) requeue(ctx context.Context, stopCh chan struct{}, queue *jobQueue, task clTask) { - select { - case <-stopCh: - b.Debug(ctx, "requeue: run stopped, dropping task: %s", task.job) - return - default: - } +// 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) } @@ -420,29 +346,71 @@ func (b *BackgroundConvLoader) push(ctx context.Context, queue *jobQueue, task c return nil } -func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}, suspendCh chan chan struct{}, - queue *jobQueue, loadCh chan *clTask, +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 @@ -468,11 +436,18 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}, suspe // neither have any data on them. select { case <-b.clock.After(duration): - case ch := <-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 { @@ -480,9 +455,13 @@ func (b *BackgroundConvLoader) loop(uid gregor1.UID, stopCh chan struct{}, suspe default: b.Debug(bgctx, "loop: failed to dispatch load, queue full") } - case ch := <-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: @@ -500,22 +479,15 @@ func (b *BackgroundConvLoader) loadLoop(uid gregor1.UID, stopCh chan struct{}, q for { select { 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 - default: - } - if b.isSuspended() { - b.Debug(bgctx, "loadLoop: suspended, re-enqueueing task: %s", task.job) - b.requeue(bgctx, stopCh, queue, *task) - } else { - b.Debug(bgctx, "loadLoop: running task: %s", task.job) - if nextTask := b.load(bgctx, *task, uid); nextTask != nil { - b.requeue(bgctx, stopCh, queue, *nextTask) - } } - b.clock.Sleep(b.loadWait) case <-stopCh: b.Debug(bgctx, "loadLoop: shutting down for %s", uid) return nil @@ -549,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 index 7d96c5cc5296..b8beaa63a212 100644 --- a/go/chat/convloader_appstate_test.go +++ b/go/chat/convloader_appstate_test.go @@ -58,20 +58,6 @@ func setupAppStateConvLoader(t *testing.T) (*BackgroundConvLoader, *pullRecorder return b, pulls, tc } -func waitConvLoaderMonitor(t *testing.T, b *BackgroundConvLoader) { - t.Helper() - require.Eventually(t, func() bool { - b.Lock() - w := b.watcher - b.Unlock() - if w == nil { - return false - } - _, caughtUp := w.CaughtUp() - return caughtUp - }, 10*time.Second, time.Millisecond, "monitor did not catch up") -} - func requireConvLoaderStopped(t *testing.T, b *BackgroundConvLoader) { t.Helper() select { @@ -88,36 +74,120 @@ func convLoaderTestJob() types.ConvLoaderJob { types.ConvLoaderPriorityHigh, types.ConvLoaderGeneric, nil) } -func TestConvLoaderMonitorSurvivesStopStart(t *testing.T) { +// 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.False(t, b.isSuspended(), "run %d: suspended at a foreground Start", i) - waitConvLoaderMonitor(t, b) + require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) + load := requirePull(t, pulls) appState.Update(keybase1.MobileAppState_BACKGROUND) - waitConvLoaderMonitor(t, b) require.True(t, b.isSuspended(), "run %d: not suspended in BACKGROUND", i) + requireCanceled(i, load) appState.Update(keybase1.MobileAppState_INACTIVE) - waitConvLoaderMonitor(t, b) require.False(t, b.isSuspended(), "run %d: suspended in INACTIVE", i) + load = requirePull(t, pulls) appState.Update(keybase1.MobileAppState_BACKGROUND) - waitConvLoaderMonitor(t, b) - require.True(t, b.isSuspended(), "run %d: not suspended in BACKGROUND", i) + requireCanceled(i, load) requireConvLoaderStopped(t, b) - // A Start in BACKGROUND seeds its suspension before any change. + // A run started in BACKGROUND loads nothing until the app leaves it. b.Start(context.TODO(), uid) require.True(t, b.isSuspended(), "run %d: not suspended at a background Start", i) - waitConvLoaderMonitor(t, b) + 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) - waitConvLoaderMonitor(t, b) require.False(t, b.isSuspended(), "run %d: suspended in BACKGROUNDACTIVE", i) + load = requirePull(t, pulls) + appState.Update(keybase1.MobileAppState_BACKGROUND) + requireCanceled(i, load) requireConvLoaderStopped(t, b) } } @@ -161,67 +231,117 @@ func TestConvLoaderResumeKeepsAppStateSuspension(t *testing.T) { require.True(t, b.isSuspended()) } -// A Start over a running loader replaces its run; Stop still waits for the -// replaced run's goroutines. -func TestConvLoaderStopWaitsForReplacedRun(t *testing.T) { +// 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) - clock := clockwork.NewFakeClock() - b.clock = clock + 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())) - // the first run's loop has pulled the job and waits out its delay - clock.BlockUntil(1) - b.Start(context.TODO(), uid) + 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 replaced run was still running") + require.FailNow(t, "Stop finished while the previous run was still running") case <-time.After(200 * time.Millisecond): } - clock.Advance(time.Second) - select { - case <-stopped: - case <-time.After(10 * time.Second): - require.FailNow(t, "Stop did not finish") + 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) { - b, _, tc := setupAppStateConvLoader(t) - clock := clockwork.NewFakeClock() - b.clock = clock uid := gregor1.UID([]byte{1, 2, 3, 4}) - tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - b.Start(context.TODO(), uid) - requireConvLoaderStopped(t, b) - b.Start(context.TODO(), uid) - defer requireConvLoaderStopped(t, b) - require.Eventually(t, func() bool { - b.Lock() - defer b.Unlock() - return len(b.suspendCh) == 0 - }, 10*time.Second, time.Millisecond, "loop did not take the suspension") + 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): + 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") + }) } - 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 @@ -263,9 +383,18 @@ func TestConvLoaderReplacedRunRetryStaysInItsRun(t *testing.T) { case <-time.After(10 * time.Second): require.FailNow(t, "old run did not load") } - b.Start(context.TODO(), newUID) + 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) @@ -285,7 +414,6 @@ func TestConvLoaderScenarioReplay(t *testing.T) { b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) defer requireConvLoaderStopped(t, b) lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { - waitConvLoaderMonitor(t, b) if got, want := b.isSuspended(), step.Want == keybase1.MobileAppState_BACKGROUND; got != want { t.Fatalf("step %d %v: suspended %v in %v", i, step.Do, got, step.Want) } @@ -295,7 +423,7 @@ func TestConvLoaderScenarioReplay(t *testing.T) { } func TestConvLoaderAppStateStress(t *testing.T) { - b, _, tc := setupAppStateConvLoader(t) + b, pulls, tc := setupAppStateConvLoader(t) baseline := runtime.NumGoroutine() uid := gregor1.UID([]byte{1, 2, 3, 4}) states := []keybase1.MobileAppState{ @@ -345,11 +473,17 @@ func TestConvLoaderAppStateStress(t *testing.T) { for _, state := range states { tc.G.MobileAppState.Update(state) b.Start(context.TODO(), uid) - waitConvLoaderMonitor(t, b) - b.Lock() - appSuspended := b.appSuspended - b.Unlock() - require.Equal(t, state == keybase1.MobileAppState_BACKGROUND, appSuspended, "in %v", state) + require.Equal(t, state == keybase1.MobileAppState_BACKGROUND, b.isSuspended(), "in %v", state) + } + // 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) @@ -365,3 +499,101 @@ func requireNoGoroutineLeak(t *testing.T, baseline int) { } 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 91a11479acb6..e152b79e84b5 100644 --- a/go/chat/convloader_test.go +++ b/go/chat/convloader_test.go @@ -134,14 +134,12 @@ func TestConvLoaderAppState(t *testing.T) { defer world.Cleanup() clock := clockwork.NewFakeClock() - appStateCh := make(chan struct{}) 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.appStateCh = appStateCh loader.Start(context.TODO(), uid) ri := tc.ChatG.ConvSource.(*HybridConversationSource).ri _ = ri @@ -164,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: @@ -203,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: From 088a5f638559d93589494b3450ffe0733e1b9c4e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 18:27:17 -0400 Subject: [PATCH 100/127] refactor(libkb): leveldb cleaner watches app state inline; drop the flush coalescing guard --- go/libkb/leveldb.go | 77 +++----- go/libkb/leveldb_cleaner.go | 108 ++++------- go/libkb/leveldb_cleaner_test.go | 321 +++++++------------------------ go/libkb/leveldb_test.go | 39 ++-- 4 files changed, 148 insertions(+), 397 deletions(-) diff --git a/go/libkb/leveldb.go b/go/libkb/leveldb.go index 0e73ea161c3e..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,16 +119,14 @@ 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 - // dbMu guards the lazy open's assignment of db, which runs under the read - // lock, against readers that don't go through dbOpenerOnce. - dbMu sync.Mutex - cleaner *levelDbCleaner + cleaner *levelDbCleaner - flushMu sync.Mutex - flushRunning bool - flushRerun bool // flushHook, if set, runs after each memtable rotation. Tests only. flushHook func() @@ -189,9 +188,7 @@ func (l *LevelDb) doWhileOpenAndNukeIfCorrupted(action func() error) (err error) } } l.G().Log.Debug("- LevelDb.open -> %s", ErrToOk(err)) - l.dbMu.Lock() - l.db = db - l.dbMu.Unlock() + l.db.Store(db) if db != nil { l.cleaner.start(db) } @@ -201,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{} @@ -227,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) } @@ -251,39 +248,19 @@ func (l *LevelDb) ForceOpen() error { // opens fast. No-op if the DB is not currently open; does not trigger a lazy // open. // -// Flushes of one DB never overlap. A call that arrives while a flush is -// running returns immediately and makes the running flush go around once -// more, so writes made before that call still get flushed. +// 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) { - l.flushMu.Lock() - if l.flushRunning { - l.flushRerun = true - l.flushMu.Unlock() - return nil - } - l.flushRunning = true - l.flushMu.Unlock() - - for { - err = l.flushMemtable() - l.flushMu.Lock() - if err != nil || !l.flushRerun { - l.flushRunning = false - l.flushRerun = false - l.flushMu.Unlock() - return err - } - l.flushRerun = false - l.flushMu.Unlock() - } + 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 { - l.dbMu.Lock() - defer l.dbMu.Unlock() - return l.db + return l.db.Load() } func (l *LevelDb) flushMemtable() (err error) { @@ -310,7 +287,7 @@ func (l *LevelDb) flushMemtable() (err error) { 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 { @@ -322,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 } @@ -344,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. @@ -421,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 @@ -435,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 @@ -443,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) }) } @@ -470,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 913d2501b24a..c72bc2bc29f2 100644 --- a/go/libkb/leveldb_cleaner.go +++ b/go/libkb/leveldb_cleaner.go @@ -60,37 +60,28 @@ 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{} - // monitoring is whether an app-state monitor runs for the current stopCh, - // and watcher is that monitor's watcher. - monitoring bool - watcher *AppStateWatcher - // monitors counts running monitor goroutines; tests use it. - monitors int + 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) @@ -99,13 +90,11 @@ func newLevelDbCleanerWithConfig(mctx MetaContext, dbName string, config DbClean 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{}), } } @@ -128,63 +117,22 @@ func (c *levelDbCleaner) Stop() { close(c.stopCh) c.stopCh = make(chan struct{}) } - c.monitoring = false - c.watcher = nil } // start attaches the cleaner to a newly opened db, undoing a previous -// Stop/Shutdown from closing it, and on mobile starts the app-state monitor. +// 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 } } - c.cacheMu.Unlock() - if !c.isMobile || c.monitoring { - return - } - c.monitoring = true - c.monitors++ - c.watcher = c.G().MobileAppState.NewWatcher() - go c.monitorAppState(c.watcher, c.stopCh, c.G().MobileAppState.State()) -} - -// monitorAppState cancels a running clean whenever the app moves to any state -// other than BACKGROUNDACTIVE. A clean may start in any state; it keeps -// running only across a transition into BACKGROUNDACTIVE, so it gives way -// when the app comes to the foreground and before it is suspended. -func (c *levelDbCleaner) monitorAppState(w *AppStateWatcher, stopCh chan struct{}, state keybase1.MobileAppState) { - c.log("monitorAppState: starting in %v", state) - defer func() { - c.log("monitorAppState: stop") - c.Lock() - defer c.Unlock() - c.monitors-- - }() - w.Run(state, stopCh, func(state keybase1.MobileAppState) bool { - if state == keybase1.MobileAppState_BACKGROUNDACTIVE { - return true - } - c.log("monitorAppState: attempting cancel, state: %v", state) - c.Lock() - defer c.Unlock() - // Stop closes stopCh under this lock, so a closed channel here means - // this run is over. - select { - case <-stopCh: - return false - default: - } - close(c.cancelCh) - c.cancelCh = make(chan struct{}) - return true - }) } func (c *levelDbCleaner) log(format string, args ...any) { @@ -242,7 +190,6 @@ func (c *levelDbCleaner) clean(force bool) (err error) { c.running = true key := c.lastKey stopCh := c.stopCh - cancelCh := c.cancelCh c.Unlock() defer c.M().Trace(fmt.Sprintf("levelDbCleaner(%s) clean, config: %v", c.dbName, c.config), &err)() @@ -266,15 +213,26 @@ func (c *levelDbCleaner) clean(force bool) (err error) { return nil } + // 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) + 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 index 6bb4ae68f26d..0605a539537e 100644 --- a/go/libkb/leveldb_cleaner_test.go +++ b/go/libkb/leveldb_cleaner_test.go @@ -1,16 +1,15 @@ package libkb import ( + stderrors "errors" "fmt" "path/filepath" - "runtime" - "sync" "testing" "time" - "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" + "github.com/syndtr/goleveldb/leveldb" ) // newMobileCleanerDb makes a LevelDb whose cleaner behaves as on mobile. The @@ -18,7 +17,7 @@ import ( 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, true) + db.cleaner = newLevelDbCleanerWithConfig(NewMetaContextTODO(tc.G), "test", config) t.Cleanup(func() { _ = db.Close() }) return db } @@ -29,46 +28,6 @@ func testCleanerConfig() DbCleanerConfig { return config } -func (c *levelDbCleaner) snapshot() (cancelCh chan struct{}, monitors int) { - c.Lock() - defer c.Unlock() - return c.cancelCh, c.monitors -} - -// cleanerWatcher returns the watcher of the cleaner's current monitor, and -// nil unless exactly one monitor runs. -func (c *levelDbCleaner) cleanerWatcher() *AppStateWatcher { - c.Lock() - defer c.Unlock() - if c.monitors != 1 { - return nil - } - return c.watcher -} - -// waitCleanerMonitor waits until the cleaner's monitor has acted on the -// current state and is waiting for the next change. -func waitCleanerMonitor(t *testing.T, c *levelDbCleaner) { - t.Helper() - require.Eventually(t, func() bool { - w := c.cleanerWatcher() - if w == nil { - return false - } - _, caughtUp := w.CaughtUp() - return caughtUp - }, 10*time.Second, time.Millisecond, "cleaner monitor did not catch up") -} - -func isClosed(ch chan struct{}) bool { - select { - case <-ch: - return true - default: - return false - } -} - var cleanerStates = []keybase1.MobileAppState{ keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE, @@ -76,233 +35,99 @@ var cleanerStates = []keybase1.MobileAppState{ keybase1.MobileAppState_BACKGROUND, } -// requireCancelOnTransition moves to next and checks that a clean running -// across the transition is canceled unless next is BACKGROUNDACTIVE. -func requireCancelOnTransition(t *testing.T, db *LevelDb, next keybase1.MobileAppState) { +// putKeys writes numKeys keys under db and returns the first and last ones. +func putKeys(t *testing.T, db *LevelDb, numKeys int) (first, last DbKey) { t.Helper() - cancelCh, _ := db.cleaner.snapshot() - db.G().MobileAppState.Update(next) - waitCleanerMonitor(t, db.cleaner) - want := next != keybase1.MobileAppState_BACKGROUNDACTIVE - require.Equal(t, want, isClosed(cancelCh), "transition to %v", next) -} - -func TestLevelDbCleanerCancelsOutsideBackgroundActive(t *testing.T) { - tc := SetupTest(t, "LevelDb-cleaner-cancel", 0) - defer tc.Cleanup() - db := newMobileCleanerDb(t, &tc, testCleanerConfig()) - require.NoError(t, db.ForceOpen()) - waitCleanerMonitor(t, db.cleaner) - for _, from := range cleanerStates { - for _, to := range cleanerStates { - if from == to { - continue - } - tc.G.MobileAppState.Update(from) - waitCleanerMonitor(t, db.cleaner) - requireCancelOnTransition(t, db, to) + for i := range numKeys { + key := DbKey{Key: fmt.Sprintf("k%05d", i), Typ: 0} + require.NoError(t, db.Put(key, nil, []byte{1})) + if i == 0 { + first = key } + last = key } + return first, last } -// A clean in progress stops at a transition out of BACKGROUNDACTIVE and runs -// to completion across a transition into it. -func TestLevelDbCleanerRunningCleanFollowsAppState(t *testing.T) { +// waitFirstBatchPurged waits until a running clean has purged firstKey, its +// oldest key. clean() samples the app state once, before its first batch; +// waiting for that first batch orders a test's own app-state update after +// that sample, so the update is guaranteed to be seen as a real change +// instead of racing the sample itself. +// +// It reads the raw db rather than going through LevelDb.Get, which would +// mark firstKey recently-used and make the cleaner skip deleting it -- the +// very thing being waited for. +func waitFirstBatchPurged(t *testing.T, db *LevelDb, firstKey DbKey) { + t.Helper() + require.Eventually(t, func() bool { + _, err := db.db.Load().Get(firstKey.ToBytes(), nil) + return stderrors.Is(err, leveldb.ErrNotFound) + }, 10*time.Second, time.Millisecond, "clean did not purge its first batch") +} + +// 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-running", 0) + tc := SetupTest(t, "LevelDb-cleaner-stop", 0) defer tc.Cleanup() config := testCleanerConfig() config.SleepInterval = 100 * time.Millisecond db := newMobileCleanerDb(t, &tc, config) - start := keybase1.MobileAppState_INACTIVE - if next == start { - start = keybase1.MobileAppState_FOREGROUND - } - tc.G.MobileAppState.Update(start) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + const numKeys = 3500 - for i := range numKeys { - require.NoError(t, db.Put(DbKey{Key: fmt.Sprintf("k%05d", i), Typ: 0}, nil, []byte{1})) - } - waitCleanerMonitor(t, db.cleaner) + firstKey, lastKey := putKeys(t, db, numKeys) db.cleaner.clearCache() done := make(chan error, 1) go func() { done <- db.cleaner.clean(true /* force */) }() - require.Eventually(t, func() bool { - db.cleaner.Lock() - defer db.cleaner.Unlock() - return db.cleaner.running - }, 10*time.Second, time.Millisecond) + waitFirstBatchPurged(t, db, firstKey) + tc.G.MobileAppState.Update(next) - waitCleanerMonitor(t, db.cleaner) require.NoError(t, <-done) - _, found, err := db.Get(DbKey{Key: fmt.Sprintf("k%05d", numKeys-1), Typ: 0}) + _, found, err := db.Get(lastKey) require.NoError(t, err) - require.Equal(t, next != keybase1.MobileAppState_BACKGROUNDACTIVE, found, - "last key after a clean across a transition to %v", next) + require.True(t, found, "a clean canceled by leaving BACKGROUNDACTIVE should not reach the last key") }) } } -func TestLevelDbCleanerSeedsFromState(t *testing.T) { - for _, initial := range cleanerStates { - t.Run(initial.String(), func(t *testing.T) { - tc := SetupTest(t, "LevelDb-cleaner-seed", 0) - defer tc.Cleanup() - tc.G.MobileAppState.Update(initial) - db := newMobileCleanerDb(t, &tc, testCleanerConfig()) - cancelCh, monitors := db.cleaner.snapshot() - require.Zero(t, monitors, "monitor running before the db opened") - require.NoError(t, db.ForceOpen()) - waitCleanerMonitor(t, db.cleaner) - require.False(t, isClosed(cancelCh), "canceled without a transition from %v", initial) - }) - } -} - -func TestLevelDbCleanerMonitorSurvivesReopen(t *testing.T) { - tc := SetupTest(t, "LevelDb-cleaner-reopen", 0) +// A clean keeps running, with no early return, for as long as the app state +// stays BACKGROUNDACTIVE, including across an unrelated update. NextUpdate +// only fires on a real change, so a same-value BACKGROUNDACTIVE update never +// wakes the batch loop at all; a collapsed BACKGROUNDACTIVE -> X -> +// BACKGROUNDACTIVE transition can't be produced deterministically, since the +// loop's poll may or may not land inside the window where the state reads as +// X. +func TestCleanerContinuesAcrossBackgroundActiveReentry(t *testing.T) { + tc := SetupTest(t, "LevelDb-cleaner-continue", 0) defer tc.Cleanup() - db := newMobileCleanerDb(t, &tc, testCleanerConfig()) - require.NoError(t, db.ForceOpen()) - waitCleanerMonitor(t, db.cleaner) - requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUND) - - reopens := map[string]func(){ - "nuke": func() { - _, err := db.Nuke() - require.NoError(t, err) - require.NoError(t, db.ForceOpen()) - }, - "close": func() { - 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()) - }, - } - for _, name := range []string{"nuke", "close", "nuke"} { - reopens[name]() - _, monitors := db.cleaner.snapshot() - require.Equal(t, 1, monitors, "after %s", name) - waitCleanerMonitor(t, db.cleaner) - requireCancelOnTransition(t, db, keybase1.MobileAppState_FOREGROUND) - requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUNDACTIVE) - requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUND) - - // A reopened cleaner cleans again. - 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) - } - require.NoError(t, db.Close()) - require.Eventually(t, func() bool { - _, monitors := db.cleaner.snapshot() - return monitors == 0 - }, 10*time.Second, time.Millisecond, "monitor outlived Close") -} - -func TestLevelDbCleanerScenarioReplay(t *testing.T) { - for _, sc := range lifecycletest.Scenarios { - t.Run(sc.Name, func(t *testing.T) { - tc := SetupTest(t, "LevelDb-cleaner-scenario", 0) - defer tc.Cleanup() - h := lifecycletest.NewHarness(t, tc.G.MobileAppState, sc.Platform) - defer h.Close() - db := newMobileCleanerDb(t, &tc, testCleanerConfig()) - require.NoError(t, db.ForceOpen()) - waitCleanerMonitor(t, db.cleaner) - prev := sc.Platform.InitialState() - for i, step := range sc.Steps { - cancelCh, _ := db.cleaner.snapshot() - h.Do(step) - waitCleanerMonitor(t, db.cleaner) - w := db.cleaner.cleanerWatcher() - require.NotNil(t, w, "step %d %v", i, step.Do) - acted, _ := w.CaughtUp() - require.Equal(t, step.Want, acted, "step %d %v", i, step.Do) - canceled := isClosed(cancelCh) - switch { - case step.Want != prev && step.Want != keybase1.MobileAppState_BACKGROUNDACTIVE: - require.True(t, canceled, "step %d %v: clean not canceled in %v", i, step.Do, step.Want) - case step.Want == keybase1.MobileAppState_BACKGROUNDACTIVE || step.Want == prev: - require.False(t, canceled, "step %d %v: clean canceled without a transition out of BACKGROUNDACTIVE", i, step.Do) - } - prev = step.Want - } - h.CheckObserved(sc.Observed) - }) - } -} - -// Nukes, closes and reopens racing app-state changes leave one working -// monitor while the db is open and none after it closes. -func TestLevelDbCleanerMonitorStress(t *testing.T) { - tc := SetupTest(t, "LevelDb-cleaner-stress", 0) - defer tc.Cleanup() - baseline := runtime.NumGoroutine() - db := newMobileCleanerDb(t, &tc, testCleanerConfig()) - - var wg sync.WaitGroup - stop := make(chan struct{}) - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; ; i++ { - select { - case <-stop: - return - default: - } - tc.G.MobileAppState.Update(cleanerStates[i%len(cleanerStates)]) - } - }() - for w := range 4 { - wg.Add(1) - go func() { - defer wg.Done() - for i := range 50 { - switch (w + i) % 3 { - case 0: - _, _ = db.Nuke() - case 1: - _ = db.Close() - default: - _ = db.Put(DbKey{Key: fmt.Sprintf("w%d-%d", w, i), Typ: 0}, nil, []byte{1}) - } - _ = db.ForceOpen() - } - }() - } - time.Sleep(10 * time.Millisecond) - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - for range 50 { - _ = db.ForceOpen() - } - }() - } - time.Sleep(200 * time.Millisecond) - close(stop) - wg.Wait() - - // A racing Close leaves one failed open before the next open succeeds. - require.Eventually(t, func() bool { return db.ForceOpen() == nil }, 10*time.Second, time.Millisecond) - tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) - waitCleanerMonitor(t, db.cleaner) - requireCancelOnTransition(t, db, keybase1.MobileAppState_BACKGROUND) - - require.NoError(t, db.Close()) - require.Eventually(t, func() bool { - _, monitors := db.cleaner.snapshot() - return monitors == 0 && runtime.NumGoroutine() <= baseline+5 - }, 10*time.Second, 10*time.Millisecond, "monitor or goroutines outlived Close") + config := testCleanerConfig() + config.SleepInterval = 100 * time.Millisecond + db := newMobileCleanerDb(t, &tc, config) + tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) + + const numKeys = 3500 + firstKey, lastKey := putKeys(t, db, numKeys) + db.cleaner.clearCache() + + done := make(chan error, 1) + go func() { done <- db.cleaner.clean(true /* force */) }() + waitFirstBatchPurged(t, db, firstKey) + + // An unrelated update that collapses to a no-op: still BACKGROUNDACTIVE, + // 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") } diff --git a/go/libkb/leveldb_test.go b/go/libkb/leveldb_test.go index 7f0a03a97f15..9ad5ecd8b3b1 100644 --- a/go/libkb/leveldb_test.go +++ b/go/libkb/leveldb_test.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "sync" - "sync/atomic" "testing" "time" @@ -73,7 +72,7 @@ func doSomeIO() error { func levelDbStats(t *testing.T, db *LevelDb) (stats leveldb.DBStats) { require.NoError(t, db.doWhileOpenAndNukeIfCorrupted(func() error { - return db.db.Stats(&stats) + return db.db.Load().Stats(&stats) })) return stats } @@ -182,7 +181,7 @@ func TestLevelDb(t *testing.T) { 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.Put(key, bytes.Repeat([]byte{byte(i)}, 100), nil)) + require.NoError(t, db.db.Load().Put(key, bytes.Repeat([]byte{byte(i)}, 100), nil)) } } } @@ -190,7 +189,7 @@ func TestLevelDb(t *testing.T) { // compaction of the flushed memtable would have inputs. for round := 0; round < 2; round++ { putAcrossPrefixes(round) - tr, err := db.db.OpenTransaction() + tr, err := db.db.Load().OpenTransaction() require.NoError(t, err) tr.Discard() } @@ -207,27 +206,29 @@ func TestLevelDb(t *testing.T) { } 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.Get([]byte("zz:2:19"), nil) + val, err := db.db.Load().Get([]byte("zz:2:19"), nil) require.NoError(t, err) require.Equal(t, bytes.Repeat([]byte{19}, 100), val) }, }, { - name: "flush-coalesces", testBody: func(t *testing.T) { - tc := SetupTest(t, "LevelDb-flush-coalesces", 0) + // 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) - // A write and a Flush request that land after the running flush - // rotated the memtable must still be flushed before it returns. rotations := 0 db.flushHook = func() { rotations++ if rotations == 1 { - require.NoError(t, db.db.Put([]byte("kv:late"), []byte{1}, nil)) + require.NoError(t, db.db.Load().Put([]byte("kv:late"), []byte{1}, nil)) require.NoError(t, db.Flush()) } } @@ -237,6 +238,10 @@ func TestLevelDb(t *testing.T) { }, }, { + // TestConcurrentFlushes: 8 goroutines call Flush with writes + // interleaved. Flush no longer coalesces concurrent callers, so this + // exercises goleveldb's own write-lock serialization of the memtable + // rotation instead. name: "flush-concurrent", testBody: func(t *testing.T) { tc := SetupTest(t, "LevelDb-flush-concurrent", 0) defer tc.Cleanup() @@ -244,19 +249,6 @@ func TestLevelDb(t *testing.T) { require.NoError(t, err) require.NoError(t, db.ForceOpen()) - var active, maxActive atomic.Int32 - db.flushHook = func() { - n := active.Add(1) - for { - m := maxActive.Load() - if n <= m || maxActive.CompareAndSwap(m, n) { - break - } - } - time.Sleep(time.Millisecond) - active.Add(-1) - } - const writers, iterations = 8, 25 var wg sync.WaitGroup for w := 0; w < writers; w++ { @@ -272,7 +264,6 @@ func TestLevelDb(t *testing.T) { } wg.Wait() - require.Equal(t, int32(1), maxActive.Load(), "flushes must not overlap") 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}) From 74a33d8eb8f06951b3e3d536a3ff50aa443e1d25 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 18:38:59 -0400 Subject: [PATCH 101/127] fix(libkb): sample leveldb cleaner's app-state baseline under lock; restore reopen and re-arm test coverage --- go/libkb/leveldb_cleaner.go | 17 ++-- go/libkb/leveldb_cleaner_test.go | 164 +++++++++++++++++++++++-------- go/libkb/leveldb_test.go | 7 +- 3 files changed, 137 insertions(+), 51 deletions(-) diff --git a/go/libkb/leveldb_cleaner.go b/go/libkb/leveldb_cleaner.go index c72bc2bc29f2..c0989313241f 100644 --- a/go/libkb/leveldb_cleaner.go +++ b/go/libkb/leveldb_cleaner.go @@ -190,6 +190,16 @@ func (c *levelDbCleaner) clean(force bool) (err error) { c.running = true key := c.lastKey stopCh := c.stopCh + // 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)() @@ -213,13 +223,6 @@ func (c *levelDbCleaner) clean(force bool) (err error) { return nil } - // 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) - var totalNumPurged, numPurged int for i := range 100 { select { diff --git a/go/libkb/leveldb_cleaner_test.go b/go/libkb/leveldb_cleaner_test.go index 0605a539537e..1d6b38d3298e 100644 --- a/go/libkb/leveldb_cleaner_test.go +++ b/go/libkb/leveldb_cleaner_test.go @@ -1,7 +1,6 @@ package libkb import ( - stderrors "errors" "fmt" "path/filepath" "testing" @@ -9,7 +8,6 @@ import ( keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" - "github.com/syndtr/goleveldb/leveldb" ) // newMobileCleanerDb makes a LevelDb whose cleaner behaves as on mobile. The @@ -35,35 +33,28 @@ var cleanerStates = []keybase1.MobileAppState{ keybase1.MobileAppState_BACKGROUND, } -// putKeys writes numKeys keys under db and returns the first and last ones. -func putKeys(t *testing.T, db *LevelDb, numKeys int) (first, last DbKey) { +// 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() - for i := range numKeys { - key := DbKey{Key: fmt.Sprintf("k%05d", i), Typ: 0} - require.NoError(t, db.Put(key, nil, []byte{1})) - if i == 0 { - first = key - } - last = key - } - return first, last + require.Eventually(t, func() bool { + c.Lock() + defer c.Unlock() + return c.running + }, 10*time.Second, time.Millisecond, "clean did not start running") } -// waitFirstBatchPurged waits until a running clean has purged firstKey, its -// oldest key. clean() samples the app state once, before its first batch; -// waiting for that first batch orders a test's own app-state update after -// that sample, so the update is guaranteed to be seen as a real change -// instead of racing the sample itself. -// -// It reads the raw db rather than going through LevelDb.Get, which would -// mark firstKey recently-used and make the cleaner skip deleting it -- the -// very thing being waited for. -func waitFirstBatchPurged(t *testing.T, db *LevelDb, firstKey DbKey) { +// putKeys writes numKeys keys under db and returns the last one. +func putKeys(t *testing.T, db *LevelDb, numKeys int) DbKey { t.Helper() - require.Eventually(t, func() bool { - _, err := db.db.Load().Get(firstKey.ToBytes(), nil) - return stderrors.Is(err, leveldb.ErrNotFound) - }, 10*time.Second, time.Millisecond, "clean did not purge its first batch") + 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 @@ -82,12 +73,12 @@ func TestCleanerStopsWhenLeavingBackgroundActive(t *testing.T) { tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) const numKeys = 3500 - firstKey, lastKey := putKeys(t, db, numKeys) + lastKey := putKeys(t, db, numKeys) db.cleaner.clearCache() done := make(chan error, 1) go func() { done <- db.cleaner.clean(true /* force */) }() - waitFirstBatchPurged(t, db, firstKey) + waitCleanerRunning(t, db.cleaner) tc.G.MobileAppState.Update(next) require.NoError(t, <-done) @@ -99,14 +90,37 @@ func TestCleanerStopsWhenLeavingBackgroundActive(t *testing.T) { } } +// 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. NextUpdate -// only fires on a real change, so a same-value BACKGROUNDACTIVE update never -// wakes the batch loop at all; a collapsed BACKGROUNDACTIVE -> X -> -// BACKGROUNDACTIVE transition can't be produced deterministically, since the -// loop's poll may or may not land inside the window where the state reads as -// X. -func TestCleanerContinuesAcrossBackgroundActiveReentry(t *testing.T) { +// 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() @@ -115,15 +129,15 @@ func TestCleanerContinuesAcrossBackgroundActiveReentry(t *testing.T) { tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) const numKeys = 3500 - firstKey, lastKey := putKeys(t, db, numKeys) + lastKey := putKeys(t, db, numKeys) db.cleaner.clearCache() done := make(chan error, 1) go func() { done <- db.cleaner.clean(true /* force */) }() - waitFirstBatchPurged(t, db, firstKey) + waitCleanerRunning(t, db.cleaner) - // An unrelated update that collapses to a no-op: still BACKGROUNDACTIVE, - // so it must not interrupt the clean. + // 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) @@ -131,3 +145,73 @@ func TestCleanerContinuesAcrossBackgroundActiveReentry(t *testing.T) { 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 9ad5ecd8b3b1..11697f93d90e 100644 --- a/go/libkb/leveldb_test.go +++ b/go/libkb/leveldb_test.go @@ -238,10 +238,9 @@ func TestLevelDb(t *testing.T) { }, }, { - // TestConcurrentFlushes: 8 goroutines call Flush with writes - // interleaved. Flush no longer coalesces concurrent callers, so this - // exercises goleveldb's own write-lock serialization of the memtable - // rotation instead. + // 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() From 152e9ea6c9d1fc58b66f5511c66f2c1ec6c10cdd Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 19:09:07 -0400 Subject: [PATCH 102/127] refactor(appstate): share the kbfs foreground wait and drop watcher internals --- go/avatars/appstate.go | 43 +++++++----- go/avatars/appstate_test.go | 24 ++----- go/chat/maps/livelocation.go | 5 +- go/chat/maps/livelocation_appstate_test.go | 31 +++++++++ go/kbfs/libkbfs/folder_block_manager.go | 44 ++++++------ go/kbfs/search/indexer.go | 31 +++++---- go/libkb/appstate.go | 78 +++------------------- 7 files changed, 120 insertions(+), 136 deletions(-) diff --git a/go/avatars/appstate.go b/go/avatars/appstate.go index 0f216f249e7b..72e61751b27a 100644 --- a/go/avatars/appstate.go +++ b/go/avatars/appstate.go @@ -10,9 +10,9 @@ import ( // backgroundFlusher runs flush each time the app enters BACKGROUND, from // start until stop. type backgroundFlusher struct { - mu sync.Mutex - stopCh chan struct{} - watcher *libkb.AppStateWatcher + mu sync.Mutex + stopCh chan struct{} + doneCh chan struct{} // flushes counts flushes; tests use it. flushes int } @@ -24,28 +24,37 @@ func (f *backgroundFlusher) start(m libkb.MetaContext, flush func(libkb.MetaCont return } f.stopCh = make(chan struct{}) - f.watcher = m.G().MobileAppState.NewWatcher() - stopCh, w := f.stopCh, f.watcher - go w.Run(m.G().MobileAppState.State(), stopCh, func(state keybase1.MobileAppState) bool { - if state == keybase1.MobileAppState_BACKGROUND { - flush(m) - f.mu.Lock() - f.flushes++ - f.mu.Unlock() + f.doneCh = make(chan struct{}) + stopCh, doneCh := f.stopCh, f.doneCh + state := m.G().MobileAppState.State() + go func() { + defer close(doneCh) + for { + select { + case <-m.G().MobileAppState.NextUpdate(state): + case <-stopCh: + return + } + state = m.G().MobileAppState.State() + if state == keybase1.MobileAppState_BACKGROUND { + flush(m) + f.mu.Lock() + f.flushes++ + f.mu.Unlock() + } } - return true - }) + }() } -// stop ends the watcher and waits for it to exit. +// stop ends the watcher goroutine and waits for it to exit. func (f *backgroundFlusher) stop() { f.mu.Lock() - stopCh, w := f.stopCh, f.watcher - f.stopCh, f.watcher = nil, nil + stopCh, doneCh := f.stopCh, f.doneCh + f.stopCh, f.doneCh = nil, nil f.mu.Unlock() if stopCh == nil { return } close(stopCh) - w.Wait() + <-doneCh } diff --git a/go/avatars/appstate_test.go b/go/avatars/appstate_test.go index 60cd5427229b..5b03630d6478 100644 --- a/go/avatars/appstate_test.go +++ b/go/avatars/appstate_test.go @@ -10,20 +10,12 @@ import ( "github.com/stretchr/testify/require" ) -// waitFlusher waits until f's watcher has acted on the current state and is -// waiting for the next change. -func waitFlusher(t *testing.T, f *backgroundFlusher) { +// 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 { - f.mu.Lock() - w := f.watcher - f.mu.Unlock() - if w == nil { - return false - } - _, caughtUp := w.CaughtUp() - return caughtUp - }, 10*time.Second, time.Millisecond, "watcher did not catch up") + return flushes(f) >= want + }, 10*time.Second, time.Millisecond, "did not reach %d flushes", want) } func flushes(f *backgroundFlusher) int { @@ -66,8 +58,6 @@ func TestAvatarsFlushSeedsFromState(t *testing.T) { tc.G.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) s.StartBackgroundTasks(m) defer s.StopBackgroundTasks(m) - waitFlusher(t, s.flusher()) - require.Equal(t, 0, flushes(s.flusher()), "flushed without a transition into BACKGROUND") for _, next := range []keybase1.MobileAppState{ keybase1.MobileAppState_FOREGROUND, @@ -75,9 +65,10 @@ func TestAvatarsFlushSeedsFromState(t *testing.T) { keybase1.MobileAppState_BACKGROUND, } { tc.G.MobileAppState.Update(next) - waitFlusher(t, s.flusher()) } - require.Equal(t, 1, flushes(s.flusher())) + 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") }) } @@ -92,7 +83,6 @@ func TestAvatarsMonitorExitsOnStop(t *testing.T) { const cycles = 50 for range cycles { s.StartBackgroundTasks(m) - waitFlusher(t, s.flusher()) s.StopBackgroundTasks(m) } require.Eventually(t, func() bool { diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 688513738b73..4942aa847ab3 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -120,7 +120,6 @@ func (l *LiveLocationTracker) releaseHoldIfIdleLocked() { // 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() { - l.releaseHoldIfIdleLocked() if len(l.trackers) == 0 || !l.G().IsMobileAppType() { return } @@ -154,6 +153,10 @@ func (l *LiveLocationTracker) runRestoredLocked(trackers []*locationTrack) { 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 { diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go index b2db2028210c..a1ed20e0b745 100644 --- a/go/chat/maps/livelocation_appstate_test.go +++ b/go/chat/maps/livelocation_appstate_test.go @@ -89,3 +89,34 @@ func TestLiveLocationTrackerHoldSurvivesWillTerminate(t *testing.T) { 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 + require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + 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/kbfs/libkbfs/folder_block_manager.go b/go/kbfs/libkbfs/folder_block_manager.go index 5511dd428289..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,19 +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) - select { - case <-fbm.appStateUpdater.NextAppStateUpdate(state): - case <-fbm.shutdownChan: + "Pausing QR while not foregrounded: state=%s", s) + if !WaitForeground(fbm.appStateUpdater, fbm.shutdownChan) { return } - state = fbm.appStateUpdater.AppState() + 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) @@ -1593,20 +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) - select { - case <-fbm.appStateUpdater.NextAppStateUpdate(state): - case <-fbm.shutdownChan: + "Pausing sync-cache cleaning while not foregrounded: state=%s", s) + if !WaitForeground(fbm.appStateUpdater, fbm.shutdownChan) { return } - state = fbm.appStateUpdater.AppState() + 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/search/indexer.go b/go/kbfs/search/indexer.go index 3b0e7dce1028..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,25 +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) - select { - case <-kbCtx.NextAppStateUpdate(state): - case <-ctx.Done(): - return - case <-i.shutdownCh: - i.cancelLoop() + "Pausing indexing while not foregrounded: state=%s", s) + if !libkbfs.WaitForeground(kbCtx, stopped) { + if ctx.Err() == nil { + i.cancelLoop() + } return } - state = kbCtx.AppState() + 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/libkb/appstate.go b/go/libkb/appstate.go index c9950ddd24fe..5ac8feadb3fc 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -103,12 +103,14 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo } // Tell connected clients, still under the lock, so the state version is - // stamped in the same critical section that wrote the state. Two concurrent - // Updates then publish in the order they wrote, and a client's - // accept-if-newer gate can never be handed an older state last and keep it - // forever. Cheap to hold: the fan-out reads the connection table and starts - // one goroutine per connection, and every send happens on those goroutines. - // Nothing it touches reads app state, so it cannot re-enter this lock. + // stamped in the same critical section that wrote the state. Update has + // one writer, lifecycle.Controller.applyLocked under Controller.mu, so + // this notify call publishes that single writer's announcements in the + // same order it wrote them, and a client's accept-if-newer gate can never + // be handed an older state last and keep it forever. Cheap to hold: the + // fan-out reads the connection table and starts one goroutine per + // connection, and every send happens on those goroutines. Nothing it + // touches reads app state, so it cannot re-enter this lock. a.G().NotifyRouter.HandleMobileAppState(context.Background(), state) return true } @@ -144,70 +146,6 @@ func (a *MobileAppState) StateAndMtime() (keybase1.MobileAppState, *time.Time) { return a.state, a.mtime } -// AppStateWatcher is the loop shared by the background workers that do nothing -// but watch the app state: wait for the next change, act on the new state, -// repeat. The caller runs it on a goroutine of its own, since the workers hang -// that goroutine off their own errgroup or done channel and do their own -// accounting when it returns. -type AppStateWatcher struct { - a *MobileAppState - mu sync.Mutex - // state is what Run last acted on and wait the change channel it waits on - // for that state; CaughtUp reports them. - state keybase1.MobileAppState - wait <-chan struct{} - done chan struct{} -} - -func (a *MobileAppState) NewWatcher() *AppStateWatcher { - return &AppStateWatcher{a: a, done: make(chan struct{})} -} - -// Run calls onChange with each new app state, starting from state, until -// stopCh closes or onChange returns false. onChange runs on Run's goroutine -// and does its own locking. -func (w *AppStateWatcher) Run(state keybase1.MobileAppState, stopCh <-chan struct{}, - onChange func(keybase1.MobileAppState) bool, -) { - defer close(w.done) - for { - next := w.a.NextUpdate(state) - w.mu.Lock() - w.state, w.wait = state, next - w.mu.Unlock() - select { - case <-next: - case <-stopCh: - return - } - state = w.a.State() - if !onChange(state) { - return - } - } -} - -// Wait blocks until Run has returned. -func (w *AppStateWatcher) Wait() { <-w.done } - -// CaughtUp reports the state Run last acted on, and whether it has acted on -// the current state and is waiting for the next change. Tests use it to wait -// until a watcher has caught up. -func (w *AppStateWatcher) CaughtUp() (keybase1.MobileAppState, bool) { - w.mu.Lock() - state, wait := w.state, w.wait - w.mu.Unlock() - if wait == nil || wait != w.a.NextUpdate(state) { - return state, false - } - select { - case <-wait: - return state, false - default: - return state, true - } -} - // -------------------------------------------------- // MobileNetState tracks the state of the network status of the app in which From 524969d6ab21a8b5e41288160e4712803424498c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 19:26:36 -0400 Subject: [PATCH 103/127] refactor(gregor): use the connection's own ctx instead of tracking which conn is current --- go/chat/sync.go | 7 + go/chat/sync_test.go | 15 ++ go/service/gregor.go | 170 +++++------------- go/service/gregor_conn_test.go | 307 ++++++++++++++------------------- 4 files changed, 201 insertions(+), 298 deletions(-) diff --git a/go/chat/sync.go b/go/chat/sync.go index 880486eab197..2d23a5cc304a 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() + // ctx is the connection's: the caller cancels it 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/service/gregor.go b/go/service/gregor.go index 185972a94f8a..0bb41bf96f50 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -205,10 +205,6 @@ type gregorHandler struct { // none of them lands after a Shutdown for the connection it came from. connGate *gregorConnGate - // syncerConn is the connection that last marked the chat syncer - // connected, under connMutex. - syncerConn *rpc.Connection - // This mutex protects the con object connMutex sync.Mutex conn *rpc.Connection @@ -238,13 +234,8 @@ type gregorHandler struct { // Testing testingEvents *testingEvents - // beforeGregorClientInstall, if set, runs in resetGregorClientFor after - // the client is built and before it is installed. - beforeGregorClientInstall func() // authParamsForTest, if set, replaces authParams in OnConnect. - authParamsForTest func(ctx context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) - // onConnectStep, if set, runs before each step of onConnectSynced. - onConnectStep func(step onConnectStep) + authParamsForTest func(ctx context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) transportForTesting *connTransport } @@ -256,16 +247,6 @@ type gregorBadger interface { var _ gregorBadger = (*badges.Badger)(nil) -type onConnectStep int - -const ( - onConnectStepChatBadges onConnectStep = iota - onConnectStepSyncer - onConnectStepServerSync - onConnectStepGregorBadges - onConnectStepConnected -) - var ( _ libkb.GregorState = (*gregorHandler)(nil) _ libkb.GregorListener = (*gregorHandler)(nil) @@ -362,17 +343,11 @@ func (g *gregorHandler) shutdownGregorClient(ctx context.Context) { } } +// resetGregorClient installs a new client for uid unless ctx is cancelled. +// OnConnect passes its connection's ctx, 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) { - return g.resetGregorClientFor(ctx, nil, uid, deviceID) -} - -// resetGregorClientFor installs a new client for uid. With conn set, it -// installs only while conn is still the current connection, checked under -// the lock Shutdown takes, so an OnConnect that loses a race with a logout -// or a reconnect doesn't install a client for the old connection. -func (g *gregorHandler) resetGregorClientFor(ctx context.Context, conn *rpc.Connection, - 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 if uid != nil && deviceID != nil { @@ -387,18 +362,13 @@ func (g *gregorHandler) resetGregorClientFor(ctx context.Context, conn *rpc.Conn g.Debug(ctx, "restore local state failed: %s", err) } } - if g.beforeGregorClientInstall != nil { - g.beforeGregorClientInstall() - } - if conn != nil { - g.connMutex.Lock() - defer g.connMutex.Unlock() - if conn != g.conn { - if gcli != nil { - gcli.Stop() - } - return nil, chat.ErrDuplicateConnection + 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 @@ -795,23 +765,19 @@ func (g *gregorHandler) notificationParams(ctx context.Context, gcli *grclient.C } // OnConnect is called by the rpc library to indicate we have connected to -// gregord -func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, +// gregord. The library cancels ctx when it shuts the connection down, so ctx +// is live exactly while the connection is still g.conn. +func (g *gregorHandler) OnConnect(ctx context.Context, _ *rpc.Connection, cli rpc.GenericClient, srv *rpc.Server, ) (err error) { 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") + if ctx.Err() != nil { + g.chatLog.Debug(ctx, "aborting, connection shut down") return chat.ErrDuplicateConnection } - g.connMutex.Unlock() g.chatLog.Debug(ctx, "connected") timeoutCli := WrapGenericClientWithTimeout(cli, GregorRequestTimeout, chat.ErrChatServerTimeout) @@ -828,7 +794,7 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, if err != nil { return err } - gcli, err := g.resetGregorClientFor(ctx, conn, uid, deviceID) + gcli, err := g.resetGregorClient(ctx, uid, deviceID) if err != nil { // %w keeps ErrDuplicateConnection visible to ShouldRetryOnConnect. return fmt.Errorf("failed to get gregor client: %w", err) @@ -872,74 +838,29 @@ func (g *gregorHandler) OnConnect(ctx context.Context, conn *rpc.Connection, return fmt.Errorf("error authenticating: %s", err) } - return g.onConnectSynced(ctx, conn, chatCli, timeoutCli, uid, gcli, syncAllRes) -} - -func (g *gregorHandler) runOnConnectStep(step onConnectStep) { - if g.onConnectStep != nil { - g.onConnectStep(step) - } + return g.onConnectSynced(ctx, chatCli, timeoutCli, uid, gcli, syncAllRes) } -func (g *gregorHandler) isCurrentConn(conn *rpc.Connection) bool { - g.connMutex.Lock() - defer g.connMutex.Unlock() - return conn == g.conn -} - -// onGateIfCurrent runs f under the connection gate if conn is still the -// current connection, and reports whether it ran. Every Shutdown and Reset is -// made under the gate too, 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. The lock order is the gate's mu, then connMutex. -func (g *gregorHandler) onGateIfCurrent(conn *rpc.Connection, f func()) bool { +// 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 !g.isCurrentConn(conn) { + if ctx.Err() != nil { return false } f() return true } -// connectSyncer marks the chat syncer connected for conn and syncs it. -// Syncer.Connected can't run under the connection gate, since the sync calls -// the server through this handler and may ask the gate to reconnect, so a -// Shutdown can land while it runs; conn then undoes its own mark, unless a -// newer connection has marked the syncer since. -func (g *gregorHandler) connectSyncer(ctx context.Context, conn *rpc.Connection, chatCli chat1.RemoteInterface, - uid gregor1.UID, syncRes *chat1.SyncChatRes, -) error { - g.connMutex.Lock() - if conn != g.conn { - g.connMutex.Unlock() - return chat.ErrDuplicateConnection - } - g.syncerConn = conn - g.connMutex.Unlock() - - err := g.G().Syncer.Connected(ctx, chatCli, uid, syncRes) - - g.connMutex.Lock() - defer g.connMutex.Unlock() - if conn != g.conn { - if g.syncerConn == conn { - g.chatLog.Debug(ctx, "connection dropped during chat sync, marking the syncer disconnected") - g.G().Syncer.Disconnected(ctx) - g.syncerConn = nil - } - return chat.ErrDuplicateConnection - } - if err != nil { - return fmt.Errorf("error running chat sync: %s", err) - } - return nil -} - -// onConnectSynced applies a SyncAll result for conn. A logout or reconnect -// can drop conn at any point, so each step applies only while conn is still -// current, and OnConnect then fails with ErrDuplicateConnection. -func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connection, chatCli chat1.RemoteInterface, +// 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. @@ -949,8 +870,7 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio // 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 - g.runOnConnectStep(onConnectStepChatBadges) - if !g.onGateIfCurrent(conn, func() { + if !g.onGateIfCurrent(ctx, func() { if g.badger != nil { g.badger.PushChatFullUpdate(ctx, syncAllRes.Badge) } @@ -960,16 +880,17 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio // Sync chat data using a Syncer object // This commits the new inbox version to persistent storage. - g.runOnConnectStep(onConnectStepSyncer) - if err := g.connectSyncer(ctx, conn, chatCli, uid, &syncAllRes.Chat); err != nil { - return err + // It can't run under the gate, since the sync calls the server through + // this handler and may ask the gate to reconnect. 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: conn can stop being - // current between this check and the sync. Gating it means running an RPC under the gate. - g.runOnConnectStep(onConnectStepServerSync) - if !g.isCurrentConn(conn) { + // 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, @@ -980,8 +901,7 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio // Update badging from gregor, and call out to reachability module if we // have one. - g.runOnConnectStep(onConnectStepGregorBadges) - if !g.onGateIfCurrent(conn, func() { + if !g.onGateIfCurrent(ctx, func() { if g.badger != nil { state, err := gcli.StateMachineState(ctx, nil, false) if err != nil { @@ -1013,8 +933,7 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, conn *rpc.Connectio }(g.makeReconnectOobm()) // No longer first connect if we are now connected. - g.runOnConnectStep(onConnectStepConnected) - if !g.onGateIfCurrent(conn, func() { + if !g.onGateIfCurrent(ctx, func() { g.chatLog.Debug(ctx, "setting first connect to false") g.setFirstConnect(false) g.setConnectedAt(time.Now()) @@ -1489,11 +1408,12 @@ 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.conn.Shutdown() + // After the Shutdown, 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.setConnectedAt(time.Time{}) diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index db5b5bcd600f..a236ae0b87b8 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -775,46 +775,18 @@ func TestGregorHandlerTerminalFailureRedialsOnForeground(t *testing.T) { require.True(t, hasConn(h), "failed connection was torn down") } -// An OnConnect that passed its connection check before a logout must not -// install a gregor client for the dropped connection. -func TestGregorClientInstallRacingLogout(t *testing.T) { - tc, g := setupGregorTest(t) - defer tc.Cleanup() - g.Syncer = chat.NewSyncer(g) - ctx := context.Background() - - h := newGregorHandler(g) - require.NoError(t, h.Connect(closedPortURI(t))) - h.connMutex.Lock() - conn := h.conn - h.connMutex.Unlock() - uid := gregor1.UID(make([]byte, 16)) - deviceID := gregor1.DeviceID(make([]byte, 16)) - - gcli, err := h.resetGregorClientFor(ctx, conn, uid, deviceID) - require.NoError(t, err) - require.NotNil(t, gcli) - _, err = h.getGregorCli() - require.NoError(t, err, "current connection did not install its client") - - h.beforeGregorClientInstall = func() { require.NoError(t, h.Disconnect()) } - _, err = h.resetGregorClientFor(ctx, conn, uid, deviceID) - require.ErrorIs(t, err, chat.ErrDuplicateConnection) - _, err = h.getGregorCli() - require.Error(t, err, "installed a client for a connection logout dropped") -} - +// fakeSyncer ignores a Connected whose ctx is cancelled, as chat.Syncer does. type fakeSyncer struct { types.Syncer mu sync.Mutex connected bool connects int - // beforeMark, if set, runs once inside Connected before the syncer is - // marked connected, as a logout landing just before the mark would. - beforeMark func() // 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 { @@ -823,15 +795,12 @@ func (s *fakeSyncer) IsConnected(context.Context) bool { return s.connected } -func (s *fakeSyncer) Connected(context.Context, chat1.RemoteInterface, gregor1.UID, *chat1.SyncChatRes) error { +func (s *fakeSyncer) Connected(ctx context.Context, _ chat1.RemoteInterface, _ gregor1.UID, _ *chat1.SyncChatRes) error { s.mu.Lock() - before := s.beforeMark - s.beforeMark = nil - s.mu.Unlock() - if before != nil { - before() + if err := ctx.Err(); err != nil { + s.mu.Unlock() + return err } - s.mu.Lock() s.connected = true s.connects++ f := s.onConnected @@ -845,8 +814,13 @@ func (s *fakeSyncer) Connected(context.Context, chat1.RemoteInterface, gregor1.U func (s *fakeSyncer) Disconnected(context.Context) { s.mu.Lock() - defer s.mu.Unlock() s.connected = false + f := s.onDisconnected + s.onDisconnected = nil + s.mu.Unlock() + if f != nil { + f() + } } func (s *fakeSyncer) connectCalls() int { @@ -856,10 +830,8 @@ func (s *fakeSyncer) connectCalls() int { } type fakeBadger struct { - mu sync.Mutex - loggedOut bool - pushes int - pushesAfterLogout int + mu sync.Mutex + pushes int // onPush, if set, runs once inside a push. onPush func() } @@ -867,9 +839,6 @@ type fakeBadger struct { func (b *fakeBadger) push() { b.mu.Lock() b.pushes++ - if b.loggedOut { - b.pushesAfterLogout++ - } f := b.onPush b.onPush = nil b.mu.Unlock() @@ -881,16 +850,10 @@ func (b *fakeBadger) push() { func (b *fakeBadger) PushState(context.Context, gregor.State) { b.push() } func (b *fakeBadger) PushChatFullUpdate(context.Context, chat1.UnreadUpdateFull) { b.push() } -func (b *fakeBadger) logout() { +func (b *fakeBadger) count() int { b.mu.Lock() defer b.mu.Unlock() - b.loggedOut = true -} - -func (b *fakeBadger) counts() (pushes, afterLogout int) { - b.mu.Lock() - defer b.mu.Unlock() - return b.pushes, b.pushesAfterLogout + return b.pushes } func currentConn(h *gregorHandler) *rpc.Connection { @@ -899,9 +862,36 @@ func currentConn(h *gregorHandler) *rpc.Connection { return h.conn } +// shutdownCtx stands in for the ctx the rpc library hands OnConnect, which +// the connection's Shutdown cancels. It is done once the handler's shutdown +// channel for that connection closes, which Shutdown does under the same +// locks as it shuts the connection down. +type shutdownCtx struct { + context.Context + done chan struct{} +} + +func (c shutdownCtx) Done() <-chan struct{} { return c.done } + +func (c shutdownCtx) Err() error { + select { + case <-c.done: + return context.Canceled + default: + return nil + } +} + +// connCtx returns the OnConnect ctx for h's current connection. +func connCtx(h *gregorHandler) context.Context { + h.connMutex.Lock() + defer h.connMutex.Unlock() + return shutdownCtx{Context: context.Background(), done: h.shutdownCh} +} + type onConnectTailTest struct { h *gregorHandler - conn *rpc.Connection + ctx context.Context gcli *grclient.Client syncer *fakeSyncer badger *fakeBadger @@ -921,106 +911,65 @@ func setupOnConnectTail(t *testing.T) *onConnectTailTest { h.badger = badger require.NoError(t, h.Connect(closedPortURI(t))) t.Cleanup(func() { h.Shutdown(context.Background()) }) - conn := currentConn(h) + ctx := connCtx(h) uid := gregor1.UID(make([]byte, 16)) - gcli, err := h.resetGregorClientFor(context.Background(), conn, uid, gregor1.DeviceID(make([]byte, 16))) + gcli, err := h.resetGregorClient(ctx, uid, gregor1.DeviceID(make([]byte, 16))) require.NoError(t, err) return &onConnectTailTest{ - h: h, conn: conn, gcli: gcli, syncer: syncer, badger: badger, uid: uid, + h: h, ctx: ctx, gcli: gcli, syncer: syncer, badger: badger, uid: uid, syncRes: chat1.SyncAllResult{Notification: chat1.NewSyncAllNotificationResWithState(gregor1.State{})}, } } -func (c *onConnectTailTest) run() error { - return c.h.onConnectSynced(context.Background(), c.conn, chat1.RemoteClient{}, nil, c.uid, c.gcli, c.syncRes) -} - -// logout does what Service.OnLogout does to gregor, and marks every badge -// push from then on as leaked. -func (c *onConnectTailTest) logout(t *testing.T) { - require.NoError(t, c.h.Disconnect()) - c.badger.logout() +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()) - pushes, _ := c.badger.counts() - require.Equal(t, 2, pushes) + 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 logout landing before any step of OnConnect's tail leaves no trace of -// the old connection. -func TestGregorOnConnectTailRacingLogout(t *testing.T) { - for _, tt := range []struct { - name string - step onConnectStep - syncerConnect int - stateSyncs int - }{ - {"chat badges", onConnectStepChatBadges, 0, 0}, - {"syncer", onConnectStepSyncer, 0, 0}, - {"server sync", onConnectStepServerSync, 1, 0}, - {"gregor badges", onConnectStepGregorBadges, 1, 1}, - {"connected", onConnectStepConnected, 1, 1}, - } { - t.Run(tt.name, func(t *testing.T) { - c := setupOnConnectTail(t) - c.h.onConnectStep = func(step onConnectStep) { - if step == tt.step { - c.logout(t) - } - } - require.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) - _, afterLogout := c.badger.counts() - require.Zero(t, afterLogout, "badges pushed after logout") - require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected after logout") - require.Equal(t, tt.syncerConnect, c.syncer.connectCalls(), "chat sync ran after logout") - require.Len(t, c.h.replayCh, tt.stateSyncs, "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 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, before or after the syncer marks itself -// connected, leaves the syncer disconnected. +// A logout during the chat sync leaves the syncer disconnected and stops the +// rest of the tail. func TestGregorOnConnectLogoutDuringChatSync(t *testing.T) { - for _, beforeMark := range []bool{true, false} { - t.Run(fmt.Sprintf("before mark %v", beforeMark), func(t *testing.T) { - c := setupOnConnectTail(t) - if beforeMark { - c.syncer.beforeMark = func() { c.logout(t) } - } else { - c.syncer.onConnected = func() { c.logout(t) } - } - require.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) - require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected after logout") - require.True(t, c.h.isFirstConnect()) - }) - } -} - -// The undo leaves alone a syncer that a newer connection has marked since. -func TestGregorOnConnectLogoutDuringChatSyncKeepsNewerConn(t *testing.T) { c := setupOnConnectTail(t) - c.syncer.onConnected = func() { - c.logout(t) - // A newer connection, installed by hand so no dial's callbacks - // touch the syncer. - newer := &rpc.Connection{} - c.h.connMutex.Lock() - c.h.conn = newer - c.h.shutdownCh = make(chan struct{}) - c.h.connMutex.Unlock() - require.NoError(t, c.h.connectSyncer(context.Background(), newer, chat1.RemoteClient{}, c.uid, &chat1.SyncChatRes{})) + 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") +} + +// 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.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) - require.True(t, c.syncer.IsConnected(context.Background()), "undo disconnected the newer connection's syncer") + 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 @@ -1039,20 +988,20 @@ func TestGregorOnConnectBadgePushHoldsOffLogout(t *testing.T) { case <-time.After(100 * time.Millisecond): } } - require.ErrorIs(t, c.run(), chat.ErrDuplicateConnection) + require.ErrorIs(t, c.run(c.ctx), chat.ErrDuplicateConnection) <-logoutDone - pushes, _ := c.badger.counts() - require.Equal(t, 1, pushes) + require.Equal(t, 1, c.badger.count()) } -// reinstall makes conn the current connection again, 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() { +// reinstall makes conn the current connection again, and returns its +// OnConnect ctx, 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) context.Context { c.h.connMutex.Lock() - defer c.h.connMutex.Unlock() - c.h.conn = c.conn + c.h.conn = conn c.h.shutdownCh = make(chan struct{}) + c.h.connMutex.Unlock() + return connCtx(c.h) } // OnConnect's tail, a logout and app state transitions all run under the @@ -1064,8 +1013,8 @@ func TestGregorOnConnectTailStress(t *testing.T) { // connection back after each logout, and a dialing one would reconnect // behind it and outlive the test. require.NoError(t, c.h.Disconnect()) - c.conn = &rpc.Connection{} - c.reinstall() + conn := &rpc.Connection{} + c.reinstall(conn) c.h.connGate.start() t.Cleanup(c.h.connGate.stop) @@ -1081,8 +1030,7 @@ func TestGregorOnConnectTailStress(t *testing.T) { return default: } - c.reinstall() - _ = c.run() + _ = c.run(c.reinstall(conn)) // A run queues at most one replay, and Init's replay thread // is not running here to take it off. select { @@ -1149,32 +1097,45 @@ func (failingRPCClient) Notify(context.Context, string, any, time.Duration) erro return errors.New("no server") } -// An OnConnect that loses the client install to a logout fails with an error -// the connection does not retry. -func TestGregorOnConnectRacingLogoutIsNotRetried(t *testing.T) { - tc, g := setupGregorTest(t) - defer tc.Cleanup() - g.Syncer = chat.NewSyncer(g) - h := newGregorHandler(g) - require.NoError(t, h.Connect(closedPortURI(t))) - defer h.Shutdown(context.Background()) - conn := currentConn(h) +// An OnConnect whose connection shuts down, before it starts or while it +// runs, installs no gregor client, leaves the chat syncer alone, and fails +// with an error the connection does not retry. +func TestGregorOnConnectAfterShutdownInstallsNothing(t *testing.T) { + for _, during := range []bool{false, true} { + t.Run(fmt.Sprintf("during %v", during), func(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + syncer := &fakeSyncer{} + g.Syncer = syncer + h := newGregorHandler(g) + require.NoError(t, h.Connect(closedPortURI(t))) + defer h.Shutdown(context.Background()) + conn := currentConn(h) + ctx := connCtx(h) + + h.authParamsForTest = func(context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) { + if during { + require.NoError(t, h.Disconnect()) + } + return gregor1.UID(make([]byte, 16)), gregor1.DeviceID(make([]byte, 16)), "", nil, nil + } + if !during { + require.NoError(t, h.Disconnect()) + } - 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)) + + err := h.OnConnect(ctx, conn, failingRPCClient{}, srv) + require.ErrorIs(t, err, chat.ErrDuplicateConnection) + require.False(t, h.ShouldRetryOnConnect(err), "retrying a connection that shut down") + _, err = h.getGregorCli() + require.Error(t, err, "installed a client for a connection that shut down") + require.Zero(t, syncer.connectCalls(), "chat sync ran for a connection that shut down") + }) } - h.beforeGregorClientInstall = func() { require.NoError(t, h.Disconnect()) } - - 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 logout dropped") - _, err = h.getGregorCli() - require.Error(t, err, "installed a client for a connection logout dropped") } From 261498d7f2dd40fb69693d9926d0dbf6b03f86d2 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 19:35:45 -0400 Subject: [PATCH 104/127] fix(gregor): gate OnConnect on connection identity and cancel its ctx when that connection shuts down --- go/chat/sync.go | 6 +- go/service/gregor.go | 66 ++++++++++++++---- go/service/gregor_conn_test.go | 121 ++++++++++++++++++++------------- 3 files changed, 127 insertions(+), 66 deletions(-) diff --git a/go/chat/sync.go b/go/chat/sync.go index 2d23a5cc304a..4439a14d9c4e 100644 --- a/go/chat/sync.go +++ b/go/chat/sync.go @@ -177,9 +177,9 @@ 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() - // ctx is the connection's: the caller cancels it before it calls - // Disconnected, so a Connected that sees the cancel here must not mark - // the syncer connected after that Disconnected. + // 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 diff --git a/go/service/gregor.go b/go/service/gregor.go index 0bb41bf96f50..1b3a82dace3b 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -209,6 +209,10 @@ type gregorHandler struct { 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. + connCtx context.Context + connCancel context.CancelFunc // connectHappened will be closed after gregor connection established connectHappened chan struct{} @@ -344,9 +348,10 @@ func (g *gregorHandler) shutdownGregorClient(ctx context.Context) { } // resetGregorClient installs a new client for uid unless ctx is cancelled. -// OnConnect passes its connection's ctx, 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. +// 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 @@ -451,6 +456,7 @@ func (g *gregorHandler) connectNow(uri *rpc.FMPURI) (err error) { // In case we need to interrupt auth'ing or the ping loop, // set up this channel. g.shutdownCh = make(chan struct{}) + g.connCtx, g.connCancel = context.WithCancel(context.Background()) g.uri = uri go g.pushStateNewDataDebouncer(g.shutdownCh) if uri.UseTLS() { @@ -764,21 +770,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. The library cancels ctx when it shuts the connection down, so ctx -// is live exactly while the connection is still g.conn. -func (g *gregorHandler) OnConnect(ctx context.Context, _ *rpc.Connection, +// gregord +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 ctx.Err() != nil { - g.chatLog.Debug(ctx, "aborting, connection shut down") - return chat.ErrDuplicateConnection - } - g.chatLog.Debug(ctx, "connected") timeoutCli := WrapGenericClientWithTimeout(cli, GregorRequestTimeout, chat.ErrChatServerTimeout) chatCli := chat1.RemoteClient{Cli: chat.NewRemoteClient(g.G(), cli)} @@ -1409,13 +1436,15 @@ func (g *gregorHandler) Shutdown(ctx context.Context) { } close(g.shutdownCh) + g.connCancel() g.conn.Shutdown() - // After the Shutdown, which cancels the ctx of an OnConnect in flight, so - // a Syncer.Connected from it either lands before this and is overwritten, + // 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{}) } @@ -1539,6 +1568,13 @@ func (g *gregorHandler) forcePing(ctx context.Context) { } func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCh chan struct{}, shutdownCancel context.CancelFunc) { + 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() @@ -1550,14 +1586,14 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCh chan 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 { diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index a236ae0b87b8..5544f7fe3da7 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -862,31 +862,12 @@ func currentConn(h *gregorHandler) *rpc.Connection { return h.conn } -// shutdownCtx stands in for the ctx the rpc library hands OnConnect, which -// the connection's Shutdown cancels. It is done once the handler's shutdown -// channel for that connection closes, which Shutdown does under the same -// locks as it shuts the connection down. -type shutdownCtx struct { - context.Context - done chan struct{} -} - -func (c shutdownCtx) Done() <-chan struct{} { return c.done } - -func (c shutdownCtx) Err() error { - select { - case <-c.done: - return context.Canceled - default: - return nil - } -} - -// connCtx returns the OnConnect ctx for h's current connection. -func connCtx(h *gregorHandler) context.Context { - h.connMutex.Lock() - defer h.connMutex.Unlock() - return shutdownCtx{Context: context.Background(), done: h.shutdownCh} +// 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 { @@ -911,7 +892,7 @@ func setupOnConnectTail(t *testing.T) *onConnectTailTest { h.badger = badger require.NoError(t, h.Connect(closedPortURI(t))) t.Cleanup(func() { h.Shutdown(context.Background()) }) - ctx := connCtx(h) + 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) @@ -960,6 +941,27 @@ func TestGregorOnConnectLogoutDuringChatSync(t *testing.T) { 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. @@ -993,15 +995,15 @@ func TestGregorOnConnectBadgePushHoldsOffLogout(t *testing.T) { require.Equal(t, 1, c.badger.count()) } -// reinstall makes conn the current connection again, and returns its -// OnConnect ctx, 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) context.Context { +// 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.shutdownCh = make(chan struct{}) - c.h.connMutex.Unlock() - return connCtx(c.h) + c.h.connCtx, c.h.connCancel = context.WithCancel(context.Background()) } // OnConnect's tail, a logout and app state transitions all run under the @@ -1030,7 +1032,12 @@ func TestGregorOnConnectTailStress(t *testing.T) { return default: } - _ = c.run(c.reinstall(conn)) + 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 { @@ -1097,30 +1104,48 @@ func (failingRPCClient) Notify(context.Context, string, any, time.Duration) erro return errors.New("no server") } -// An OnConnect whose connection shuts down, before it starts or while it -// runs, installs no gregor client, leaves the chat syncer alone, and fails -// with an error the connection does not retry. +// 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 _, during := range []bool{false, true} { - t.Run(fmt.Sprintf("during %v", during), func(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) - require.NoError(t, h.Connect(closedPortURI(t))) + uri := closedPortURI(t) + require.NoError(t, h.Connect(uri)) defer h.Shutdown(context.Background()) conn := currentConn(h) - ctx := connCtx(h) h.authParamsForTest = func(context.Context) (gregor1.UID, gregor1.DeviceID, gregor1.SessionToken, *libkb.NIST, error) { - if during { - require.NoError(t, h.Disconnect()) + if tt.during != nil { + tt.during(t, h, uri) } return gregor1.UID(make([]byte, 16)), gregor1.DeviceID(make([]byte, 16)), "", nil, nil } - if !during { - require.NoError(t, h.Disconnect()) + if tt.before != nil { + tt.before(t, h, uri) } local, remote := net.Pipe() @@ -1130,12 +1155,12 @@ func TestGregorOnConnectAfterShutdownInstallsNothing(t *testing.T) { defer xp.Close() srv := rpc.NewServer(xp, libkb.MakeWrapError(tc.G)) - err := h.OnConnect(ctx, conn, failingRPCClient{}, srv) + err := h.OnConnect(context.Background(), conn, failingRPCClient{}, srv) require.ErrorIs(t, err, chat.ErrDuplicateConnection) - require.False(t, h.ShouldRetryOnConnect(err), "retrying a connection that shut down") + 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 shut down") - require.Zero(t, syncer.connectCalls(), "chat sync ran for a connection that shut down") + 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") }) } } From 61d2cab4d96e36297db997e36ee6ab4f90729295 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 19:47:00 -0400 Subject: [PATCH 105/127] fix(gregor): decide every connect in reconcile, including desktop suspend connect and reconnect now go through reconcile, the one place that checks the mobile app state and the desktop suspend, so a ping-timeout reconnect or a connect no longer dials while the machine is suspended. The handler drops its own copy of the uri and reads the gate's. --- go/service/gregor.go | 30 +++++------- go/service/gregor_conn.go | 55 ++++++++++----------- go/service/gregor_conn_test.go | 88 +++++++++++++++++++++++++++++++++- 3 files changed, 124 insertions(+), 49 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 1b3a82dace3b..69e876fc1fd8 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -208,7 +208,6 @@ type gregorHandler struct { // 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. connCtx context.Context @@ -288,12 +287,6 @@ func (g *gregorHandler) Init() { go g.syncReplayThread() } -func (g *gregorHandler) GetURI() *rpc.FMPURI { - g.connMutex.Lock() - defer g.connMutex.Unlock() - return g.uri -} - func (g *gregorHandler) GetIncomingClient() gregor1.IncomingInterface { cli := g.getRPCCli() if g.IsShutdown() || cli == nil { @@ -426,8 +419,8 @@ func (g *gregorHandler) setReachability(r *reachability) { g.reachability = r } -// Connect connects to uri unless the app is in BACKGROUND, in which case it -// connects once the app leaves BACKGROUND. +// 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) } @@ -457,12 +450,11 @@ func (g *gregorHandler) connectNow(uri *rpc.FMPURI) (err error) { // set up this channel. g.shutdownCh = make(chan struct{}) g.connCtx, g.connCancel = context.WithCancel(context.Background()) - g.uri = uri go g.pushStateNewDataDebouncer(g.shutdownCh) if uri.UseTLS() { - err = g.connectTLS(ctx) + err = g.connectTLS(ctx, uri) } else { - err = g.connectNoTLS(ctx) + err = g.connectNoTLS(ctx, uri) } return err @@ -834,6 +826,12 @@ func (g *gregorHandler) OnConnect(rpcCtx 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, @@ -843,7 +841,7 @@ func (g *gregorHandler) OnConnect(rpcCtx 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, }) @@ -1664,13 +1662,12 @@ func (g *gregorHandler) pingLoop(ctx context.Context, shutdownCh chan struct{}) } // connMutex must be locked before calling this -func (g *gregorHandler) connectTLS(ctx context.Context) error { +func (g *gregorHandler) connectTLS(ctx context.Context, uri *rpc.FMPURI) error { if g.conn != nil { g.chatLog.Debug(ctx, "skipping connect, conn is not nil") return nil } - uri := g.uri g.chatLog.Debug(ctx, "connecting to gregord via TLS at %s", uri) rawCA := g.G().Env.GetBundledCA(uri.Host) if len(rawCA) == 0 { @@ -1716,12 +1713,11 @@ func (g *gregorHandler) connectTLS(ctx context.Context) error { } // connMutex must be locked before calling this -func (g *gregorHandler) connectNoTLS(ctx context.Context) error { +func (g *gregorHandler) connectNoTLS(ctx context.Context, uri *rpc.FMPURI) error { if g.conn != nil { g.chatLog.Debug(ctx, "skipping connect, conn is not nil") return nil } - uri := g.uri g.chatLog.Debug(ctx, "connecting to gregord without TLS at %s", uri) t := newConnTransport(g.G().ExternalG(), uri.HostPort) g.transportForTesting = t diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index 8889e6b29c10..f2a208733a0c 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -35,7 +35,8 @@ type gregorAppState interface { // 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. +// 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: every operation here is synchronous with a result its @@ -53,8 +54,8 @@ type gregorConnGate struct { mu sync.Mutex // uri is the last URI a connect asked for. It is kept when the connect is - // held back in BACKGROUND, so the monitor connects once the app leaves - // BACKGROUND. + // 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. @@ -83,10 +84,6 @@ func newGregorConnGate(mobile gregorAppState, desktop *libkb.DesktopAppState, co } } -func (c *gregorConnGate) canConnect(state keybase1.MobileAppState) bool { - return state != keybase1.MobileAppState_BACKGROUND -} - // start reconciles against the current state and starts the monitor. func (c *gregorConnGate) start() { c.startOnce.Do(func() { @@ -104,10 +101,10 @@ func (c *gregorConnGate) stop() { c.stopOnce.Do(func() { close(c.stopCh) }) } -// connect connects to uri unless the app is in BACKGROUND. 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. +// 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() @@ -117,12 +114,7 @@ func (c *gregorConnGate) connect(ctx context.Context, uri *rpc.FMPURI, reset boo return err } } - state := c.mobile.State() - if !c.canConnect(state) { - c.debug(ctx, "connect: not connecting in %v", state) - return nil - } - return c.conn.connectNow(uri) + return c.reconcileLocked(ctx) } // forget resets the connection and drops the uri, so nothing reconnects until @@ -135,8 +127,8 @@ func (c *gregorConnGate) forget(ctx context.Context) error { return c.conn.Reset() } -// reconnect drops a live connection and connects again, unless the app is -// now in BACKGROUND. didShutdown reports whether a connection was dropped. +// reconnect drops a live connection and connects again when reconcile allows +// it. didShutdown reports whether a connection was dropped. func (c *gregorConnGate) reconnect(ctx context.Context) (didShutdown bool, err error) { c.mu.Lock() defer c.mu.Unlock() @@ -146,30 +138,33 @@ func (c *gregorConnGate) reconnect(ctx context.Context) (didShutdown bool, err e } c.debug(ctx, "Reconnect: reconnecting to server") c.conn.Shutdown(ctx) - if state := c.mobile.State(); !c.canConnect(state) { - c.debug(ctx, "Reconnect: not connecting in %v", state) - return true, nil - } - return true, c.conn.connectNow(c.uri) + return true, c.reconcileLocked(ctx) } 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 !c.canConnect(state) || suspended { + if state == keybase1.MobileAppState_BACKGROUND || suspended { c.debug(ctx, "reconcile: disconnecting in %v (suspended: %v)", state, suspended) c.conn.Shutdown(ctx) - return + return nil } // Nothing asked to connect yet, for example before login. if c.uri == nil { - return + return nil } c.debug(ctx, "reconcile: connecting in %v", state) - if err := c.conn.connectNow(c.uri); err != nil { - c.debug(ctx, "reconcile: error connecting: %s", err) - } + return c.conn.connectNow(c.uri) } func (c *gregorConnGate) monitor(ctx context.Context, state keybase1.MobileAppState, suspended bool) { diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index 5544f7fe3da7..f6c5242bfa8a 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -416,6 +416,38 @@ func TestGregorConnDesktopSuspend(t *testing.T) { 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)) + didShutdown, err := c.gate.reconnect(context.Background()) + require.NoError(t, err) + require.True(t, didShutdown) + 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 @@ -587,7 +619,7 @@ func TestGregorHandlerConnectRaces(t *testing.T) { return default: } - _ = h.GetURI() + _ = gateURI(h) runtime.Gosched() } }() @@ -599,7 +631,13 @@ func TestGregorHandlerConnectRaces(t *testing.T) { } close(stop) <-readerDone - require.Equal(t, uri, h.GetURI()) + 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 } // closedPortURI points at a closed port, so a connection only retries until @@ -1164,3 +1202,49 @@ func TestGregorOnConnectAfterShutdownInstallsNothing(t *testing.T) { }) } } + +// 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) +} From ee90e33ec0fdb901c3988d9ff0dccc483cb726a5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 19:47:31 -0400 Subject: [PATCH 106/127] fix(gregor): end a connection's goroutines with its ctx and start none for a failed connect The ping loop, the push state debouncer and loggedIn follow the connection's ctx instead of a separate shutdown channel. connectNow creates the ctx and starts those goroutines only once the connection exists, so a connect that fails, as with no bundled CA for the host, no longer leaves a debouncer running that nothing stops. --- go/service/gregor.go | 111 +++++++++++++-------------------- go/service/gregor_conn_test.go | 67 +++++++++++++++++++- 2 files changed, 108 insertions(+), 70 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 69e876fc1fd8..9d246dc4c400 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -209,7 +209,8 @@ type gregorHandler struct { connMutex sync.Mutex conn *rpc.Connection // connCtx lives as long as conn: Shutdown cancels it under connMutex. - // OnConnect runs under a ctx derived from it. + // 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 @@ -229,7 +230,6 @@ 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{} @@ -446,18 +446,32 @@ func (g *gregorHandler) connectNow(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.connCtx, g.connCancel = context.WithCancel(context.Background()) - go g.pushStateNewDataDebouncer(g.shutdownCh) + var conn *rpc.Connection if uri.UseTLS() { - err = g.connectTLS(ctx, uri) + conn, err = g.connectTLS(ctx, uri) + if err != nil { + return err + } } else { - err = g.connectNoTLS(ctx, uri) + 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 { @@ -530,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 @@ -550,7 +564,7 @@ func (g *gregorHandler) pushStateNewDataDebouncer(shutdownCh chan struct{}) { } case <-time.After(dur): trigger() - case <-shutdownCh: + case <-done: return } } @@ -1433,7 +1447,6 @@ func (g *gregorHandler) Shutdown(ctx context.Context) { return } - close(g.shutdownCh) g.connCancel() g.conn.Shutdown() // After connCancel, which cancels the ctx of an OnConnect in flight, so a @@ -1468,15 +1481,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, + // Check to see if we have been shut down. g.connMutex.Lock() - shutdownCh := g.shutdownCh + connCtx := g.connCtx g.connMutex.Unlock() - select { - case <-shutdownCh: + 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 @@ -1565,7 +1575,7 @@ func (g *gregorHandler) forcePing(ctx context.Context) { } } -func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCh chan struct{}, shutdownCancel context.CancelFunc) { +func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, done <-chan struct{}, shutdownCancel context.CancelFunc) { g.connMutex.Lock() pingCli := g.pingCli g.connMutex.Unlock() @@ -1604,7 +1614,7 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCh chan select { case err = <-doneCh: - case <-shutdownCh: + case <-done: g.chatLog.Debug(ctx, "ping loop: id: %x shutdown received", id) shutdownCancel() return @@ -1629,9 +1639,9 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, shutdownCh chan } } -// pingLoop runs until shutdownCh, the channel of the connection it was -// started for, closes. -func (g *gregorHandler) pingLoop(ctx context.Context, shutdownCh chan struct{}) { +// 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() @@ -1649,10 +1659,10 @@ func (g *gregorHandler) pingLoop(ctx context.Context, shutdownCh chan struct{}) select { case <-g.forcePingCh: g.chatLog.Debug(pingCtx, "ping loop: forced attempt") - g.pingOnce(pingCtx, id, shutdownCh, shutdownCancel) + g.pingOnce(pingCtx, id, done, shutdownCancel) case <-ticker.C: - g.pingOnce(pingCtx, id, shutdownCh, shutdownCancel) - case <-shutdownCh: + g.pingOnce(pingCtx, id, done, shutdownCancel) + case <-done: g.chatLog.Debug(pingCtx, "ping loop: id: %x shutdown received", id) shutdownCancel() return @@ -1661,17 +1671,11 @@ func (g *gregorHandler) pingLoop(ctx context.Context, shutdownCh chan struct{}) } } -// connMutex must be locked before calling this -func (g *gregorHandler) connectTLS(ctx context.Context, uri *rpc.FMPURI) error { - if g.conn != nil { - g.chatLog.Debug(ctx, "skipping connect, conn is not nil") - return nil - } - +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 @@ -1688,36 +1692,17 @@ func (g *gregorHandler) connectTLS(ctx context.Context, uri *rpc.FMPURI) 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, g.shutdownCh) - - return nil + libkb.NewProxyDialable(g.G().Env)), nil } // connMutex must be locked before calling this -func (g *gregorHandler) connectNoTLS(ctx context.Context, uri *rpc.FMPURI) error { - if g.conn != nil { - g.chatLog.Debug(ctx, "skipping connect, conn is not nil") - return nil - } +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 @@ -1729,19 +1714,9 @@ func (g *gregorHandler) connectNoTLS(ctx context.Context, uri *rpc.FMPURI) 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, g.shutdownCh) - - return nil } func (g *gregorHandler) currentUID() gregor1.UID { diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index f6c5242bfa8a..9210b028ea35 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -600,7 +600,7 @@ func TestGregorConnStress(t *testing.T) { } // Connects and shutdowns race the connection's own goroutines: OnConnect -// reads the URI, the ping loop watches its shutdown channel, and the +// reads the URI, the ping loop watches its connection's ctx, and the // transport dials. func TestGregorHandlerConnectRaces(t *testing.T) { tc, g := setupGregorTest(t) @@ -640,6 +640,70 @@ func gateURI(h *gregorHandler) *rpc.FMPURI { 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 { @@ -1040,7 +1104,6 @@ func (c *onConnectTailTest) reinstall(conn *rpc.Connection) { c.h.connMutex.Lock() defer c.h.connMutex.Unlock() c.h.conn = conn - c.h.shutdownCh = make(chan struct{}) c.h.connCtx, c.h.connCancel = context.WithCancel(context.Background()) } From 8786896a49967d9eaf8477c873fcd3a02ad05278 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 20:05:46 -0400 Subject: [PATCH 107/127] refactor(gregor): make Reconnect asynchronous and run onConnect's sync in one critical section --- go/chat/deliverer.go | 4 +- go/chat/server.go | 6 +- go/chat/server_test.go | 4 +- go/chat/types/interfaces.go | 3 +- go/kbtest/chat.go | 4 +- go/service/gregor.go | 136 ++++++++++++------------ go/service/gregor_conn.go | 95 ++++++++++++++--- go/service/gregor_conn_test.go | 186 +++++++++++++++++++++++++++------ 8 files changed, 310 insertions(+), 128 deletions(-) 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/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/types/interfaces.go b/go/chat/types/interfaces.go index cbf170ca3174..cfb002e0615f 100644 --- a/go/chat/types/interfaces.go +++ b/go/chat/types/interfaces.go @@ -700,7 +700,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/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/service/gregor.go b/go/service/gregor.go index 9d246dc4c400..3bde37ca99c8 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -200,17 +200,18 @@ type gregorHandler struct { 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 decides when to connect and disconnect, and runs everything + // OnConnect applies after SyncAll, so none of it lands after a Shutdown + // for the connection it came from. connGate *gregorConnGate // This mutex protects the con object connMutex sync.Mutex conn *rpc.Connection - // 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 lives as long as conn: Shutdown cancels it under connMutex, and + // so does cancel, ahead of a Shutdown. 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 @@ -438,8 +439,12 @@ func (g *gregorHandler) connectNow(uri *rpc.FMPURI) (err error) { g.connMutex.Lock() defer g.connMutex.Unlock() if g.conn != nil { - g.chatLog.Debug(ctx, "skipping connect, conn is not nil") - return nil + if g.connCtx.Err() == nil { + g.chatLog.Debug(ctx, "skipping connect, conn is not nil") + return nil + } + g.chatLog.Debug(ctx, "replacing a cancelled conn") + g.shutdownLocked(ctx) } defer func() { close(g.connectHappened) @@ -895,13 +900,20 @@ func (g *gregorHandler) onGateIfCurrent(ctx context.Context, f func()) bool { 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. +// onConnectSynced applies a SyncAll result for OnConnect's connection. It +// holds the connection gate throughout, so no Shutdown lands during it, and +// nothing it calls may call back into the gate. A disconnect cancels ctx +// before it takes the gate, 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 { + g.connGate.mu.Lock() + defer g.connGate.mu.Unlock() + if ctx.Err() != nil { + return chat.ErrDuplicateConnection + } + // 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 @@ -909,26 +921,18 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.Remot // 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.onGateIfCurrent(ctx, func() { - if g.badger != nil { - g.badger.PushChatFullUpdate(ctx, syncAllRes.Badge) - } - }) { - return chat.ErrDuplicateConnection + if g.badger != nil { + g.badger.PushChatFullUpdate(ctx, syncAllRes.Badge) } // Sync chat data using a Syncer object // This commits the new inbox version to persistent storage. - // It can't run under the gate, since the sync calls the server through - // this handler and may ask the gate to reconnect. The Syncer ignores a - // cancelled ctx, and Shutdown marks it disconnected after cancelling. + // The Syncer ignores a cancelled ctx. 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 } @@ -940,25 +944,24 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.Remot // 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) - } - } - if g.reachability != nil { - g.chatLog.Debug(ctx, "setting reachability") - g.reachability.setReachability(keybase1.Reachability{ - Reachable: keybase1.Reachable_YES, - }) - } - }) { + if ctx.Err() != nil { return chat.ErrDuplicateConnection } + 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) + } + } + if g.reachability != nil { + g.chatLog.Debug(ctx, "setting reachability") + g.reachability.setReachability(keybase1.Reachability{ + Reachable: keybase1.Reachable_YES, + }) + } // Broadcast reconnect oobm. Spawn this off into a goroutine so that we don't delay // reconnection any longer than we have to. @@ -972,13 +975,12 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.Remot }(g.makeReconnectOobm()) // 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()) - }) { + if ctx.Err() != nil { return chat.ErrDuplicateConnection } + g.chatLog.Debug(ctx, "setting first connect to false") + g.setFirstConnect(false) + g.setConnectedAt(time.Now()) g.chatLog.Debug(ctx, "OnConnect complete") return nil } @@ -1434,6 +1436,16 @@ func (g *gregorHandler) handleOutOfBandMessage(ctx context.Context, obm gregor.O } } +// cancel cancels the current connection's ctx, and so the OnConnect running +// for it, without shutting it down. +func (g *gregorHandler) cancel() { + g.connMutex.Lock() + defer g.connMutex.Unlock() + if g.conn != nil { + g.connCancel() + } +} + // 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 @@ -1442,7 +1454,11 @@ func (g *gregorHandler) Shutdown(ctx context.Context) { defer g.chatLog.Trace(ctx, nil, "Shutdown")() g.connMutex.Lock() defer g.connMutex.Unlock() + g.shutdownLocked(ctx) +} +// shutdownLocked is Shutdown with connMutex held. +func (g *gregorHandler) shutdownLocked(ctx context.Context) { if g.conn == nil { return } @@ -1554,17 +1570,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) { - return g.connGate.reconnect(ctx) +// 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) { @@ -1575,7 +1591,7 @@ func (g *gregorHandler) forcePing(ctx context.Context) { } } -func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, done <-chan struct{}, shutdownCancel context.CancelFunc) { +func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, done <-chan struct{}) { g.connMutex.Lock() pingCli := g.pingCli g.connMutex.Unlock() @@ -1616,25 +1632,13 @@ func (g *gregorHandler) pingOnce(ctx context.Context, id []byte, done <-chan str case err = <-doneCh: 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) } } } @@ -1659,9 +1663,9 @@ func (g *gregorHandler) pingLoop(ctx context.Context, done <-chan struct{}) { select { case <-g.forcePingCh: g.chatLog.Debug(pingCtx, "ping loop: forced attempt") - g.pingOnce(pingCtx, id, done, shutdownCancel) + g.pingOnce(pingCtx, id, done) case <-ticker.C: - g.pingOnce(pingCtx, id, done, shutdownCancel) + g.pingOnce(pingCtx, id, done) case <-done: g.chatLog.Debug(pingCtx, "ping loop: id: %x shutdown received", id) shutdownCancel() diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index f2a208733a0c..ba4ec2a70cf2 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -3,6 +3,7 @@ package service import ( "context" "sync" + "sync/atomic" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/protocol/keybase1" @@ -12,8 +13,11 @@ import ( // 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 connects to uri, doing nothing if already connected. A + // cancelled connection is replaced. connectNow(uri *rpc.FMPURI) error + // cancel cancels the current connection's ctx without shutting it down. + cancel() // Shutdown disconnects, doing nothing if not connected. Shutdown(ctx context.Context) Reset() error @@ -34,17 +38,18 @@ type gregorAppState interface { // 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. +// also runs everything OnConnect applies after SyncAll (onConnectSynced holds +// it throughout), so none of it interleaves with a disconnect, and guards the +// uri OnConnect reads. A BACKGROUND, a desktop suspend or a logout cancels the +// connection before taking mu, so it waits only for an OnConnect that is +// already unwinding. // // This is a mutex gate rather than a single owning goroutine like -// kbhttp/manager's Srv: every operation here is synchronous with a result its -// caller needs (connect/reconnect return errors, reconnect also didShutdown), -// 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. +// kbhttp/manager's Srv: connect and forget return errors their callers need, +// and OnConnect must learn on its own goroutine that its connection is no +// longer current so it 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. type gregorConnGate struct { mobile gregorAppState desktop *libkb.DesktopAppState @@ -64,6 +69,11 @@ type gregorConnGate struct { monitorWait <-chan struct{} monitorSuspendWait <-chan struct{} + // reconnectPending is set while a requested reconnect waits for mu. + reconnectPending atomic.Bool + // reconcileCh has the monitor reconcile. + reconcileCh chan struct{} + startOnce sync.Once stopOnce sync.Once stopCh chan struct{} @@ -79,6 +89,7 @@ func newGregorConnGate(mobile gregorAppState, desktop *libkb.DesktopAppState, co conn: conn, debug: debug, onForeground: onForeground, + reconcileCh: make(chan struct{}, 1), stopCh: make(chan struct{}), monitorDone: make(chan struct{}), } @@ -92,6 +103,7 @@ func (c *gregorConnGate) start() { c.debug(ctx, "monitorAppState: starting up in %v (suspended: %v)", state, suspended) c.reconcile(ctx) go c.monitor(ctx, state, suspended) + go c.cancelWhileDown(state, suspended) }) } @@ -120,6 +132,7 @@ func (c *gregorConnGate) connect(ctx context.Context, uri *rpc.FMPURI, reset boo // forget resets the connection and drops the uri, so nothing reconnects until // the next connect. func (c *gregorConnGate) forget(ctx context.Context) error { + c.conn.cancel() c.mu.Lock() defer c.mu.Unlock() c.debug(ctx, "forget: resetting and forgetting the uri") @@ -127,18 +140,31 @@ func (c *gregorConnGate) forget(ctx context.Context) error { return c.conn.Reset() } +// requestReconnect reconnects without waiting. Requests made while one waits +// for mu are merged into it. +func (c *gregorConnGate) requestReconnect(ctx context.Context) { + if !c.reconnectPending.CompareAndSwap(false, true) { + c.debug(ctx, "Reconnect: merged into a pending reconnect") + return + } + go c.reconnect(libkb.CopyTagsToBackground(ctx)) +} + // reconnect drops a live connection and connects again when reconcile allows -// it. didShutdown reports whether a connection was dropped. -func (c *gregorConnGate) reconnect(ctx context.Context) (didShutdown bool, err error) { +// it. +func (c *gregorConnGate) reconnect(ctx context.Context) { c.mu.Lock() defer c.mu.Unlock() + c.reconnectPending.Store(false) if !c.conn.IsConnected() { c.debug(ctx, "Reconnect: skipping reconnect, already disconnected") - return false, nil + return } c.debug(ctx, "Reconnect: reconnecting to server") c.conn.Shutdown(ctx) - return true, c.reconcileLocked(ctx) + if err := c.reconcileLocked(ctx); err != nil { + c.debug(ctx, "Reconnect: error connecting: %s", err) + } } func (c *gregorConnGate) reconcile(ctx context.Context) { @@ -149,12 +175,17 @@ func (c *gregorConnGate) reconcile(ctx context.Context) { } } +// keepsDown reports whether no connection may exist in state. +func keepsDown(state keybase1.MobileAppState, suspended bool) bool { + return state == keybase1.MobileAppState_BACKGROUND || suspended +} + // 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 { + if keepsDown(state, suspended) { c.debug(ctx, "reconcile: disconnecting in %v (suspended: %v)", state, suspended) c.conn.Shutdown(ctx) return nil @@ -179,6 +210,7 @@ func (c *gregorConnGate) monitor(ctx context.Context, state keybase1.MobileAppSt select { case <-next: case <-nextSuspend: + case <-c.reconcileCh: case <-c.stopCh: return } @@ -190,3 +222,36 @@ func (c *gregorConnGate) monitor(ctx context.Context, state keybase1.MobileAppSt c.reconcile(ctx) } } + +// cancelWhileDown cancels the connection on every change to a state that +// keeps it down, without mu, so the monitor's reconcile finds any OnConnect +// holding mu already unwinding. It is not part of the monitor, which may +// itself be waiting for mu when the change lands. +func (c *gregorConnGate) cancelWhileDown(state keybase1.MobileAppState, suspended bool) { + for { + next := c.mobile.NextUpdate(state) + nextSuspend := c.desktop.NextSuspendUpdate(suspended) + select { + case <-next: + case <-nextSuspend: + case <-c.stopCh: + return + } + state, suspended = c.mobile.State(), c.desktop.Suspended() + if keepsDown(state, suspended) { + c.cancelAndReconcile() + } + } +} + +// cancelAndReconcile cancels the connection and has the monitor reconcile +// after that, which shuts it down or, if the state has come back up since it +// was read, replaces it. The monitor may already have connected for that +// later state, and nothing else would follow the cancel. +func (c *gregorConnGate) cancelAndReconcile() { + c.conn.cancel() + select { + case c.reconcileCh <- struct{}{}: + default: + } +} diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index 9210b028ea35..f7dee513eee4 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -27,11 +27,13 @@ import ( ) // fakeGregorConn models the handler's connection: it can exist without being -// connected (stale), and connectNow does nothing while one exists. +// connected (stale), and connectNow does nothing while one exists that is not +// cancelled. type fakeGregorConn struct { sync.Mutex exists bool up bool + cancelled bool uri *rpc.FMPURI connects int shutdowns int @@ -41,6 +43,9 @@ type fakeGregorConn struct { func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { f.Lock() defer f.Unlock() + if f.exists && f.cancelled { + f.shutdownLocked() + } if !f.exists { f.exists, f.up = true, true f.uri = uri @@ -49,11 +54,21 @@ func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { return nil } +func (f *fakeGregorConn) cancel() { + f.Lock() + defer f.Unlock() + f.cancelled = f.exists +} + func (f *fakeGregorConn) Shutdown(context.Context) { f.Lock() defer f.Unlock() + f.shutdownLocked() +} + +func (f *fakeGregorConn) shutdownLocked() { if f.exists { - f.exists, f.up = false, false + f.exists, f.up, f.cancelled = false, false, false f.shutdowns++ } } @@ -81,14 +96,14 @@ func (f *fakeGregorConn) IsConnected() bool { } type fakeGregorCounts struct { - up bool + up, cancelled 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} + return fakeGregorCounts{up: f.up, cancelled: f.cancelled, connects: f.connects, shutdowns: f.shutdowns, resets: f.resets} } func (f *fakeGregorConn) lastURI() *rpc.FMPURI { @@ -299,8 +314,7 @@ func (c *gregorConnTest) requireStaysDown(t *testing.T, why string) { connects := c.conn.counts().connects for _, state := range allAppStates { c.update(t, state) - _, err := c.gate.reconnect(context.Background()) - require.NoError(t, err) + 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) @@ -379,9 +393,7 @@ func TestGregorConnReconnectInBackground(t *testing.T) { uri := testGregorURI(t, "gregord.test") require.NoError(t, c.gate.connect(context.Background(), uri, false)) - didShutdown, err := c.gate.reconnect(context.Background()) - require.NoError(t, err) - require.True(t, didShutdown) + 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 @@ -391,15 +403,26 @@ func TestGregorConnReconnectInBackground(t *testing.T) { c.gate.mu.Unlock() c.waitMonitor(t) require.NoError(t, c.conn.connectNow(uri)) - didShutdown, err = c.gate.reconnect(context.Background()) - require.NoError(t, err) - require.True(t, didShutdown) + c.gate.reconnect(context.Background()) c.requireUp(t, false, "reconnect connected in BACKGROUND") + require.Equal(t, fakeGregorCounts{connects: 3, shutdowns: 3}, c.conn.counts()) - didShutdown, err = c.gate.reconnect(context.Background()) - require.NoError(t, err) - require.False(t, didShutdown) + 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 cancel acting on a BACKGROUND read after the monitor has already +// connected for the state that followed it does not leave that connection +// cancelled. +func TestGregorConnStaleCancelIsReconciled(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)) + c.gate.cancelAndReconcile() + want := fakeGregorCounts{up: true, connects: 2, shutdowns: 1} + require.Eventually(t, func() bool { return c.conn.counts() == want }, 10*time.Second, time.Millisecond, + "cancelled connection left in place: %+v", c.conn.counts()) } func TestGregorConnDesktopSuspend(t *testing.T) { @@ -432,9 +455,7 @@ func TestGregorReconnectWhileSuspendedDoesNotConnect(t *testing.T) { c.gate.mu.Unlock() c.waitMonitor(t) require.NoError(t, c.conn.connectNow(uri)) - didShutdown, err := c.gate.reconnect(context.Background()) - require.NoError(t, err) - require.True(t, didShutdown) + c.gate.reconnect(context.Background()) c.requireUp(t, false, "reconnect connected while suspended") require.Equal(t, fakeGregorCounts{connects: 2, shutdowns: 2}, c.conn.counts()) @@ -527,15 +548,17 @@ func TestGregorConnStress(t *testing.T) { return default: } - switch (i + w) % 4 { + switch (i + w) % 5 { case 0: _ = gate.connect(ctx, uri, false) case 1: _ = gate.connect(ctx, uri, true) case 2: _ = gate.forget(ctx) + case 3: + gate.cancelAndReconcile() default: - _, _ = gate.reconnect(ctx) + gate.requestReconnect(ctx) } runtime.Gosched() } @@ -779,6 +802,25 @@ func TestGregorHandlerConnectInBackground(t *testing.T) { h.Shutdown(context.Background()) } +// A connection a disconnect cancelled but has yet to shut down, as when the +// app leaves BACKGROUND again before the monitor acts, is replaced by the +// next connect rather than kept with its ping loop gone. +func TestGregorHandlerConnectReplacesCancelledConn(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + h := newGregorHandler(g) + require.NoError(t, h.Connect(closedPortURI(t))) + defer h.Shutdown(context.Background()) + cancelled := currentConn(h) + h.cancel() + h.connGate.reconcile(context.Background()) + require.NotSame(t, cancelled, currentConn(h), "kept a cancelled connection") + h.connMutex.Lock() + defer h.connMutex.Unlock() + require.NoError(t, h.connCtx.Err(), "the connection's ctx is cancelled") +} + // acceptingListener accepts and holds connections, counting them, so a // connection dials successfully and then fails in OnConnect. type acceptingListener struct { @@ -1030,17 +1072,97 @@ func TestGregorOnConnectTailAfterLogout(t *testing.T) { 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 logout or a BACKGROUND landing during the chat sync, which holds the +// gate, cancels the sync instead of waiting it out. It leaves the syncer +// disconnected and stops the rest of the tail. +func TestGregorDisconnectDuringSyncWaitsForCancelledSyncOnly(t *testing.T) { + for _, tt := range []struct { + name string + disconnect func(c *onConnectTailTest) + }{ + {name: "logout", disconnect: func(c *onConnectTailTest) { _ = c.h.Disconnect() }}, + {name: "background", disconnect: func(c *onConnectTailTest) { + c.h.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitNoConn(c.h) + }}, + // The monitor is still waiting for the gate to reconcile INACTIVE + // when BACKGROUND lands. + {name: "inactive then background", disconnect: func(c *onConnectTailTest) { + c.h.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) + time.Sleep(20 * time.Millisecond) + c.h.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) + waitNoConn(c.h) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + c := setupOnConnectTail(t) + c.h.connGate.start() + t.Cleanup(c.h.connGate.stop) + syncing := make(chan struct{}) + // A chat sync that runs until its connection is cancelled. + c.syncer.onConnected = func() { + close(syncing) + <-c.ctx.Done() + } + runErr := make(chan error, 1) + go func() { runErr <- c.run(c.ctx) }() + <-syncing + + done := make(chan struct{}) + go func() { + defer close(done) + tt.disconnect(c) + }() + select { + case <-done: + case <-time.After(200 * time.Millisecond): + // Let the sync, and so the disconnect, finish. + c.h.cancel() + <-done + t.Fatal("disconnect waited for the sync instead of cancelling it") + } + require.ErrorIs(t, <-runErr, chat.ErrDuplicateConnection) + require.False(t, hasConn(c.h), "connection left up") + require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected") + require.Equal(t, 1, c.badger.count(), "badges pushed after the disconnect") + require.Empty(t, c.h.replayCh, "gregor state sync ran after the disconnect") + require.True(t, c.h.isFirstConnect(), "first connect cleared after the disconnect") + require.True(t, c.h.connectedSince().IsZero(), "connected time set after the disconnect") + }) + } +} + +func waitNoConn(h *gregorHandler) { + for deadline := time.Now().Add(10 * time.Second); hasConn(h) && time.Now().Before(deadline); { + time.Sleep(time.Millisecond) + } +} + +// Reconnect returns without waiting for the gate, which an OnConnect sync can +// hold, and merges requests made while one waits for it. +func TestGregorReconnectDoesNotWaitForGate(t *testing.T) { + tc, g := setupGregorTest(t) + defer tc.Cleanup() + g.Syncer = chat.NewSyncer(g) + h := newGregorHandler(g) + + h.connGate.mu.Lock() + done := make(chan struct{}) + go func() { + defer close(done) + h.Reconnect(context.Background()) + h.Reconnect(context.Background()) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("Reconnect waited for the gate") + } + require.True(t, h.connGate.reconnectPending.Load(), "no reconnect pending") + h.connGate.mu.Unlock() + <-done + require.Eventually(t, func() bool { return !h.connGate.reconnectPending.Load() }, + 10*time.Second, time.Millisecond, "pending reconnect did not run") } // A Shutdown that lands between the gregor badge push and the connected @@ -1072,7 +1194,7 @@ func TestGregorShutdownCancelsBeforeSyncerDisconnected(t *testing.T) { c.syncer.onDisconnected = func() { _ = c.syncer.Connected(c.ctx, chat1.RemoteClient{}, c.uid, &chat1.SyncChatRes{}) } - require.NoError(t, c.h.Disconnect()) + c.h.Shutdown(context.Background()) require.False(t, c.syncer.IsConnected(context.Background()), "syncer connected after shutdown") } From e68af1b790b71a00703bf8e31817aaa5a8d87620 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 20:15:15 -0400 Subject: [PATCH 108/127] refactor(gregor): keep Reconnect asynchronous; return onConnect to short gate holds --- go/service/gregor.go | 113 ++++++++----------- go/service/gregor_conn.go | 91 ++++----------- go/service/gregor_conn_test.go | 199 ++++++++++----------------------- 3 files changed, 128 insertions(+), 275 deletions(-) diff --git a/go/service/gregor.go b/go/service/gregor.go index 3bde37ca99c8..7484ee6836db 100644 --- a/go/service/gregor.go +++ b/go/service/gregor.go @@ -200,18 +200,17 @@ type gregorHandler struct { reachability *reachability chatLog utils.DebugLabeler - // connGate decides when to connect and disconnect, and runs everything - // OnConnect applies after SyncAll, so none of it lands after a Shutdown - // for the connection it came from. + // 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 - // connCtx lives as long as conn: Shutdown cancels it under connMutex, and - // so does cancel, ahead of a Shutdown. OnConnect runs under a ctx derived - // from it, and the connection's ping loop and push state debouncer exit - // when it is done. + // 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 @@ -439,12 +438,8 @@ func (g *gregorHandler) connectNow(uri *rpc.FMPURI) (err error) { g.connMutex.Lock() defer g.connMutex.Unlock() if g.conn != nil { - if g.connCtx.Err() == nil { - g.chatLog.Debug(ctx, "skipping connect, conn is not nil") - return nil - } - g.chatLog.Debug(ctx, "replacing a cancelled conn") - g.shutdownLocked(ctx) + g.chatLog.Debug(ctx, "skipping connect, conn is not nil") + return nil } defer func() { close(g.connectHappened) @@ -900,20 +895,13 @@ func (g *gregorHandler) onGateIfCurrent(ctx context.Context, f func()) bool { return true } -// onConnectSynced applies a SyncAll result for OnConnect's connection. It -// holds the connection gate throughout, so no Shutdown lands during it, and -// nothing it calls may call back into the gate. A disconnect cancels ctx -// before it takes the gate, so each step applies only while ctx is live, and -// OnConnect then fails with ErrDuplicateConnection. +// 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 { - g.connGate.mu.Lock() - defer g.connGate.mu.Unlock() - if ctx.Err() != nil { - return chat.ErrDuplicateConnection - } - // 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 @@ -921,18 +909,27 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.Remot // 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. - // The Syncer ignores a cancelled ctx. + // 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 } @@ -944,23 +941,24 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.Remot // Update badging from gregor, and call out to reachability module if we // have one. - if ctx.Err() != nil { - return chat.ErrDuplicateConnection - } - 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) + 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) + } } - } - 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 @@ -975,12 +973,13 @@ func (g *gregorHandler) onConnectSynced(ctx context.Context, chatCli chat1.Remot }(g.makeReconnectOobm()) // No longer first connect if we are now connected. - if ctx.Err() != nil { + 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, "setting first connect to false") - g.setFirstConnect(false) - g.setConnectedAt(time.Now()) g.chatLog.Debug(ctx, "OnConnect complete") return nil } @@ -1436,16 +1435,6 @@ func (g *gregorHandler) handleOutOfBandMessage(ctx context.Context, obm gregor.O } } -// cancel cancels the current connection's ctx, and so the OnConnect running -// for it, without shutting it down. -func (g *gregorHandler) cancel() { - g.connMutex.Lock() - defer g.connMutex.Unlock() - if g.conn != nil { - g.connCancel() - } -} - // 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 @@ -1454,11 +1443,7 @@ func (g *gregorHandler) Shutdown(ctx context.Context) { defer g.chatLog.Trace(ctx, nil, "Shutdown")() g.connMutex.Lock() defer g.connMutex.Unlock() - g.shutdownLocked(ctx) -} -// shutdownLocked is Shutdown with connMutex held. -func (g *gregorHandler) shutdownLocked(ctx context.Context) { if g.conn == nil { return } @@ -1659,7 +1644,7 @@ func (g *gregorHandler) pingLoop(ctx context.Context, done <-chan struct{}) { 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") @@ -1668,10 +1653,10 @@ func (g *gregorHandler) pingLoop(ctx context.Context, done <-chan struct{}) { g.pingOnce(pingCtx, id, done) case <-done: g.chatLog.Debug(pingCtx, "ping loop: id: %x shutdown received", id) - shutdownCancel() + pingCancel() return } - shutdownCancel() + pingCancel() } } diff --git a/go/service/gregor_conn.go b/go/service/gregor_conn.go index ba4ec2a70cf2..2b0fe6b89b27 100644 --- a/go/service/gregor_conn.go +++ b/go/service/gregor_conn.go @@ -3,7 +3,6 @@ package service import ( "context" "sync" - "sync/atomic" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/protocol/keybase1" @@ -13,11 +12,8 @@ import ( // 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. A - // cancelled connection is replaced. + // connectNow connects to uri, doing nothing if already connected. connectNow(uri *rpc.FMPURI) error - // cancel cancels the current connection's ctx without shutting it down. - cancel() // Shutdown disconnects, doing nothing if not connected. Shutdown(ctx context.Context) Reset() error @@ -38,18 +34,17 @@ type gregorAppState interface { // 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 everything OnConnect applies after SyncAll (onConnectSynced holds -// it throughout), so none of it interleaves with a disconnect, and guards the -// uri OnConnect reads. A BACKGROUND, a desktop suspend or a logout cancels the -// connection before taking mu, so it waits only for an OnConnect that is -// already unwinding. +// 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 OnConnect must learn on its own goroutine that its connection is no -// longer current so it 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. +// 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 @@ -69,10 +64,9 @@ type gregorConnGate struct { monitorWait <-chan struct{} monitorSuspendWait <-chan struct{} - // reconnectPending is set while a requested reconnect waits for mu. - reconnectPending atomic.Bool - // reconcileCh has the monitor reconcile. - reconcileCh 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 @@ -89,7 +83,7 @@ func newGregorConnGate(mobile gregorAppState, desktop *libkb.DesktopAppState, co conn: conn, debug: debug, onForeground: onForeground, - reconcileCh: make(chan struct{}, 1), + reconnectCh: make(chan struct{}, 1), stopCh: make(chan struct{}), monitorDone: make(chan struct{}), } @@ -103,7 +97,6 @@ func (c *gregorConnGate) start() { c.debug(ctx, "monitorAppState: starting up in %v (suspended: %v)", state, suspended) c.reconcile(ctx) go c.monitor(ctx, state, suspended) - go c.cancelWhileDown(state, suspended) }) } @@ -132,7 +125,6 @@ func (c *gregorConnGate) connect(ctx context.Context, uri *rpc.FMPURI, reset boo // forget resets the connection and drops the uri, so nothing reconnects until // the next connect. func (c *gregorConnGate) forget(ctx context.Context) error { - c.conn.cancel() c.mu.Lock() defer c.mu.Unlock() c.debug(ctx, "forget: resetting and forgetting the uri") @@ -140,14 +132,14 @@ func (c *gregorConnGate) forget(ctx context.Context) error { return c.conn.Reset() } -// requestReconnect reconnects without waiting. Requests made while one waits -// for mu are merged into it. +// requestReconnect asks the monitor to reconnect and returns without waiting. func (c *gregorConnGate) requestReconnect(ctx context.Context) { - if !c.reconnectPending.CompareAndSwap(false, true) { - c.debug(ctx, "Reconnect: merged into a pending reconnect") - return + select { + case c.reconnectCh <- struct{}{}: + c.debug(ctx, "Reconnect: requested") + default: + c.debug(ctx, "Reconnect: one is already pending") } - go c.reconnect(libkb.CopyTagsToBackground(ctx)) } // reconnect drops a live connection and connects again when reconcile allows @@ -155,7 +147,6 @@ func (c *gregorConnGate) requestReconnect(ctx context.Context) { func (c *gregorConnGate) reconnect(ctx context.Context) { c.mu.Lock() defer c.mu.Unlock() - c.reconnectPending.Store(false) if !c.conn.IsConnected() { c.debug(ctx, "Reconnect: skipping reconnect, already disconnected") return @@ -175,17 +166,12 @@ func (c *gregorConnGate) reconcile(ctx context.Context) { } } -// keepsDown reports whether no connection may exist in state. -func keepsDown(state keybase1.MobileAppState, suspended bool) bool { - return state == keybase1.MobileAppState_BACKGROUND || suspended -} - // 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 keepsDown(state, suspended) { + if state == keybase1.MobileAppState_BACKGROUND || suspended { c.debug(ctx, "reconcile: disconnecting in %v (suspended: %v)", state, suspended) c.conn.Shutdown(ctx) return nil @@ -210,7 +196,9 @@ func (c *gregorConnGate) monitor(ctx context.Context, state keybase1.MobileAppSt select { case <-next: case <-nextSuspend: - case <-c.reconcileCh: + case <-c.reconnectCh: + c.reconnect(ctx) + continue case <-c.stopCh: return } @@ -222,36 +210,3 @@ func (c *gregorConnGate) monitor(ctx context.Context, state keybase1.MobileAppSt c.reconcile(ctx) } } - -// cancelWhileDown cancels the connection on every change to a state that -// keeps it down, without mu, so the monitor's reconcile finds any OnConnect -// holding mu already unwinding. It is not part of the monitor, which may -// itself be waiting for mu when the change lands. -func (c *gregorConnGate) cancelWhileDown(state keybase1.MobileAppState, suspended bool) { - for { - next := c.mobile.NextUpdate(state) - nextSuspend := c.desktop.NextSuspendUpdate(suspended) - select { - case <-next: - case <-nextSuspend: - case <-c.stopCh: - return - } - state, suspended = c.mobile.State(), c.desktop.Suspended() - if keepsDown(state, suspended) { - c.cancelAndReconcile() - } - } -} - -// cancelAndReconcile cancels the connection and has the monitor reconcile -// after that, which shuts it down or, if the state has come back up since it -// was read, replaces it. The monitor may already have connected for that -// later state, and nothing else would follow the cancel. -func (c *gregorConnGate) cancelAndReconcile() { - c.conn.cancel() - select { - case c.reconcileCh <- struct{}{}: - default: - } -} diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index f7dee513eee4..ee0c526149aa 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -27,13 +27,11 @@ import ( ) // fakeGregorConn models the handler's connection: it can exist without being -// connected (stale), and connectNow does nothing while one exists that is not -// cancelled. +// connected (stale), and connectNow does nothing while one exists. type fakeGregorConn struct { sync.Mutex exists bool up bool - cancelled bool uri *rpc.FMPURI connects int shutdowns int @@ -43,9 +41,6 @@ type fakeGregorConn struct { func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { f.Lock() defer f.Unlock() - if f.exists && f.cancelled { - f.shutdownLocked() - } if !f.exists { f.exists, f.up = true, true f.uri = uri @@ -54,21 +49,11 @@ func (f *fakeGregorConn) connectNow(uri *rpc.FMPURI) error { return nil } -func (f *fakeGregorConn) cancel() { - f.Lock() - defer f.Unlock() - f.cancelled = f.exists -} - func (f *fakeGregorConn) Shutdown(context.Context) { f.Lock() defer f.Unlock() - f.shutdownLocked() -} - -func (f *fakeGregorConn) shutdownLocked() { if f.exists { - f.exists, f.up, f.cancelled = false, false, false + f.exists, f.up = false, false f.shutdowns++ } } @@ -96,14 +81,14 @@ func (f *fakeGregorConn) IsConnected() bool { } type fakeGregorCounts struct { - up, cancelled bool + up bool connects, shutdowns, resets int } func (f *fakeGregorConn) counts() fakeGregorCounts { f.Lock() defer f.Unlock() - return fakeGregorCounts{up: f.up, cancelled: f.cancelled, connects: f.connects, shutdowns: f.shutdowns, resets: f.resets} + return fakeGregorCounts{up: f.up, connects: f.connects, shutdowns: f.shutdowns, resets: f.resets} } func (f *fakeGregorConn) lastURI() *rpc.FMPURI { @@ -412,17 +397,46 @@ func TestGregorConnReconnectInBackground(t *testing.T) { require.Equal(t, fakeGregorCounts{connects: 3, shutdowns: 3}, c.conn.counts()) } -// A cancel acting on a BACKGROUND read after the monitor has already -// connected for the state that followed it does not leave that connection -// cancelled. -func TestGregorConnStaleCancelIsReconciled(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)) - c.gate.cancelAndReconcile() +// 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 c.conn.counts() == want }, 10*time.Second, time.Millisecond, - "cancelled connection left in place: %+v", c.conn.counts()) + 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) { @@ -548,15 +562,13 @@ func TestGregorConnStress(t *testing.T) { return default: } - switch (i + w) % 5 { + switch (i + w) % 4 { case 0: _ = gate.connect(ctx, uri, false) case 1: _ = gate.connect(ctx, uri, true) case 2: _ = gate.forget(ctx) - case 3: - gate.cancelAndReconcile() default: gate.requestReconnect(ctx) } @@ -802,25 +814,6 @@ func TestGregorHandlerConnectInBackground(t *testing.T) { h.Shutdown(context.Background()) } -// A connection a disconnect cancelled but has yet to shut down, as when the -// app leaves BACKGROUND again before the monitor acts, is replaced by the -// next connect rather than kept with its ping loop gone. -func TestGregorHandlerConnectReplacesCancelledConn(t *testing.T) { - tc, g := setupGregorTest(t) - defer tc.Cleanup() - g.Syncer = chat.NewSyncer(g) - h := newGregorHandler(g) - require.NoError(t, h.Connect(closedPortURI(t))) - defer h.Shutdown(context.Background()) - cancelled := currentConn(h) - h.cancel() - h.connGate.reconcile(context.Background()) - require.NotSame(t, cancelled, currentConn(h), "kept a cancelled connection") - h.connMutex.Lock() - defer h.connMutex.Unlock() - require.NoError(t, h.connCtx.Err(), "the connection's ctx is cancelled") -} - // acceptingListener accepts and holds connections, counting them, so a // connection dials successfully and then fails in OnConnect. type acceptingListener struct { @@ -1072,97 +1065,17 @@ func TestGregorOnConnectTailAfterLogout(t *testing.T) { require.True(t, c.h.connectedSince().IsZero(), "connected time set after logout") } -// A logout or a BACKGROUND landing during the chat sync, which holds the -// gate, cancels the sync instead of waiting it out. It leaves the syncer -// disconnected and stops the rest of the tail. -func TestGregorDisconnectDuringSyncWaitsForCancelledSyncOnly(t *testing.T) { - for _, tt := range []struct { - name string - disconnect func(c *onConnectTailTest) - }{ - {name: "logout", disconnect: func(c *onConnectTailTest) { _ = c.h.Disconnect() }}, - {name: "background", disconnect: func(c *onConnectTailTest) { - c.h.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitNoConn(c.h) - }}, - // The monitor is still waiting for the gate to reconcile INACTIVE - // when BACKGROUND lands. - {name: "inactive then background", disconnect: func(c *onConnectTailTest) { - c.h.G().MobileAppState.Update(keybase1.MobileAppState_INACTIVE) - time.Sleep(20 * time.Millisecond) - c.h.G().MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - waitNoConn(c.h) - }}, - } { - t.Run(tt.name, func(t *testing.T) { - c := setupOnConnectTail(t) - c.h.connGate.start() - t.Cleanup(c.h.connGate.stop) - syncing := make(chan struct{}) - // A chat sync that runs until its connection is cancelled. - c.syncer.onConnected = func() { - close(syncing) - <-c.ctx.Done() - } - runErr := make(chan error, 1) - go func() { runErr <- c.run(c.ctx) }() - <-syncing - - done := make(chan struct{}) - go func() { - defer close(done) - tt.disconnect(c) - }() - select { - case <-done: - case <-time.After(200 * time.Millisecond): - // Let the sync, and so the disconnect, finish. - c.h.cancel() - <-done - t.Fatal("disconnect waited for the sync instead of cancelling it") - } - require.ErrorIs(t, <-runErr, chat.ErrDuplicateConnection) - require.False(t, hasConn(c.h), "connection left up") - require.False(t, c.syncer.IsConnected(context.Background()), "syncer left connected") - require.Equal(t, 1, c.badger.count(), "badges pushed after the disconnect") - require.Empty(t, c.h.replayCh, "gregor state sync ran after the disconnect") - require.True(t, c.h.isFirstConnect(), "first connect cleared after the disconnect") - require.True(t, c.h.connectedSince().IsZero(), "connected time set after the disconnect") - }) - } -} - -func waitNoConn(h *gregorHandler) { - for deadline := time.Now().Add(10 * time.Second); hasConn(h) && time.Now().Before(deadline); { - time.Sleep(time.Millisecond) - } -} - -// Reconnect returns without waiting for the gate, which an OnConnect sync can -// hold, and merges requests made while one waits for it. -func TestGregorReconnectDoesNotWaitForGate(t *testing.T) { - tc, g := setupGregorTest(t) - defer tc.Cleanup() - g.Syncer = chat.NewSyncer(g) - h := newGregorHandler(g) - - h.connGate.mu.Lock() - done := make(chan struct{}) - go func() { - defer close(done) - h.Reconnect(context.Background()) - h.Reconnect(context.Background()) - }() - select { - case <-done: - case <-time.After(10 * time.Second): - t.Error("Reconnect waited for the gate") - } - require.True(t, h.connGate.reconnectPending.Load(), "no reconnect pending") - h.connGate.mu.Unlock() - <-done - require.Eventually(t, func() bool { return !h.connGate.reconnectPending.Load() }, - 10*time.Second, time.Millisecond, "pending reconnect did not run") +// 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 @@ -1194,7 +1107,7 @@ func TestGregorShutdownCancelsBeforeSyncerDisconnected(t *testing.T) { c.syncer.onDisconnected = func() { _ = c.syncer.Connected(c.ctx, chat1.RemoteClient{}, c.uid, &chat1.SyncChatRes{}) } - c.h.Shutdown(context.Background()) + require.NoError(t, c.h.Disconnect()) require.False(t, c.syncer.IsConnected(context.Background()), "syncer connected after shutdown") } From cdb29d49a51e6ddadde19f5647e450ed9f361779 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 20:29:31 -0400 Subject: [PATCH 109/127] refactor(kbhttp): keep the last bound address and rebind when leaving the background A stopped server now keeps its last bound address in Info/Addr, so attachment URLs are never empty once the server has bound; only a server that never bound reports an error. HTTPSrvInfoUpdate goes out only when the bound address changes, the first bind included. Where the server stops in the background (iOS), leaving BACKGROUND or BACKGROUNDACTIVE for FOREGROUND or INACTIVE stops and starts it on its pinned port. That replaces the unexpected-exit detection (OnUnexpectedExit and the one-restart-per-change cap), which the rebind covers for a listener the OS reclaimed while the app was suspended. The service creates its server in SetupCriticalSubServices, after NotifyRouter exists, so the server reads the router once instead of on every notify. --- go/chat/attachment_httpsrv.go | 2 + go/chat/attachment_httpsrv_appstate_test.go | 25 +- go/kbhttp/manager/manager.go | 108 +++--- go/kbhttp/manager/manager_test.go | 349 ++++++++------------ go/kbhttp/srv.go | 24 -- go/kbhttp/srv_test.go | 47 +-- go/service/config.go | 10 +- go/service/main.go | 4 +- go/service/notify_test.go | 2 + 9 files changed, 228 insertions(+), 343 deletions(-) diff --git a/go/chat/attachment_httpsrv.go b/go/chat/attachment_httpsrv.go index c4efbea3a232..505d8595c88b 100644 --- a/go/chat/attachment_httpsrv.go +++ b/go/chat/attachment_httpsrv.go @@ -121,6 +121,8 @@ func (r *AttachmentHTTPSrv) genURLKey(prefix string, payload any) (string, error } func (r *AttachmentHTTPSrv) getURL(ctx context.Context, prefix string, payload any) string { + // 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: no HTTP server address: %s", err) diff --git a/go/chat/attachment_httpsrv_appstate_test.go b/go/chat/attachment_httpsrv_appstate_test.go index e4e64e4fa690..5fde9a33c894 100644 --- a/go/chat/attachment_httpsrv_appstate_test.go +++ b/go/chat/attachment_httpsrv_appstate_test.go @@ -2,6 +2,7 @@ package chat import ( "context" + "net" "strings" "testing" "time" @@ -22,17 +23,24 @@ type startOnlyAttachmentFetcher struct { func (startOnlyAttachmentFetcher) OnStart(libkb.MetaContext) {} -// requireSrvServing waits until the server does or does not have an address to -// hand out, which is what decides whether a URL can be built. +// 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 { - _, err := srv.Addr() + 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 TestAttachmentURLsEmptyWhileServerStopped(t *testing.T) { +func TestGetURLWhileStoppedUsesLastAddress(t *testing.T) { tc := externalstest.SetupTest(t, "attachment-url-stopped", 0) defer tc.Cleanup() tc.G.ConnectionManager = libkb.NewConnectionManager() @@ -62,17 +70,20 @@ func TestAttachmentURLsEmptyWhileServerStopped(t *testing.T) { } 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, "http://"), "url %q while serving", url) + 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, urls{}, get()) + require.Equal(t, up, get()) tc.G.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND) requireSrvServing(t, httpSrv, true) - require.True(t, strings.HasPrefix(get().full, "http://")) + require.Equal(t, up, get()) } diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go index 5b66ff8ed1df..5a6b529e16bc 100644 --- a/go/kbhttp/manager/manager.go +++ b/go/kbhttp/manager/manager.go @@ -35,8 +35,8 @@ type handlerRequest struct { } // Srv runs a local HTTP server. One goroutine, run, owns it: only run starts -// and stops it, reacting to app state changes, unexpected exits, handler -// registrations and shutdown. +// and stops it, reacting to app state changes, handler registrations and +// shutdown. type Srv struct { name string // prefixes every log line, so each server's lines are told apart log logger.Logger @@ -48,12 +48,14 @@ type Srv struct { 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. + // 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 what run last published, empty while not serving; readers never wait on run. + // 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] - exited chan struct{} handlers chan handlerRequest shutdownOnce sync.Once shutdownCh chan struct{} @@ -63,23 +65,21 @@ type Srv struct { httpSrv *kbhttp.Srv endpoints map[string]srvEndpoint state keybase1.MobileAppState - // restartedSinceChange caps restarts after unexpected exits at one per app - // state change, so a listener that keeps dying doesn't spin. - restartedSinceChange bool } -// NewSrv runs the service's HTTP server until the service shuts down. +// 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 { 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)) - // Read NotifyRouter when notifying: the service sets it after creating this server. - g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) + notifyRouter.HandleHTTPSrvInfoUpdate(ctx, info) }) g.PushShutdownHook(func(libkb.MetaContext) error { r.Shutdown() @@ -108,7 +108,6 @@ func New(name string, log logger.Logger, appState func() keybase1.MobileAppState listenerSource: listenerSource, stopInBackground: stopInBackground, notify: notify, - exited: make(chan struct{}, 1), handlers: make(chan handlerRequest), shutdownCh: make(chan struct{}), done: make(chan struct{}), @@ -116,7 +115,7 @@ func New(name string, log logger.Logger, appState func() keybase1.MobileAppState } // Publish an empty status before run can be observed, so readers never dereference nil. r.status.Store(&keybase1.HttpSrvInfo{}) - r.httpSrv = r.newHTTPSrv() + r.httpSrv = kbhttp.NewSrv(r.log, r.listenerSource()) ready := make(chan error) go r.run(ready) return r, <-ready @@ -134,17 +133,6 @@ func TokenPrefix(token string) string { return token } -func (r *Srv) newHTTPSrv() *kbhttp.Srv { - srv := kbhttp.NewSrv(r.log, r.listenerSource()) - srv.OnUnexpectedExit(func() { - select { - case r.exited <- struct{}{}: - default: - } - }) - return srv -} - func (r *Srv) wantUp(state keybase1.MobileAppState) bool { return !r.stopInBackground || state != keybase1.MobileAppState_BACKGROUND } @@ -156,17 +144,17 @@ func (r *Srv) run(ready chan<- error) { ctx := context.Background() r.state = r.appState() r.debug(ctx, "run: starting up in %v", r.state) - err := r.reconcile(ctx) - r.publish() - ready <- err + ready <- r.reconcile(ctx) for { select { case <-r.nextAppState(r.state): + prev := r.state r.state = r.appState() - r.restartedSinceChange = false + if r.leavingBackground(prev) { + r.debug(ctx, "run: rebinding on %v -> %v", prev, r.state) + r.httpSrv.Stop() + } _ = r.reconcile(ctx) - case <-r.exited: - r.serverExited(ctx) case req := <-r.handlers: r.endpoints[req.endpoint] = req.desc // A stopped server has no mux; start registers every endpoint. @@ -176,13 +164,28 @@ func (r *Srv) run(ready chan<- error) { close(req.done) case <-r.shutdownCh: <-r.httpSrv.Stop() - r.status.Store(&keybase1.HttpSrvInfo{}) return } - r.publish() } } +// 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. @@ -194,21 +197,6 @@ func (r *Srv) reconcile(ctx context.Context) error { return r.start(ctx) } -// serverExited restarts a server whose listener died without a Stop, for -// example one the OS reclaimed while the app was suspended without reaching BACKGROUND. -func (r *Srv) serverExited(ctx context.Context) { - if r.httpSrv.Active() { - return - } - if !r.wantUp(r.state) || r.restartedSinceChange { - r.debug(ctx, "serverExited: not restarting in %v", r.state) - return - } - r.restartedSinceChange = true - r.debug(ctx, "serverExited: restarting in %v", r.state) - _ = r.start(ctx) -} - func (r *Srv) start(ctx context.Context) error { if r.httpSrv.Active() { return nil @@ -218,31 +206,27 @@ func (r *Srv) start(ctx context.Context) error { // 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 = r.newHTTPSrv() + r.httpSrv = kbhttp.NewSrv(r.log, r.listenerSource()) err = r.httpSrv.StartWithHandlers(r.registerEndpoints) } if err != nil { r.log.CWarningf(ctx, "%s: start: failed to start HTTP server: %s", r.name, err) return err } - // Publish before notifying, so a listener reading Info gets the address it is told about. - info := r.publish() - if info.Address == "" { // Serve already exited; run handles that exit next + addr, err := r.httpSrv.Addr() + if err != nil { + 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) publish() keybase1.HttpSrvInfo { - var info keybase1.HttpSrvInfo - if addr, err := r.httpSrv.Addr(); err == nil { - info = keybase1.HttpSrvInfo{Address: addr, Token: r.token} - } - r.status.Store(&info) - return info -} - func (r *Srv) registerEndpoints(mux *http.ServeMux) { for endpoint, desc := range r.endpoints { mux.HandleFunc("/"+endpoint, r.checkToken(desc.tokenMode, desc.serve)) @@ -293,10 +277,12 @@ func (r *Srv) Addr() (string, error) { 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 not running") + 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 index 540e0fa4dab3..9224840ab120 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -22,28 +22,12 @@ import ( ) // listeners hands out pinned random-port listener sources, as NewSrv does, -// and remembers the last listener so a test can kill it underneath the -// server. +// 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 - // failing makes new listeners fail on their first Accept, so Serve - // returns right away. - failing atomic.Bool - // armed makes the next GetListener close blocked and wait on block, - // which release closes. - armed bool - block chan struct{} - blocked chan struct{} -} - -type failingListener struct { - net.Listener -} - -func (failingListener) Accept() (net.Conn, error) { - return nil, errors.New("listener failed") } type trackedSource struct { @@ -52,24 +36,12 @@ type trackedSource struct { } func (s trackedSource) GetListener() (net.Listener, string, error) { - s.l.Lock() - armed := s.l.armed - s.l.armed = false - block, blocked := s.l.block, s.l.blocked - s.l.Unlock() - if armed { - close(blocked) - <-block - } listener, address, err := s.src.GetListener() s.l.Lock() defer s.l.Unlock() s.l.calls++ if err == nil { s.l.last = listener - if s.l.failing.Load() { - listener = failingListener{listener} - } } return listener, address, err } @@ -84,34 +56,6 @@ func (l *listeners) Calls() int { return l.calls } -// blockNext makes the next GetListener wait for release. -func (l *listeners) blockNext() { - l.Lock() - defer l.Unlock() - l.armed = true - l.block = make(chan struct{}) - l.blocked = make(chan struct{}) -} - -// waitBlocked waits until a GetListener is held by blockNext. -func (l *listeners) waitBlocked(t *testing.T) { - t.Helper() - l.Lock() - blocked := l.blocked - l.Unlock() - select { - case <-blocked: - case <-time.After(10 * time.Second): - require.Fail(t, "no GetListener reached the block") - } -} - -func (l *listeners) release() { - l.Lock() - defer l.Unlock() - close(l.block) -} - func (l *listeners) kill(t *testing.T) { l.Lock() defer l.Unlock() @@ -123,20 +67,18 @@ var client = &http.Client{ Transport: &http.Transport{DisableKeepAlives: true}, } -// appState records each turn of run, which asks for the next update once -// per turn, after publishing. +// 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 - turns int - wait <-chan struct{} + 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.turns++ a.wait = wait return wait } @@ -154,10 +96,14 @@ func app(srv *Srv) *appState { return a.(*appState) } -// active reports whether srv has an address to hand out. -func active(srv *Srv) bool { - _, err := srv.Addr() - return err == nil +// 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) { @@ -178,7 +124,7 @@ func setupWithNotify(t *testing.T, state keybase1.MobileAppState, stopInBackgrou 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), active(srv), "launch state not applied when New returned") + 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") }) @@ -207,9 +153,9 @@ func fetchPath(info keybase1.HttpSrvInfo, endpoint string) (int, error) { return resp.StatusCode, nil } -// waitLoop waits until run has published for the current app state and -// waits for its next change. Handler requests are synchronous, and exits are -// awaited with waitTurns, so no event a caller made is still pending. +// 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 { @@ -229,39 +175,8 @@ func waitLoop(t *testing.T, srv *Srv) { }, 10*time.Second, time.Millisecond, "run did not catch up") } -func turns(srv *Srv) int { - a := app(srv) - a.mu.Lock() - defer a.mu.Unlock() - return a.turns -} - -// waitTurns waits until run has handled events up to turn n, and no more. -func waitTurns(t *testing.T, srv *Srv, n int) { - t.Helper() - require.Eventually(t, func() bool { return turns(srv) >= n }, 10*time.Second, time.Millisecond, - "run did not reach turn %d", n) - require.Equal(t, n, turns(srv)) -} - -// killUntilDown kills the listener until an unexpected exit is not -// restarted, because this app state change already had its restart. -func killUntilDown(t *testing.T, srv *Srv, l *listeners) { - t.Helper() - for range 2 { - n := turns(srv) - l.kill(t) - waitTurns(t, srv, n+1) - if !active(srv) { - return - } - } - t.Fatal("server kept restarting after unexpected exits") -} - func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { t.Helper() - require.True(t, active(srv), "server not active") info, err := srv.Info() require.NoError(t, err) _, err = fetch(info) @@ -271,80 +186,125 @@ func requireServing(t *testing.T, srv *Srv) keybase1.HttpSrvInfo { func requireStopped(t *testing.T, srv *Srv) { t.Helper() - require.False(t, active(srv), "server still active") + require.False(t, serving(srv), "server still serving") +} + +func requireNeverBound(t *testing.T, srv *Srv) { + t.Helper() _, err := srv.Info() require.Error(t, err) } -func TestDeadListenerRestartsOnTransition(t *testing.T) { - srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) - waitLoop(t, srv) - requireServing(t, srv) - for _, next := range []keybase1.MobileAppState{ - keybase1.MobileAppState_INACTIVE, - keybase1.MobileAppState_FOREGROUND, - keybase1.MobileAppState_BACKGROUNDACTIVE, - } { - killUntilDown(t, srv, l) - app(srv).Update(next) - waitLoop(t, srv) - requireServing(t, srv) - } +// 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) } -func TestDeadListenerRestartsWithoutTransition(t *testing.T) { - srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) +// 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) - n := turns(srv) - l.kill(t) - waitTurns(t, srv, n+1) - again := requireServing(t, srv) - require.Equal(t, first.Token, again.Token) - require.Equal(t, 2, l.Calls()) -} -func TestUnexpectedExitRestartsOncePerStateChange(t *testing.T) { - srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + app(srv).Update(keybase1.MobileAppState_BACKGROUND) waitLoop(t, srv) - requireServing(t, srv) - - n := turns(srv) - l.failing.Store(true) - l.kill(t) - // The restart's listener fails at once; its exit must not restart again. - // run handles each exit before the next start, so once both exits are - // handled the listener count is final. - waitTurns(t, srv, n+2) - require.Equal(t, 2, l.Calls(), "restart loop on a failing listener") 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) - // A new app state change allows one more restart after run's own start: - // turns for the change, the start's exit and the restart's exit. - n = turns(srv) - app(srv).Update(keybase1.MobileAppState_INACTIVE) - waitTurns(t, srv, n+3) - require.Equal(t, 4, l.Calls(), "restart loop on a failing listener") - - l.failing.Store(false) app(srv).Update(keybase1.MobileAppState_FOREGROUND) waitLoop(t, srv) - requireServing(t, srv) + require.Equal(t, first, requireServing(t, srv)) } -// A BACKGROUND that lands while an exit-restart is starting must leave the server stopped. -func TestUnexpectedExitRacingBackground(t *testing.T) { - srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) +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) - requireServing(t, srv) - l.blockNext() - l.kill(t) - l.waitBlocked(t) // run is inside start, waiting for a listener + 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) - l.release() waitLoop(t, srv) - require.Equal(t, keybase1.MobileAppState_BACKGROUND, app(srv).State()) - 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) + 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) { @@ -356,10 +316,6 @@ func TestNothingStartsAfterShutdown(t *testing.T) { calls := l.Calls() app(srv).Update(keybase1.MobileAppState_BACKGROUND) app(srv).Update(keybase1.MobileAppState_FOREGROUND) - select { // an exit signal nobody handles - case srv.exited <- struct{}{}: - default: - } registered := make(chan struct{}) go func() { srv.HandleFunc("late", SrvTokenModeDefault, func(http.ResponseWriter, *http.Request) {}) @@ -370,7 +326,7 @@ func TestNothingStartsAfterShutdown(t *testing.T) { case <-time.After(10 * time.Second): require.Fail(t, "HandleFunc hung after Shutdown") } - require.Never(t, func() bool { return active(srv) || l.Calls() != calls }, 200*time.Millisecond, 10*time.Millisecond) + 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. @@ -436,7 +392,7 @@ func TestInactiveKeepsServingBackgroundStops(t *testing.T) { 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") - requireStopped(t, srv) + requireNeverBound(t, srv) waitLoop(t, srv) require.Equal(t, 0, l.Calls()) @@ -471,15 +427,21 @@ func TestUpUnlessBackground(t *testing.T) { } } -// An INACTIVE or BACKGROUNDACTIVE blip neither restarts the server nor breaks -// a request in flight. +// 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 _, blip := range []keybase1.MobileAppState{ - keybase1.MobileAppState_INACTIVE, - keybase1.MobileAppState_BACKGROUNDACTIVE, + for _, c := range []struct { + blip keybase1.MobileAppState + stopInBackground bool + }{ + {keybase1.MobileAppState_INACTIVE, true}, + {keybase1.MobileAppState_INACTIVE, false}, + {keybase1.MobileAppState_BACKGROUNDACTIVE, false}, } { - t.Run(blip.String(), func(t *testing.T) { - srv, l := setup(t, keybase1.MobileAppState_FOREGROUND, true) + 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) @@ -511,7 +473,7 @@ func TestBlipKeepsRequestInFlight(t *testing.T) { } // Without stopping in the background (Android), the server serves in every -// state, and a dead one comes back on any transition or once after it exits. +// state and is never rebound. func TestNotStoppingInBackgroundStaysUp(t *testing.T) { srv, l := setup(t, keybase1.MobileAppState_BACKGROUND, false) waitLoop(t, srv) @@ -527,21 +489,7 @@ func TestNotStoppingInBackgroundStaysUp(t *testing.T) { waitLoop(t, srv) requireServing(t, srv) } - - n := turns(srv) - l.kill(t) - waitTurns(t, srv, n+1) - requireServing(t, srv) - - for _, next := range []keybase1.MobileAppState{ - keybase1.MobileAppState_BACKGROUNDACTIVE, - keybase1.MobileAppState_BACKGROUND, - } { - killUntilDown(t, srv, l) - 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. @@ -572,11 +520,11 @@ func TestNewReportsFirstStartErrorAndRetries(t *testing.T) { }, true, func(context.Context, keybase1.HttpSrvInfo) {}) require.Error(t, err) t.Cleanup(srv.Shutdown) - requireStopped(t, srv) + requireNeverBound(t, srv) broken.Store(false) tc.G.MobileAppState.Update(keybase1.MobileAppState_INACTIVE) - require.Eventually(t, func() bool { return active(srv) }, 10*time.Second, time.Millisecond, + require.Eventually(t, func() bool { return serving(srv) }, 10*time.Second, time.Millisecond, "server did not start on the next app state change") } @@ -588,26 +536,20 @@ func TestScenarioReplay(t *testing.T) { 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 active(srv) { + if serving(srv) { t.Fatalf("step %d %v: server up in BACKGROUND", i, step.Do) } return } - if !active(srv) { - t.Fatalf("step %d %v: server down in %v", i, step.Do, step.Want) - } info, err := srv.Info() require.NoError(t, err) if _, err := fetch(info); err != nil { - t.Fatalf("step %d %v: %v", i, step.Do, err) + t.Fatalf("step %d %v: server down in %v: %v", i, step.Do, step.Want, err) } - // Leave the server dead before a step that moves to another - // up state, which must bring it back. - if i+1 < len(sc.Steps) { - next := sc.Steps[i+1].Want - if next != step.Want && srv.wantUp(next) { - killUntilDown(t, srv, l) - } + // 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) } }) }) @@ -623,7 +565,6 @@ func TestPinnedPortTakenPicksNewAddress(t *testing.T) { stop := make(chan struct{}) var readers sync.WaitGroup for _, read := range []func(){ - func() { _ = active(srv) }, func() { _, _ = srv.Addr() }, func() { _, _ = srv.Info() }, } { @@ -784,7 +725,6 @@ func TestStressTransitionsAndRequests(t *testing.T) { if i < 200 { srv.HandleFunc(fmt.Sprintf("extra%d", i), SrvTokenModeUnchecked, func(http.ResponseWriter, *http.Request) {}) } - _ = active(srv) _, _ = srv.Addr() if info, err := srv.Info(); err == nil && info.Token != token { select { @@ -843,8 +783,7 @@ func TestStressTransitionsAndRequests(t *testing.T) { default: } - // BACKGROUND stops every server, so no exit from a killed listener can - // restart one later; leaving it is a real change run must wake for. + // Leaving BACKGROUND rebinds whatever listener the killer left dead. for _, state := range []keybase1.MobileAppState{ keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_FOREGROUND, diff --git a/go/kbhttp/srv.go b/go/kbhttp/srv.go index a553fe669c0a..9c3b765e12e0 100644 --- a/go/kbhttp/srv.go +++ b/go/kbhttp/srv.go @@ -148,7 +148,6 @@ type Srv struct { listenerSource ListenerSource server *http.Server doneCh chan struct{} - onExit func() } // NewSrv creates a new HTTP server with the given listener @@ -160,16 +159,6 @@ func NewSrv(log logger.Logger, listenerSource ListenerSource) *Srv { } } -// OnUnexpectedExit sets f to run whenever the server stops serving without -// Stop, as when its listener is closed underneath it. f runs without the -// server's lock held, so it may call back into the server, and before that -// server's done channel closes. -func (h *Srv) OnUnexpectedExit(f func()) { - h.Lock() - defer h.Unlock() - h.onExit = f -} - // Start starts listening on the server's listener source. func (h *Srv) Start() (err error) { return h.StartWithHandlers(nil) @@ -206,19 +195,6 @@ func (h *Srv) StartWithHandlers(register func(mux *http.ServeMux)) (err error) { if err := server.Serve(listener); err != nil { h.log.Debug("kbhttp.Srv: server died: %s", err) } - h.Lock() - // Serve can return without Stop (the listener was closed underneath - // us), so forget the dead server or Start could never run again. A - // Stop and a newer Start may already have replaced it. - unexpected := h.server == server - if unexpected { - h.server = nil - } - onExit := h.onExit - h.Unlock() - if unexpected && onExit != nil { - onExit() - } close(doneCh) }(h.server, h.doneCh) return nil diff --git a/go/kbhttp/srv_test.go b/go/kbhttp/srv_test.go index f2132c8e5271..4292d6da0f0c 100644 --- a/go/kbhttp/srv_test.go +++ b/go/kbhttp/srv_test.go @@ -9,9 +9,7 @@ import ( "net" "net/http" "sync" - "sync/atomic" "testing" - "time" "github.com/keybase/client/go/logger" "github.com/stretchr/testify/require" @@ -61,15 +59,18 @@ func (c *capturingListenerSource) kill() { _ = c.listener.Close() } -func TestSrvRestartsAfterListenerDies(t *testing.T) { +// 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 := http.Get(fmt.Sprintf("http://%s/test", addr)) //nolint:gosec // G107: Test code making request to own test server + resp, err := client.Get(fmt.Sprintf("http://%s/test", addr)) if err != nil { return err } @@ -93,44 +94,10 @@ func TestSrvRestartsAfterListenerDies(t *testing.T) { require.NoError(t, get()) source.kill() - require.Eventually(t, func() bool { return !srv.Active() }, 5*time.Second, 10*time.Millisecond, - "server still reports active after its listener died") - _, err := srv.Addr() - require.Error(t, err) - + require.Error(t, get()) + <-srv.Stop() require.NoError(t, srv.StartWithHandlers(register)) require.NoError(t, get()) <-srv.Stop() require.False(t, srv.Active()) } - -// The old Serve goroutine exiting after a Stop and a newer Start must not -// forget the new server. -func TestSrvOldServeExitKeepsNewServer(t *testing.T) { - srv := NewSrv(logger.NewTestLogger(t), NewAutoPortListenerSource()) - require.NoError(t, srv.Start()) - oldDone := srv.Stop() - require.NoError(t, srv.Start()) - <-oldDone - require.True(t, srv.Active()) - <-srv.Stop() -} - -func TestSrvOnUnexpectedExit(t *testing.T) { - source := &capturingListenerSource{} - srv := NewSrv(logger.NewTestLogger(t), source) - var exits atomic.Int32 - srv.OnUnexpectedExit(func() { - // Must not deadlock: the callback runs without the server's lock. - _ = srv.Active() - exits.Add(1) - }) - - require.NoError(t, srv.Start()) - // The done channel closes only after any exit callback has run. - <-srv.Stop() - require.Equal(t, int32(0), exits.Load(), "Stop reported as an unexpected exit") - require.NoError(t, srv.Start()) - source.kill() - require.Eventually(t, func() bool { return exits.Load() == 1 }, 5*time.Second, time.Millisecond) -} diff --git a/go/service/config.go b/go/service/config.go index 392efe7e8b5c..191a1d731c8e 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -362,11 +362,11 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r } res = eng.Status() // Not waited on: every client learns the address from HTTPSrvInfoUpdate, which - // the server sends on every start, and a client new enough for setNotifications - // also gets it in the subscription reply. An older client still decodes that - // notification -- the rpc codec ignores map keys it has no field for, so the - // version it does not know about costs it nothing. This field is left as a - // convenience for a status read that happens to run while the server is up. + // the server sends whenever its address changes, and a client new enough for + // setNotifications also gets it in the subscription reply. An older client + // still decodes that notification -- the rpc codec ignores map keys it has no + // field for, so the version it does not know about costs it nothing. 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 { diff --git a/go/service/main.go b/go/service/main.go index d76b2cdc3def..f0fc87f0a9d7 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{}), } @@ -355,6 +354,9 @@ 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. + d.httpSrv = manager.NewSrv(d.G()) d.G().RuntimeStats = runtimestats.NewRunner(allG) teams.ServiceInit(d.G()) stellar.ServiceInit(d.G(), d.walletState, d.badger) diff --git a/go/service/notify_test.go b/go/service/notify_test.go index 4f4cc4509abc..2b25717c9958 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/keybase/client/go/kbhttp/manager" "github.com/keybase/client/go/libkb" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" @@ -12,6 +13,7 @@ import ( func newTestNotifyCtlHandler(t *testing.T, g *libkb.GlobalContext) (*NotifyCtlHandler, *Service, libkb.ConnectionID) { t.Helper() svc := NewService(g, false) + svc.httpSrv = manager.NewSrv(g) connID := g.NotifyRouter.AddConnection(nil, nil) return NewNotifyCtlHandler(nil, connID, g, svc), svc, connID } From b8251e33b16b194a8e4d99d9fec198cce58a2026 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 21:21:05 -0400 Subject: [PATCH 110/127] refactor(notify): one ordered per-connection stream carries the client state Each connection gets one sender goroutine draining an unbounded FIFO. loggedIn, loggedOut, HTTPSrvInfoUpdate, mobileAppStateChanged, pushTapRouteAvailable and the new clientState notification all go through it, so a connection receives them in the order they were queued. A clientState reads the session, the http address and the app state when it is sent, so for every field the last message a client gets carries the latest value and clients apply everything in arrival order. StateVersion and every accept-if-newer check are gone. setNotifications returns void; the state arrives as clientState, first on subscribing, after every completed login and logout, after every switchUserMu release that leaves no valid session, and once the startup login attempt settles. Provisional valid writes mid-provisioning or mid-signup queue nothing. JS takes the session and the identity only from clientState. A null session keeps the splash up (a handshake step waits for one). A logged-in clientState for a different uid is applied as a logout then a login, and the userSwitching guard is gone: the ordered stream has no stale snapshots, and the router keeps the shell mounted through the flap. --- go/bind/keybase.go | 4 +- go/client/chat_svc_handler.go | 6 +- go/client/cmd_chat_api_listen.go | 2 +- go/client/cmd_chat_archive.go | 2 +- go/client/cmd_chat_archive_resume.go | 2 +- go/client/cmd_show_notifications.go | 4 +- go/kbfs/libkbfs/init_test.go | 2 +- go/kbfs/libkbfs/keybase_daemon_rpc.go | 4 +- go/kbfs/libkbfs/keybase_daemon_rpc_test.go | 2 +- go/kbfs/libkbfs/keybase_service_base.go | 2 +- go/libkb/appstate.go | 17 +- go/libkb/appstate_test.go | 57 ++-- go/libkb/connmgr.go | 40 +-- go/libkb/context.go | 18 +- go/libkb/globals.go | 48 ++-- go/libkb/logout.go | 2 +- go/libkb/notify_recorder.go | 163 ++++++++++++ go/libkb/notify_router.go | 293 +++++++++++++++++---- go/libkb/notify_router_test.go | 213 +++++++++++++++ go/libkb/state_version_test.go | 54 ---- go/protocol/keybase1/common.go | 12 - go/protocol/keybase1/notify_app.go | 36 ++- go/protocol/keybase1/notify_ctl.go | 10 +- go/protocol/keybase1/notify_service.go | 10 +- go/protocol/keybase1/notify_session.go | 20 +- go/service/appstate_test.go | 16 +- go/service/config.go | 10 +- go/service/main.go | 40 ++- go/service/notify.go | 36 +-- go/service/notify_test.go | 278 +++++++++++++++---- go/systests/multiuser_common_test.go | 2 +- go/systests/teams_test.go | 2 +- go/systests/tracking_test.go | 3 +- go/systests/user_test.go | 5 +- protocol/avdl/keybase1/common.avdl | 11 - protocol/avdl/keybase1/notify_app.avdl | 18 +- protocol/avdl/keybase1/notify_ctl.avdl | 21 +- protocol/avdl/keybase1/notify_service.avdl | 2 +- protocol/avdl/keybase1/notify_session.avdl | 4 +- protocol/bin/enabled-calls.json | 1 + protocol/json/keybase1/common.json | 14 - protocol/json/keybase1/notify_app.json | 16 +- protocol/json/keybase1/notify_ctl.json | 6 +- protocol/json/keybase1/notify_service.json | 4 - protocol/json/keybase1/notify_session.json | 11 +- shared/constants/init/app-state.test.ts | 50 ++-- shared/constants/init/shared.test.ts | 247 +++++++---------- shared/constants/init/shared.tsx | 220 ++++++---------- shared/constants/rpc/index.tsx | 1 + shared/constants/rpc/rpc-gen.tsx | 19 +- shared/stores/config.tsx | 99 +------ shared/stores/daemon.tsx | 4 +- shared/stores/tests/client-state.test.ts | 260 +++++++++--------- shared/stores/tests/daemon.test.ts | 3 +- shared/stores/tests/legacy-service.test.ts | 156 ----------- 55 files changed, 1405 insertions(+), 1177 deletions(-) create mode 100644 go/libkb/notify_recorder.go create mode 100644 go/libkb/notify_router_test.go delete mode 100644 go/libkb/state_version_test.go delete mode 100644 shared/stores/tests/legacy-service.test.ts diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 0037fbdc39ee..23a36bc8c69d 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -444,8 +444,8 @@ func Init(homeDir, mobileSharedHome, logFile, runModeStr string, // 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: setNotifications answers with no session at all in that window, - // and GetBootstrapStatus, which waits for the attempt, is what settles it. + // 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) diff --git a/go/client/chat_svc_handler.go b/go/client/chat_svc_handler.go index 4f13a6feca46..ac3fe121d550 100644 --- a/go/client/chat_svc_handler.go +++ b/go/client/chat_svc_handler.go @@ -868,7 +868,7 @@ func (c *chatServiceHandler) AttachV1(ctx context.Context, opts attachOptionsV1, channels := keybase1.NotificationChannels{ Chatattachments: true, } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return c.errReply(err) } @@ -928,7 +928,7 @@ func (c *chatServiceHandler) DownloadV1(ctx context.Context, opts downloadOption channels := keybase1.NotificationChannels{ Chatattachments: true, } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return c.errReply(err) } @@ -991,7 +991,7 @@ func (c *chatServiceHandler) downloadV1NoStream(ctx context.Context, opts downlo channels := keybase1.NotificationChannels{ Chatattachments: true, } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return c.errReply(err) } diff --git a/go/client/cmd_chat_api_listen.go b/go/client/cmd_chat_api_listen.go index 97e5b28cbdaa..e432147c328e 100644 --- a/go/client/cmd_chat_api_listen.go +++ b/go/client/cmd_chat_api_listen.go @@ -184,7 +184,7 @@ func (c *CmdChatAPIListen) Run() error { Chatdev: c.subscribeDev, Wallet: c.subscribeWallet, } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } errWriter := c.G().UI.GetTerminalUI().ErrorWriter() diff --git a/go/client/cmd_chat_archive.go b/go/client/cmd_chat_archive.go index 1002dc60a01c..d0f35b76f5c6 100644 --- a/go/client/cmd_chat_archive.go +++ b/go/client/cmd_chat_archive.go @@ -101,7 +101,7 @@ func (c *CmdChatArchive) Run() error { channels := keybase1.NotificationChannels{ Chatarchive: true, } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } diff --git a/go/client/cmd_chat_archive_resume.go b/go/client/cmd_chat_archive_resume.go index 055f445a8b36..0d14b2892e74 100644 --- a/go/client/cmd_chat_archive_resume.go +++ b/go/client/cmd_chat_archive_resume.go @@ -92,7 +92,7 @@ func (c *CmdChatArchiveResume) Run() error { channels := keybase1.NotificationChannels{ Chatarchive: true, } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } diff --git a/go/client/cmd_show_notifications.go b/go/client/cmd_show_notifications.go index 51b4d0a8bbd1..fc27f3ff2a42 100644 --- a/go/client/cmd_show_notifications.go +++ b/go/client/cmd_show_notifications.go @@ -57,7 +57,7 @@ func (c *CmdShowNotifications) Run() error { if err != nil { return err } - if _, err := cli.SetNotifications(context.TODO(), channels); err != nil { + if err := cli.SetNotifications(context.TODO(), channels); err != nil { return err } @@ -99,7 +99,7 @@ func (d *notificationDisplay) printf(fmt string, args ...any) error { return err } -func (d *notificationDisplay) LoggedOut(_ context.Context, _ keybase1.StateVersion) error { +func (d *notificationDisplay) LoggedOut(_ context.Context) error { return d.printf("Logged out\n") } diff --git a/go/kbfs/libkbfs/init_test.go b/go/kbfs/libkbfs/init_test.go index 7fc938fbd5f5..3ba53d7949dd 100644 --- a/go/kbfs/libkbfs/init_test.go +++ b/go/kbfs/libkbfs/init_test.go @@ -155,7 +155,7 @@ func (c *initOrderCn) callIntoKBFS() { require.NoError(t, err) require.NoError(t, c.daemon.PaperKeyCached( ctx, keybase1.PaperKeyCachedArg{Uid: session.UID})) - require.NoError(t, c.daemon.LoggedOut(ctx, keybase1.StateVersion{})) + require.NoError(t, c.daemon.LoggedOut(ctx)) // Until init is ready, requests get an error or wait. _, err = c.daemon.GetTLFCryptKeys(ctx, keybase1.TLFQuery{TlfName: "testuser"}) diff --git a/go/kbfs/libkbfs/keybase_daemon_rpc.go b/go/kbfs/libkbfs/keybase_daemon_rpc.go index 123aa441309b..f03592688725 100644 --- a/go/kbfs/libkbfs/keybase_daemon_rpc.go +++ b/go/kbfs/libkbfs/keybase_daemon_rpc.go @@ -362,7 +362,7 @@ func (k *KeybaseDaemonRPC) OnConnect(ctx context.Context, // Using conn.GetClient() here would cause problematic // recursion. c := keybase1.NotifyCtlClient{Cli: rawClient} - _, err = c.SetNotifications(ctx, keybase1.NotificationChannels{ + err = c.SetNotifications(ctx, keybase1.NotificationChannels{ Session: true, Paperkeys: true, Keyfamily: true, @@ -492,7 +492,7 @@ func (s *notifyServiceHandler) Shutdown(_ context.Context, code int) error { return nil } -func (s *notifyServiceHandler) HTTPSrvInfoUpdate(_ context.Context, _ keybase1.HTTPSrvInfoUpdateArg) error { +func (s *notifyServiceHandler) HTTPSrvInfoUpdate(_ context.Context, info keybase1.HttpSrvInfo) error { return nil } diff --git a/go/kbfs/libkbfs/keybase_daemon_rpc_test.go b/go/kbfs/libkbfs/keybase_daemon_rpc_test.go index 0894cbafa9b8..0dc390e5e60b 100644 --- a/go/kbfs/libkbfs/keybase_daemon_rpc_test.go +++ b/go/kbfs/libkbfs/keybase_daemon_rpc_test.go @@ -251,7 +251,7 @@ func TestKeybaseDaemonSessionCache(t *testing.T) { testCurrentSession(t, client, c, session, expectCached) // Should invalidate cache. - err := c.LoggedOut(context.Background(), keybase1.StateVersion{}) + err := c.LoggedOut(context.Background()) require.NoError(t, err) // Should fill cache again. diff --git a/go/kbfs/libkbfs/keybase_service_base.go b/go/kbfs/libkbfs/keybase_service_base.go index db85a835ad79..b60a05f45104 100644 --- a/go/kbfs/libkbfs/keybase_service_base.go +++ b/go/kbfs/libkbfs/keybase_service_base.go @@ -384,7 +384,7 @@ func (k *KeybaseServiceBase) LoggedIn(ctx context.Context, arg keybase1.LoggedIn } // LoggedOut implements keybase1.NotifySessionInterface. -func (k *KeybaseServiceBase) LoggedOut(ctx context.Context, _ keybase1.StateVersion) error { +func (k *KeybaseServiceBase) LoggedOut(ctx context.Context) error { k.log.CDebugf(ctx, "Current session logged out") k.setCachedCurrentSession(idutil.SessionInfo{}) if k.config != nil { diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 5ac8feadb3fc..02030304d51b 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -102,15 +102,14 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo // Nothing to do for other states. } - // Tell connected clients, still under the lock, so the state version is - // stamped in the same critical section that wrote the state. Update has - // one writer, lifecycle.Controller.applyLocked under Controller.mu, so - // this notify call publishes that single writer's announcements in the - // same order it wrote them, and a client's accept-if-newer gate can never - // be handed an older state last and keep it forever. Cheap to hold: the - // fan-out reads the connection table and starts one goroutine per - // connection, and every send happens on those goroutines. Nothing it - // touches reads app state, so it cannot re-enter this lock. + // 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 } diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index 61bcc7f1544f..421605b61fd2 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -77,45 +77,62 @@ func TestMobileAppStateBackgroundCancelsRPCsOnlyOnChange(t *testing.T) { 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. The announce is observable -// from here as the state version it stamps. +// 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() - before := tc.G.StateVersion() require.True(t, a.Update(keybase1.MobileAppState_BACKGROUND)) - announced := tc.G.StateVersion() - require.Equal(t, before.Counter+1, announced.Counter, "one stamp for the change") - require.False(t, a.Update(keybase1.MobileAppState_BACKGROUND)) - require.Equal(t, announced.Counter, tc.G.StateVersion().Counter, "nothing announced for a same-value update") - require.True(t, a.Update(keybase1.MobileAppState_FOREGROUND)) - require.Equal(t, announced.Counter+1, tc.G.StateVersion().Counter) + 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 stamp lands in the same critical section as the state write, so two -// concurrent Updates publish 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 the version been stamped yet", -// and the answer must be yes before the lock is released. -func TestMobileAppStateStampsUnderTheLock(t *testing.T) { - tc := SetupTest(t, "MobileAppStateStamp", 0) +// 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() - before := tc.G.StateVersion().Counter a.Lock() changed := a.updateLocked(keybase1.MobileAppState_BACKGROUND) - stamped := tc.G.StateVersion().Counter + // 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, before+1, stamped, - "the change was announced before the lock that wrote it was released") + 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/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..8611dea64df9 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, "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, "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, "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, "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, "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, "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, "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, "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, "SetEncryptionKey")() return g.ActiveDevice.setEncryptionKey(uv, deviceID, encKey) } diff --git a/go/libkb/globals.go b/go/libkb/globals.go index f597bbb84fb6..4a92a6f09daf 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -25,7 +25,6 @@ import ( "os" "runtime" "sync" - "sync/atomic" "time" "github.com/keybase/client/go/libkb/lifecycle" @@ -80,8 +79,6 @@ type GlobalContext struct { Identify3State *Identify3State // keep track of Identify3 sessions vidMu *sync.Mutex // protect VID RuntimeStats RuntimeStats // performance runtime stats - stateEpoch int64 // see StateVersion - stateCounter atomic.Int64 // see StateVersion cacheMu *sync.RWMutex // protects all caches ProofCache *ProofCache // where to cache proof results @@ -173,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) @@ -321,15 +319,6 @@ func (g *GlobalContext) Init() *GlobalContext { g.IdentifyDispatch = NewIdentifyDispatch() g.Identify3State = NewIdentify3State(g) g.GregorState = newNullGregorState() - // Any value distinct from every other service process will do: a client only - // ever asks whether two epochs differ, never which is greater. Kept under - // 2^32 because a JS client decodes an int64 into a float64, which is exact - // only below 2^53. - if epoch, err := RandInt64(); err == nil { - g.stateEpoch = epoch & 0xFFFFFFFF - } else { - g.stateEpoch = time.Now().UnixMilli() & 0xFFFFFFFF - } g.LocalNetworkInstrumenterStorage = NewDiskInstrumentationStorage(g, keybase1.NetworkSource_LOCAL) g.RemoteNetworkInstrumenterStorage = NewDiskInstrumentationStorage(g, keybase1.NetworkSource_REMOTE) @@ -342,22 +331,6 @@ func NewGlobalContextInit() *GlobalContext { return NewGlobalContext().Init() } -// StateVersion labels the last change a notification announced (the http server -// address, login, logout). Epoch identifies this service process, so a client -// that reconnects to a restarted service sees a different epoch instead of a -// counter that looks stale; counter strictly increases within one epoch. -func (g *GlobalContext) StateVersion() keybase1.StateVersion { - return keybase1.StateVersion{Epoch: g.stateEpoch, Counter: g.stateCounter.Load()} -} - -// NextStateVersion stamps a change about to be announced. NotifyRouter calls it -// after the change is readable, so nothing carrying this version is still -// invisible, which makes a snapshot labelled with StateVersion never newer than -// its label. -func (g *GlobalContext) NextStateVersion() keybase1.StateVersion { - return keybase1.StateVersion{Epoch: g.stateEpoch, Counter: g.stateCounter.Add(1)} -} - func (g *GlobalContext) SetService() { g.Service = true g.ConnectionManager = NewConnectionManager() @@ -388,10 +361,25 @@ func (g *GlobalContext) SetAvatarLoader(a AvatarLoaderSource) { g.avatarLoader = a } +// lockSwitchUser takes switchUserMu, which every session write (the active +// device, the config's current user) is made under. A release that leaves no +// valid session queues a clientState to connected clients, after unlocking; a +// release that leaves a valid one queues nothing, because the login it belongs +// to announces itself when it completes. See connSender for why that is enough. +func (g *GlobalContext) lockSwitchUser(mctx MetaContext, reasonFormat string, args ...any) (release func()) { + unlock := g.switchUserMu.Acquire(mctx, reasonFormat, args...) + return func() { + unlock() + if !g.ActiveDevice.Valid() { + 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), "simulateServiceRestart")() _ = g.ActiveDevice.Clear() } diff --git a/go/libkb/logout.go b/go/libkb/logout.go index 5c28274431a4..f6426b5fd54e 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, "Logout")() mctx.Debug("MetaContext#logoutWithSecretKill: after switchUserMu acquisition (username: %s, options: %#v)", username, options) diff --git a/go/libkb/notify_recorder.go b/go/libkb/notify_recorder.go new file mode 100644 index 000000000000..60eadb80f4a2 --- /dev/null +++ b/go/libkb/notify_recorder.go @@ -0,0 +1,163 @@ +// 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/libkb/notify_router.go b/go/libkb/notify_router.go index c8b75b8847a4..48a6b30fe006 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 and loggedOut are calls rather than notifications, and +// callInOrder gives them 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: a clientState is queued after every completed login and +// logout (SendLogin, HandleLogout), and after every switchUserMu release +// that leaves no valid session (GlobalContext.lockSwitchUser). So the last +// session write is either a clear, with a clientState queued right after +// it, or a valid write, which its login completes with SendLogin and so a +// clientState queued after that. 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. A valid write made +// partway through provisioning or signup queues nothing, so a client does +// not see a login before it completes. What this leaves: a flow that fails +// and leaves a valid session it never announces is not pushed until the +// next clientState, and a clientState dequeued partway through a flow reads +// its provisional session. The startup login attempt settling, which turns +// a null session into a real one, queues a clientState too. +// - 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,17 +449,29 @@ 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) + } +} -// setNotificationChannels registers the connection's filter and returns the -// version labelling it. The version is read under the same lock announce takes to -// decide whether this connection is registered, so the registration and its label -// are one step and neither can be taken without the other. -func (n *NotifyRouter) setNotificationChannels(id ConnectionID, val keybase1.NotificationChannels) keybase1.StateVersion { +// 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) { n.Lock() defer n.Unlock() - n.state[id] = val - return n.G().StateVersion() + 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 { @@ -392,43 +505,108 @@ 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, and returns the version that labels a state read -// made from here on. The version comes back from the registration rather than -// from a separate StateVersion call so that a reply describing the state cannot -// be built before the connection is subscribed: a change landing in that window -// would be announced to nobody and reported stale, and the client keeps whichever -// version is newer, so it would keep the stale one for good. -func (n *NotifyRouter) SetChannels(i ConnectionID, nc keybase1.NotificationChannels) keybase1.StateVersion { - return n.setNotificationChannels(i, nc) +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.Lock() + defer n.Unlock() + n.state[i] = nc + if s := n.senders[i]; s != nil && 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 stamps one state version and fans a notification out to every -// connection whose channel filter wants it. Stamping here rather than at each -// call site is what makes the version the default for an announced change: the -// stamp happens after the change is readable and before any send. +// 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(rpc.Transporter, keybase1.StateVersion), + send func(ctx context.Context, xp rpc.Transporter), ) { - version := n.G().NextStateVersion() - n.cm.ApplyAllDetails(func(id ConnectionID, xp rpc.Transporter, d *keybase1.ClientDetails) bool { - registered := wants(n.getNotificationChannels(id)) - if registered { - go send(xp, version) - } - desc := "" - if d != nil { - desc = fmt.Sprintf("%+v", *d) + 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.G().Log.CDebugf(ctx, "| NotifyRouter#%s: client %s (sent=%v)", name, desc, registered) - return true - }) + } + 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 @@ -438,14 +616,14 @@ func (n *NotifyRouter) HandleLogout(ctx context.Context) { return } defer n.G().CTrace(ctx, "NotifyRouter#HandleLogout", nil)() - ctx = CopyTagsToBackground(ctx) n.announce(ctx, "HandleLogout", func(ch keybase1.NotificationChannels) bool { return ch.Session }, - func(xp rpc.Transporter, version keybase1.StateVersion) { - _ = (keybase1.NotifySessionClient{ - Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).LoggedOut(ctx, version) + func(ctx context.Context, xp rpc.Transporter) { + n.callInOrder(xp, func(cli *rpc.Client) error { + return (keybase1.NotifySessionClient{Cli: cli}).LoggedOut(ctx) + }) }) + n.AnnounceClientState(ctx) n.runListeners(func(listener NotifyListener) { listener.Logout() @@ -478,18 +656,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) - ctx = CopyTagsToBackground(ctx) n.announce(ctx, "SendLogin", func(ch keybase1.NotificationChannels) bool { return ch.Session }, - func(xp rpc.Transporter, version keybase1.StateVersion) { - _ = (keybase1.NotifySessionClient{ - Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).LoggedIn(ctx, keybase1.LoggedInArg{ - Username: u, - SignedUp: signedUp, - Version: version, + 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, + }) }) }) + n.AnnounceClientState(ctx) n.runListeners(func(listener NotifyListener) { listener.Login(u) @@ -2838,10 +3015,10 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase } n.announce(ctx, "HandleHTTPSrvInfoUpdate", func(ch keybase1.NotificationChannels) bool { return ch.Service }, - func(xp rpc.Transporter, version keybase1.StateVersion) { + func(ctx context.Context, xp rpc.Transporter) { _ = (keybase1.NotifyServiceClient{ Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).HTTPSrvInfoUpdate(ctx, keybase1.HTTPSrvInfoUpdateArg{Info: info, Version: version}) + }).HTTPSrvInfoUpdate(ctx, info) }) n.runListeners(func(listener NotifyListener) { listener.HTTPSrvInfoUpdate(info) @@ -2864,10 +3041,10 @@ func (n *NotifyRouter) HandleMobileAppState(ctx context.Context, state keybase1. } n.announce(ctx, "HandleMobileAppState", func(ch keybase1.NotificationChannels) bool { return ch.App }, - func(xp rpc.Transporter, version keybase1.StateVersion) { + func(ctx context.Context, xp rpc.Transporter) { _ = (keybase1.NotifyAppClient{ Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), - }).MobileAppStateChanged(ctx, keybase1.MobileAppStateChangedArg{State: state, Version: version}) + }).MobileAppStateChanged(ctx, state) }) } @@ -2882,7 +3059,7 @@ func (n *NotifyRouter) HandlePushTapRouteAvailable(ctx context.Context) { } n.announce(ctx, "HandlePushTapRouteAvailable", func(ch keybase1.NotificationChannels) bool { return ch.App }, - func(xp rpc.Transporter, version keybase1.StateVersion) { + func(ctx context.Context, xp rpc.Transporter) { _ = (keybase1.NotifyAppClient{ Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), }).PushTapRouteAvailable(ctx) diff --git a/go/libkb/notify_router_test.go b/go/libkb/notify_router_test.go new file mode 100644 index 000000000000..ae16a20c79f3 --- /dev/null +++ b/go/libkb/notify_router_test.go @@ -0,0 +1,213 @@ +// 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" + + "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 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") +} diff --git a/go/libkb/state_version_test.go b/go/libkb/state_version_test.go deleted file mode 100644 index b3b033ed4d58..000000000000 --- a/go/libkb/state_version_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// 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" -) - -// Every announced change gets its own version, and the version is readable -// through StateVersion by the time the notification is on its way out. A client -// compares the version on the snapshot it got from setNotifications against the -// versions on the notifications it got, so a change that stamped nothing would -// look older than a snapshot read before it and be dropped. -func TestNotifyRouterStampsEachAnnouncedChange(t *testing.T) { - tc := SetupTest(t, "StateVersion", 0) - defer tc.Cleanup() - g := tc.G - g.SetService() - ctx := context.Background() - - epoch := g.StateVersion().Epoch - require.Less(t, epoch, int64(1)<<53, "a JS client decodes this into a float64") - require.EqualValues(t, 0, g.StateVersion().Counter, "nothing announced yet") - - g.NotifyRouter.HandleHTTPSrvInfoUpdate(ctx, keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "token"}) - afterHTTP := g.StateVersion() - require.EqualValues(t, 1, afterHTTP.Counter) - - g.NotifyRouter.SendLogin(ctx, "testuser", false) - afterLogin := g.StateVersion() - require.Greater(t, afterLogin.Counter, afterHTTP.Counter) - - g.NotifyRouter.HandleLogout(ctx) - require.Greater(t, g.StateVersion().Counter, afterLogin.Counter) - - require.Equal(t, epoch, g.StateVersion().Epoch, "the epoch never moves within a process") -} - -// A client keeps the versions it applied across a reconnect and tells a -// restarted service from a continuing one by the epoch, so two services must -// never share one. -func TestStateVersionEpochsDiffer(t *testing.T) { - first := SetupTest(t, "StateVersionA", 0) - defer first.Cleanup() - second := SetupTest(t, "StateVersionB", 0) - defer second.Cleanup() - - require.NotEqual(t, first.G.StateVersion().Epoch, second.G.StateVersion().Epoch) -} diff --git a/go/protocol/keybase1/common.go b/go/protocol/keybase1/common.go index c64302bfeeaf..026bfd968567 100644 --- a/go/protocol/keybase1/common.go +++ b/go/protocol/keybase1/common.go @@ -1161,18 +1161,6 @@ func (o UserReacjis) DeepCopy() UserReacjis { } } -type StateVersion struct { - Epoch int64 `codec:"epoch" json:"epoch"` - Counter int64 `codec:"counter" json:"counter"` -} - -func (o StateVersion) DeepCopy() StateVersion { - return StateVersion{ - Epoch: o.Epoch, - Counter: o.Counter, - } -} - type CommonInterface interface { } diff --git a/go/protocol/keybase1/notify_app.go b/go/protocol/keybase1/notify_app.go index 302d8a59da68..5db46395b403 100644 --- a/go/protocol/keybase1/notify_app.go +++ b/go/protocol/keybase1/notify_app.go @@ -14,8 +14,11 @@ type ExitArg struct { } type MobileAppStateChangedArg struct { - State MobileAppState `codec:"state" json:"state"` - Version StateVersion `codec:"version" json:"version"` + State MobileAppState `codec:"state" json:"state"` +} + +type ClientStateArg struct { + State ClientState `codec:"state" json:"state"` } type PushTapRouteAvailableArg struct { @@ -23,7 +26,8 @@ type PushTapRouteAvailableArg struct { type NotifyAppInterface interface { Exit(context.Context) error - MobileAppStateChanged(context.Context, MobileAppStateChangedArg) error + MobileAppStateChanged(context.Context, MobileAppState) error + ClientState(context.Context, ClientState) error PushTapRouteAvailable(context.Context) error } @@ -52,7 +56,22 @@ func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol { err = rpc.NewTypeError((*[1]MobileAppStateChangedArg)(nil), args) return } - err = i.MobileAppStateChanged(ctx, typedArgs[0]) + 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 }, }, @@ -79,11 +98,18 @@ func (c NotifyAppClient) Exit(ctx context.Context) (err error) { return } -func (c NotifyAppClient) MobileAppStateChanged(ctx context.Context, __arg MobileAppStateChangedArg) (err error) { +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 9e7d4bd6e963..414347af0234 100644 --- a/go/protocol/keybase1/notify_ctl.go +++ b/go/protocol/keybase1/notify_ctl.go @@ -107,7 +107,6 @@ func (o ClientSession) DeepCopy() ClientSession { } type ClientState struct { - Version StateVersion `codec:"version" json:"version"` Session *ClientSession `codec:"session,omitempty" json:"session,omitempty"` HttpSrvInfo *HttpSrvInfo `codec:"httpSrvInfo,omitempty" json:"httpSrvInfo,omitempty"` AppState MobileAppState `codec:"appState" json:"appState"` @@ -115,7 +114,6 @@ type ClientState struct { func (o ClientState) DeepCopy() ClientState { return ClientState{ - Version: o.Version.DeepCopy(), Session: (func(x *ClientSession) *ClientSession { if x == nil { return nil @@ -139,7 +137,7 @@ type SetNotificationsArg struct { } type NotifyCtlInterface interface { - SetNotifications(context.Context, NotificationChannels) (ClientState, error) + SetNotifications(context.Context, NotificationChannels) error } func NotifyCtlProtocol(i NotifyCtlInterface) rpc.Protocol { @@ -157,7 +155,7 @@ func NotifyCtlProtocol(i NotifyCtlInterface) rpc.Protocol { err = rpc.NewTypeError((*[1]SetNotificationsArg)(nil), args) return } - ret, err = i.SetNotifications(ctx, typedArgs[0].Channels) + err = i.SetNotifications(ctx, typedArgs[0].Channels) return }, }, @@ -169,8 +167,8 @@ type NotifyCtlClient struct { Cli rpc.GenericClient } -func (c NotifyCtlClient) SetNotifications(ctx context.Context, channels NotificationChannels) (res ClientState, err error) { +func (c NotifyCtlClient) SetNotifications(ctx context.Context, channels NotificationChannels) (err error) { __arg := SetNotificationsArg{Channels: channels} - err = c.Cli.Call(ctx, "keybase.1.notifyCtl.setNotifications", []any{__arg}, &res, 0*time.Millisecond) + err = c.Cli.Call(ctx, "keybase.1.notifyCtl.setNotifications", []any{__arg}, nil, 0*time.Millisecond) return } diff --git a/go/protocol/keybase1/notify_service.go b/go/protocol/keybase1/notify_service.go index 6f86649419c4..c9eb27588491 100644 --- a/go/protocol/keybase1/notify_service.go +++ b/go/protocol/keybase1/notify_service.go @@ -23,8 +23,7 @@ func (o HttpSrvInfo) DeepCopy() HttpSrvInfo { } type HTTPSrvInfoUpdateArg struct { - Info HttpSrvInfo `codec:"info" json:"info"` - Version StateVersion `codec:"version" json:"version"` + Info HttpSrvInfo `codec:"info" json:"info"` } type HandleKeybaseLinkArg struct { @@ -37,7 +36,7 @@ type ShutdownArg struct { } type NotifyServiceInterface interface { - HTTPSrvInfoUpdate(context.Context, HTTPSrvInfoUpdateArg) error + HTTPSrvInfoUpdate(context.Context, HttpSrvInfo) error HandleKeybaseLink(context.Context, HandleKeybaseLinkArg) error Shutdown(context.Context, int) error } @@ -57,7 +56,7 @@ func NotifyServiceProtocol(i NotifyServiceInterface) rpc.Protocol { err = rpc.NewTypeError((*[1]HTTPSrvInfoUpdateArg)(nil), args) return } - err = i.HTTPSrvInfoUpdate(ctx, typedArgs[0]) + err = i.HTTPSrvInfoUpdate(ctx, typedArgs[0].Info) return }, }, @@ -99,7 +98,8 @@ type NotifyServiceClient struct { Cli rpc.GenericClient } -func (c NotifyServiceClient) HTTPSrvInfoUpdate(ctx context.Context, __arg HTTPSrvInfoUpdateArg) (err error) { +func (c NotifyServiceClient) HTTPSrvInfoUpdate(ctx context.Context, info HttpSrvInfo) (err error) { + __arg := HTTPSrvInfoUpdateArg{Info: info} err = c.Cli.Notify(ctx, "keybase.1.NotifyService.HTTPSrvInfoUpdate", []any{__arg}, 0*time.Millisecond) return } diff --git a/go/protocol/keybase1/notify_session.go b/go/protocol/keybase1/notify_session.go index 930c93413662..0aca0ca39fcc 100644 --- a/go/protocol/keybase1/notify_session.go +++ b/go/protocol/keybase1/notify_session.go @@ -11,13 +11,11 @@ import ( ) type LoggedOutArg struct { - Version StateVersion `codec:"version" json:"version"` } type LoggedInArg struct { - Username string `codec:"username" json:"username"` - SignedUp bool `codec:"signedUp" json:"signedUp"` - Version StateVersion `codec:"version" json:"version"` + Username string `codec:"username" json:"username"` + SignedUp bool `codec:"signedUp" json:"signedUp"` } type ClientOutOfDateArg struct { @@ -27,7 +25,7 @@ type ClientOutOfDateArg struct { } type NotifySessionInterface interface { - LoggedOut(context.Context, StateVersion) error + LoggedOut(context.Context) error LoggedIn(context.Context, LoggedInArg) error ClientOutOfDate(context.Context, ClientOutOfDateArg) error } @@ -42,12 +40,7 @@ func NotifySessionProtocol(i NotifySessionInterface) rpc.Protocol { return &ret }, Handler: func(ctx context.Context, args any) (ret any, err error) { - typedArgs, ok := args.(*[1]LoggedOutArg) - if !ok { - err = rpc.NewTypeError((*[1]LoggedOutArg)(nil), args) - return - } - err = i.LoggedOut(ctx, typedArgs[0].Version) + err = i.LoggedOut(ctx) return }, }, @@ -89,9 +82,8 @@ type NotifySessionClient struct { Cli rpc.GenericClient } -func (c NotifySessionClient) LoggedOut(ctx context.Context, version StateVersion) (err error) { - __arg := LoggedOutArg{Version: version} - err = c.Cli.Notify(ctx, "keybase.1.NotifySession.loggedOut", []any{__arg}, 0*time.Millisecond) +func (c NotifySessionClient) LoggedOut(ctx context.Context) (err error) { + err = c.Cli.Notify(ctx, "keybase.1.NotifySession.loggedOut", []any{LoggedOutArg{}}, 0*time.Millisecond) return } diff --git a/go/service/appstate_test.go b/go/service/appstate_test.go index bde0c1e409b7..1d34a47323af 100644 --- a/go/service/appstate_test.go +++ b/go/service/appstate_test.go @@ -69,9 +69,9 @@ func TestAckPushTapRouteIgnoresAStaleID(t *testing.T) { require.Equal(t, "keybase://devices", survived.Url) } -// A tap must ride its own call and nothing else. setNotifications answers every -// subscriber -- kbfs subscribes from inside this same process -- so a tap -// carried in that reply would be consumed by whichever one subscribed first. +// 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() @@ -82,10 +82,12 @@ func TestSetNotificationsLeavesTheTapAlone(t *testing.T) { route := keybase1.PushTapRoute{Url: "keybase://devices", TargetUID: "u1"} g.PendingPushTap.Set(ctx, route) - n, svc, _ := newTestNotifyCtlHandler(t, g) - svc.initialLoginAttemptOnce.Do(func() { close(svc.initialLoginAttemptDone) }) - _, err := n.SetNotifications(ctx, keybase1.NotificationChannels{App: true}) - require.NoError(t, err) + 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) diff --git a/go/service/config.go b/go/service/config.go index 191a1d731c8e..5e67f484bf51 100644 --- a/go/service/config.go +++ b/go/service/config.go @@ -361,12 +361,10 @@ func (h ConfigHandler) GetBootstrapStatus(ctx context.Context, sessionID int) (r return res, err } res = eng.Status() - // Not waited on: every client learns the address from HTTPSrvInfoUpdate, which - // the server sends whenever its address changes, and a client new enough for - // setNotifications also gets it in the subscription reply. An older client - // still decodes that notification -- the rpc codec ignores map keys it has no - // field for, so the version it does not know about costs it nothing. This - // field is left as a convenience for a status read once the server has bound. + // 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 { diff --git a/go/service/main.go b/go/service/main.go index f0fc87f0a9d7..fe46346885e0 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -153,7 +153,7 @@ func (d *Service) RegisterProtocols(srv *rpc.Server, xp rpc.Transporter, connID keybase1.KvstoreProtocol(NewKVStoreHandler(xp, g)), keybase1.LogProtocol(NewLogHandler(xp, logReg, g)), keybase1.LoginProtocol(NewLoginHandler(xp, g)), - keybase1.NotifyCtlProtocol(NewNotifyCtlHandler(xp, connID, g, d)), + keybase1.NotifyCtlProtocol(NewNotifyCtlHandler(xp, connID, g)), keybase1.PGPProtocol(NewPGPHandler(xp, connID, g)), keybase1.PprofProtocol(NewPprofHandler(xp, g)), keybase1.ReachabilityProtocol(newReachabilityHandler(xp, g, d)), @@ -232,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,11 +333,11 @@ 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 gets - // a session in its setNotifications reply rather than "not known yet". Mobile + // 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 -- which is why the reply says so explicitly - // instead of relying on this ordering. + // 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 @@ -357,6 +359,7 @@ func (d *Service) SetupCriticalSubServices() error { // Not in NewService: the service sets up NotifyRouter after that, and the // server reads it once, when created. 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) @@ -1421,6 +1424,31 @@ 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. // @@ -1430,7 +1458,7 @@ func (d *Service) awaitInitialLoginAttempt(m libkb.MetaContext, maxWait time.Dur 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 0a45758f2234..085f8fc1d60d 100644 --- a/go/service/notify.go +++ b/go/service/notify.go @@ -6,7 +6,6 @@ package service import ( "context" - "github.com/keybase/client/go/engine" "github.com/keybase/client/go/libkb" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/go-framed-msgpack-rpc/rpc" @@ -16,42 +15,23 @@ import ( type NotifyCtlHandler struct { libkb.Contextified *BaseHandler - id libkb.ConnectionID - svc *Service + id libkb.ConnectionID } // NewNotifyCtlHandler creates a new handler for setting up notification // channels -func NewNotifyCtlHandler(xp rpc.Transporter, id libkb.ConnectionID, g *libkb.GlobalContext, svc *Service) *NotifyCtlHandler { +func NewNotifyCtlHandler(xp rpc.Transporter, id libkb.ConnectionID, g *libkb.GlobalContext) *NotifyCtlHandler { return &NotifyCtlHandler{ Contextified: libkb.NewContextified(g), BaseHandler: NewBaseHandler(g, xp), id: id, - svc: svc, } } -// SetNotifications registers the channels and then reads the client state. The -// order is not a convention here: the version that labels the reply is what -// SetChannels returns, so the state below cannot be read before the connection is -// subscribed. A change from here on is announced to this connection, so the reply -// can only miss something the client is about to be told about anyway. -func (h *NotifyCtlHandler) SetNotifications(ctx context.Context, n keybase1.NotificationChannels) (keybase1.ClientState, error) { - // The version is read before the state it describes. NextStateVersion is - // stamped after a change is readable, so this snapshot is never newer than its - // label and a client can drop it on a tie without losing anything. - version := h.G().NotifyRouter.SetChannels(h.id, n) - res := keybase1.ClientState{Version: version, AppState: h.G().MobileAppState.State()} - // 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 client would have to be corrected out of by a notification it - // might never get. The client falls back to getBootstrapStatus, which waits. - if h.svc.initialLoginAttemptSettled() { - session, _ := engine.SessionState(libkb.NewMetaContext(ctx, h.G())) - res.Session = &session - } - if info, err := h.svc.httpSrv.Info(); err == nil { - res.HttpSrvInfo = &info - } - return res, nil +// 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 index 2b25717c9958..b69e0383bd9f 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -2,103 +2,267 @@ 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" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" ) -func newTestNotifyCtlHandler(t *testing.T, g *libkb.GlobalContext) (*NotifyCtlHandler, *Service, libkb.ConnectionID) { +// 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) - connID := g.NotifyRouter.AddConnection(nil, nil) - return NewNotifyCtlHandler(nil, connID, g, svc), svc, connID + g.NotifyRouter.SetClientStateReader(svc.readClientState) + return svc } -// The reply carries no session until the service's startup login attempt has -// settled. Reporting a logged-out session in that window would be wrong rather -// than merely early, and the client would then have to be corrected out of it by -// a notification whose send is fire-and-forget -- so the window must not exist. -func TestSetNotificationsHoldsBackAnUnsettledSession(t *testing.T) { +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() - h, svc, _ := newTestNotifyCtlHandler(t, g) + 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") +} - res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) +// 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) - require.Nil(t, res.Session, "the startup login attempt has not run") - require.NotZero(t, res.Version.Epoch, "the read is still labelled") + svc.httpSrv = srv + g.NotifyRouter.SetClientStateReader(svc.readClientState) + svc.settleInitialLoginAttempt(context.Background()) + + rec := libkb.NewNotifyRecorder(g, allClientStateChannels) + defer rec.Close() - svc.initialLoginAttemptOnce.Do(func() { close(svc.initialLoginAttemptDone) }) + 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: + g.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{}) + } + } + }() + } + // 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() - res, err = h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) + 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, res.Session, "the attempt settled, so there is a session to report") - require.False(t, res.Session.LoggedIn, "logged out in a fresh test context") + require.NotNil(t, lastHTTP) + require.Equal(t, info, *lastHTTP) + require.NotEmpty(t, httpChanges, "the http server rebound while connected") } -// What is checkable from out here: the call registers the channels, labels the -// read no earlier than everything already announced, and leaves every later -// change strictly newer than that label -- which together are what let a client -// keep a notification over the reply. -// -// The register-before-read order inside SetNotifications is not observable from -// here and is not pinned here: it is pinned by the compiler instead, because the -// version labelling the reply is SetChannels' return value and there is no reply -// to build without first having called it. -func TestSetNotificationsRegistersChannelsAndLabelsTheRead(t *testing.T) { +// 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()) - h, svc, connID := newTestNotifyCtlHandler(t, g) - svc.initialLoginAttemptOnce.Do(func() { close(svc.initialLoginAttemptDone) }) + rec := libkb.NewNotifyRecorder(g, allClientStateChannels) + defer rec.Close() + rec.Flush() + before := len(rec.Messages()) - // a change announced before anyone subscribed - g.NotifyRouter.HandleLogout(context.Background()) - announced := g.StateVersion() + testLoginWrite(t, tc, "testuser") + g.NotifyRouter.SendLogin(context.Background(), "testuser", false) + rec.Flush() - // only Session, so nothing below actually sends down this test's nil transport - res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) - require.NoError(t, err) - require.True(t, g.NotifyRouter.GetChannels(connID).Session, "subscribed by the time it returned") + 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) +} - require.Equal(t, announced.Epoch, res.Version.Epoch) - require.GreaterOrEqual(t, res.Version.Counter, announced.Counter, - "the read is labelled no earlier than everything already announced") +// 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) - // a change announced after subscribing is strictly newer than the reply, which - // is what lets the client keep the notification over the reply - g.NotifyRouter.HandleHTTPSrvInfoUpdate(context.Background(), keybase1.HttpSrvInfo{Address: "127.0.0.1:1", Token: "t"}) - require.Greater(t, g.StateVersion().Counter, res.Version.Counter) + 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") } -// The app state is derived here and nowhere else, so a client that started late -// -- on iOS JS never starts on a background launch -- has no earlier reading to -// order against: the reply is its first and only catch-up. -func TestSetNotificationsCarriesTheAppState(t *testing.T) { +// 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) - h, _, _ := newTestNotifyCtlHandler(t, g) - - res, err := h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) - require.NoError(t, err) - require.Equal(t, g.MobileAppState.State(), res.AppState) + 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") - g.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND) - res, err = h.SetNotifications(context.Background(), keybase1.NotificationChannels{Session: true}) - require.NoError(t, err) - require.Equal(t, keybase1.MobileAppState_BACKGROUND, res.AppState) - require.GreaterOrEqual(t, res.Version.Counter, g.StateVersion().Counter-1, - "labelled no earlier than the change it reports") + require.NoError(t, h.SetNotifications(context.Background(), allClientStateChannels)) + rec.Flush() + require.Len(t, clientStatesOf(t, rec.Messages()), 1) } diff --git a/go/systests/multiuser_common_test.go b/go/systests/multiuser_common_test.go index 399472412765..2f95ba2fff3f 100644 --- a/go/systests/multiuser_common_test.go +++ b/go/systests/multiuser_common_test.go @@ -394,7 +394,7 @@ func (u *smuUser) registerForNotifications() { require.NoError(u.ctx.t, err) } ncli := keybase1.NotifyCtlClient{Cli: u.primaryDevice().rpcClient()} - if _, err := ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{Team: true}); err != nil { + if err := ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{Team: true}); err != nil { require.NoError(u.ctx.t, err) } } diff --git a/go/systests/teams_test.go b/go/systests/teams_test.go index ffe0d40979c8..0744bc2a11b7 100644 --- a/go/systests/teams_test.go +++ b/go/systests/teams_test.go @@ -244,7 +244,7 @@ func (tt *teamTester) addUserHelper(pre string, puk bool, paper bool) *userPlusD err = srv.Register(keybase1.NotifyTeambotProtocol(u.notifications)) require.NoError(tt.t, err) ncli := keybase1.NotifyCtlClient{Cli: cli} - _, err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ + err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ Team: true, Badges: true, Ephemeral: true, diff --git a/go/systests/tracking_test.go b/go/systests/tracking_test.go index 0422a8ae6bfe..5a5f5b5f38dc 100644 --- a/go/systests/tracking_test.go +++ b/go/systests/tracking_test.go @@ -170,10 +170,9 @@ func TestTrackingNotifications(t *testing.T) { return err } ncli := keybase1.NotifyCtlClient{Cli: cli} - _, err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ + return ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ Tracking: true, }) - return err } // Actually launch it in the background diff --git a/go/systests/user_test.go b/go/systests/user_test.go index 0a55ad06a61c..3c2c511730b4 100644 --- a/go/systests/user_test.go +++ b/go/systests/user_test.go @@ -247,7 +247,7 @@ func newNotifyHandler() *notifyHandler { } } -func (h *notifyHandler) LoggedOut(_ context.Context, _ keybase1.StateVersion) error { +func (h *notifyHandler) LoggedOut(_ context.Context) error { h.logoutCh <- struct{}{} return nil } @@ -330,11 +330,10 @@ func TestSignupLogout(t *testing.T) { return err } ncli := keybase1.NotifyCtlClient{Cli: cli} - _, err = ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ + return ncli.SetNotifications(context.TODO(), keybase1.NotificationChannels{ Session: true, Users: true, }) - return err } // Actually launch it in the background diff --git a/protocol/avdl/keybase1/common.avdl b/protocol/avdl/keybase1/common.avdl index 007239b87957..f200b502b930 100644 --- a/protocol/avdl/keybase1/common.avdl +++ b/protocol/avdl/keybase1/common.avdl @@ -419,15 +419,4 @@ protocol Common { ReacjiSkinTone skinTone; } - - // StateVersion labels a change the service announced (a login, a logout, the - // http server address). Epoch identifies the service process, so a client can - // tell a restarted service's counter from a continuing one; counter strictly - // increases within one epoch. A service too old to send one leaves it unset, - // and a client with nothing to order by applies what it gets in arrival order. - record StateVersion { - long epoch; - long counter; - } - } diff --git a/protocol/avdl/keybase1/notify_app.avdl b/protocol/avdl/keybase1/notify_app.avdl index b72e663a5ed3..04668d8fbd63 100644 --- a/protocol/avdl/keybase1/notify_app.avdl +++ b/protocol/avdl/keybase1/notify_app.avdl @@ -3,16 +3,22 @@ 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 the appState in setNotifications' reply - // 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. Versioned - // because the fan-out is one goroutine per connection, so two of these can - // arrive in either order. - void mobileAppStateChanged(MobileAppState state, StateVersion version) oneway; + // 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 diff --git a/protocol/avdl/keybase1/notify_ctl.avdl b/protocol/avdl/keybase1/notify_ctl.avdl index 06fa37db3b45..9b6bba782c9c 100644 --- a/protocol/avdl/keybase1/notify_ctl.avdl +++ b/protocol/avdl/keybase1/notify_ctl.avdl @@ -55,16 +55,16 @@ protocol notifyCtl { string deviceName; } - // ClientState is the state a client needs before it can render anything, read - // as the reply to setNotifications so there is no read to order against the - // subscription. It carries only what is available with nothing to wait on; - // the slower derived fields stay on getBootstrapStatus. + // 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 { - StateVersion version; // labels this read: compare against the versions on the notifications // 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 must fall back to getBootstrapStatus, which waits for that attempt, - // rather than read a null as logged out. + // 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. @@ -75,8 +75,7 @@ protocol notifyCtl { MobileAppState appState; } - // Registers the channels, then returns the state, so anything that changes - // from here on is announced to this connection rather than falling in a gap - // between the subscription and a separate read. - ClientState setNotifications(NotificationChannels channels); + // 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_service.avdl b/protocol/avdl/keybase1/notify_service.avdl index cd4e678e6819..f0bc1b003729 100644 --- a/protocol/avdl/keybase1/notify_service.avdl +++ b/protocol/avdl/keybase1/notify_service.avdl @@ -7,7 +7,7 @@ protocol NotifyService { string token; } @lint("ignore") - void HTTPSrvInfoUpdate(HttpSrvInfo info, StateVersion version) oneway; + void HTTPSrvInfoUpdate(HttpSrvInfo info) oneway; void handleKeybaseLink(string link, boolean deferred) oneway; diff --git a/protocol/avdl/keybase1/notify_session.avdl b/protocol/avdl/keybase1/notify_session.avdl index 1ef8720feda3..814dfa705db8 100644 --- a/protocol/avdl/keybase1/notify_session.avdl +++ b/protocol/avdl/keybase1/notify_session.avdl @@ -4,7 +4,7 @@ protocol NotifySession { import idl "common.avdl"; @notify("") - void loggedOut(StateVersion version); - void loggedIn(string username, boolean signedUp, StateVersion version); // signedUp if this is due to a signup + void loggedOut(); + void loggedIn(string username, boolean signedUp); // signedUp if this is due to a signup void clientOutOfDate(string upgradeTo, string upgradeURI, string upgradeMsg); } diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json index e3f572536b0b..9bba7f004b96 100644 --- a/protocol/bin/enabled-calls.json +++ b/protocol/bin/enabled-calls.json @@ -151,6 +151,7 @@ "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}, diff --git a/protocol/json/keybase1/common.json b/protocol/json/keybase1/common.json index 0cf63c8b4ed1..49ee7411c85e 100644 --- a/protocol/json/keybase1/common.json +++ b/protocol/json/keybase1/common.json @@ -904,20 +904,6 @@ "name": "skinTone" } ] - }, - { - "type": "record", - "name": "StateVersion", - "fields": [ - { - "type": "long", - "name": "epoch" - }, - { - "type": "long", - "name": "counter" - } - ] } ], "messages": {}, diff --git a/protocol/json/keybase1/notify_app.json b/protocol/json/keybase1/notify_app.json index 0a224c0c0e07..40184b5e862c 100644 --- a/protocol/json/keybase1/notify_app.json +++ b/protocol/json/keybase1/notify_app.json @@ -8,6 +8,10 @@ { "path": "appstate.avdl", "type": "idl" + }, + { + "path": "notify_ctl.avdl", + "type": "idl" } ], "types": [], @@ -22,10 +26,16 @@ { "name": "state", "type": "MobileAppState" - }, + } + ], + "response": null, + "oneway": true + }, + "clientState": { + "request": [ { - "name": "version", - "type": "StateVersion" + "name": "state", + "type": "ClientState" } ], "response": null, diff --git a/protocol/json/keybase1/notify_ctl.json b/protocol/json/keybase1/notify_ctl.json index c7d7fa79dbd0..af0337f1c806 100644 --- a/protocol/json/keybase1/notify_ctl.json +++ b/protocol/json/keybase1/notify_ctl.json @@ -191,10 +191,6 @@ "type": "record", "name": "ClientState", "fields": [ - { - "type": "StateVersion", - "name": "version" - }, { "type": [ null, @@ -224,7 +220,7 @@ "type": "NotificationChannels" } ], - "response": "ClientState" + "response": null } }, "namespace": "keybase.1" diff --git a/protocol/json/keybase1/notify_service.json b/protocol/json/keybase1/notify_service.json index 362a33a52468..967a5b2d2046 100644 --- a/protocol/json/keybase1/notify_service.json +++ b/protocol/json/keybase1/notify_service.json @@ -28,10 +28,6 @@ { "name": "info", "type": "HttpSrvInfo" - }, - { - "name": "version", - "type": "StateVersion" } ], "response": null, diff --git a/protocol/json/keybase1/notify_session.json b/protocol/json/keybase1/notify_session.json index f7bbc409f379..a571d0d2245c 100644 --- a/protocol/json/keybase1/notify_session.json +++ b/protocol/json/keybase1/notify_session.json @@ -9,12 +9,7 @@ "types": [], "messages": { "loggedOut": { - "request": [ - { - "name": "version", - "type": "StateVersion" - } - ], + "request": [], "response": null, "notify": "" }, @@ -27,10 +22,6 @@ { "name": "signedUp", "type": "boolean" - }, - { - "name": "version", - "type": "StateVersion" } ], "response": null diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts index bf2d16b9efbe..89127cf7cbe5 100644 --- a/shared/constants/init/app-state.test.ts +++ b/shared/constants/init/app-state.test.ts @@ -6,13 +6,7 @@ import {applyClientState, applyMobileAppState, _onEngineIncoming} from './shared const g = globalThis as unknown as {isMobile: boolean} -// The applied versions live outside the store and survive resetAllStores on purpose, so each test -// gets its own epoch rather than a counter that has to beat every earlier test's. -let testEpoch = 500 -const version = (counter: number, epoch = testEpoch): T.RPCGen.StateVersion => ({counter, epoch}) - beforeEach(() => { - testEpoch++ g.isMobile = true resetAllStores() // the shell store keeps its state across an account-level reset on purpose @@ -31,55 +25,47 @@ describe('the app state the service derives', () => { // nothing in the UI distinguishes "backgrounded with work still running" from "backgrounded" [T.RPCGen.MobileAppState.backgroundactive, 'background'], ])('%s becomes %s', (state, expected) => { - applyMobileAppState(state, version(1)) + applyMobileAppState(state) expect(useShellState.getState().mobileAppState).toBe(expected) }) test('arrives through the notification', () => { _onEngineIncoming({ - payload: {params: {state: T.RPCGen.MobileAppState.background, version: version(1)}}, + payload: {params: {state: T.RPCGen.MobileAppState.background}}, type: 'keybase.1.NotifyApp.mobileAppStateChanged', } as never) expect(useShellState.getState().mobileAppState).toBe('background') }) - test('an older notification than the one applied is dropped', () => { - applyMobileAppState(T.RPCGen.MobileAppState.background, version(5)) - // the fan-out is one goroutine per connection, so this can land after the one above - applyMobileAppState(T.RPCGen.MobileAppState.foreground, version(4)) - expect(useShellState.getState().mobileAppState).toBe('background') - - applyMobileAppState(T.RPCGen.MobileAppState.foreground, version(6)) - expect(useShellState.getState().mobileAppState).toBe('active') - }) - - test('a new service process wins whatever its counter says', () => { - applyMobileAppState(T.RPCGen.MobileAppState.background, version(9)) - applyMobileAppState(T.RPCGen.MobileAppState.foreground, version(1, testEpoch + 1000)) + 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 subscribe snapshot, which is what catches a late-started JS up', () => { - applyClientState({appState: T.RPCGen.MobileAppState.background, version: version(1)}) + 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 state we do not map leaves the app state alone rather than guessing', () => { - applyMobileAppState(T.RPCGen.MobileAppState.background, version(1)) - applyMobileAppState(99 as T.RPCGen.MobileAppState, version(2)) - 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 service too old to send one leaves the state unknown and burns no version', () => { - applyMobileAppState(undefined, version(1)) - expect(useShellState.getState().mobileAppState).toBe('unknown') - applyMobileAppState(T.RPCGen.MobileAppState.background, version(1)) + 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, version(1)) + applyMobileAppState(T.RPCGen.MobileAppState.foreground) expect(useShellState.getState().mobileAppState).toBe('unknown') }) }) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 84e57c545e1c..87284b18b87f 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -1,15 +1,15 @@ /// 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 {useCurrentUserState} from '@/stores/current-user' import { + applyClientState, loadAccountsStep, - onBootstrapStatusChanged, onEngineConnected, - onLoggedInChanged, onNetworkOnlineChanged, + sessionSettledStep, } from './shared' describe('loadAccountsStep', () => { @@ -101,9 +101,9 @@ describe('onEngineConnected', () => { } const deferredSubscription = () => { - let subscribed!: (cs: T.RPCGen.ClientState) => void + let subscribed!: () => void jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockReturnValue( - new Promise(resolve => { + new Promise(resolve => { subscribed = resolve }) ) @@ -128,23 +128,13 @@ describe('onEngineConnected', () => { test('the bootstrap read does not wait for the subscription', async () => { stubRegistrations() - const subscribed = deferredSubscription() + deferredSubscription() const bootstrap = spyOnBootstrap() onEngineConnected() await new Promise(resolve => setImmediate(resolve)) expect(bootstrap).toHaveBeenCalledTimes(1) - - subscribed({ - appState: T.RPCGen.MobileAppState.foreground, - httpSrvInfo: {address: '127.0.0.1:2000', token: 'token'}, - session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, - version: {counter: 1, epoch: 7}, - }) - await new Promise(resolve => setImmediate(resolve)) - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2000') }) test('the bootstrap read still runs when the subscription fails', async () => { @@ -160,138 +150,115 @@ describe('onEngineConnected', () => { expect(bootstrap).toHaveBeenCalledTimes(1) }) - test('a new connection does not inherit the previous one\'s fallback', async () => { - // the old service left the flag set; while this connection's reply is still in flight it has - // told us nothing, so the status must not own the session on its behalf - stubRegistrations() - jest - .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') - .mockRejectedValue(new Error('no notifications')) - spyOnBootstrap() - onEngineConnected() - await new Promise(resolve => setImmediate(resolve)) - - expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(true) - - deferredSubscription() - onEngineConnected() - - expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) - - // the first connection's fallback logged us in; this connection has said nothing yet, so a - // status arriving now must not be the one to decide the session again - useConfigState.setState({loggedIn: false}) - onBootstrapStatusChanged({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', - } as never) - - expect(useConfigState.getState().loggedIn).toBe(false) - }) - - test('a throw while applying a good reply is not read as a failed subscribe', async () => { - // otherwise the catch flips this connection to the unversioned fallback and re-applies the - // status on top of half-applied versioned state + test('the bootstrap status is not a session source', async () => { stubRegistrations() - jest.spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise').mockResolvedValue({ - httpSrvInfo: {address: '127.0.0.1:4242', token: 'token'}, - session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, - version: {counter: 1, epoch: 4242}, - } as never) + 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) - const originalCurrentUser = useCurrentUserState.getState().dispatch - useCurrentUserState.setState({ - dispatch: { - ...originalCurrentUser, - setBootstrap: () => { - throw new Error('boom') - }, - }, - }) onEngineConnected() await new Promise(resolve => setImmediate(resolve)) - useCurrentUserState.setState({dispatch: originalCurrentUser}) - expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) - // and what the reply had already applied before the throw is left alone, rather than - // re-decided by the status the fallback would have replayed - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:4242') + expect(useDaemonState.getState().bootstrapStatus?.loggedIn).toBe(true) + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useConfigState.getState().httpSrv.address).toBe('') }) +}) - test('a reply from a connection a later handshake replaced writes nothing', async () => { - stubRegistrations() - const subscribed = deferredSubscription() - spyOnBootstrap() +describe('sessionSettledStep', () => { + const originalConfigDispatch = useConfigState.getState().dispatch - onEngineConnected() - // a reconnect before the first reply lands - deferredSubscription() - onEngineConnected() - useConfigState.setState({loggedIn: false}) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + useConfigState.setState({dispatch: originalConfigDispatch}) + resetAllStores() + }) - subscribed({ - appState: T.RPCGen.MobileAppState.foreground, - session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, - version: {counter: 1, epoch: 4243}, + 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 + } - expect(useConfigState.getState().loggedIn).toBe(false) - }) + test('waits for a clientState that carries a session, which may say logged out', async () => { + connect(async () => Promise.resolve()) + const step = sessionSettledStep() - test('a logout during the subscribe window does not discard the live reply', async () => { - // resetAllStores zeroes the store's copy of handshakeGeneration while the daemon's closure - // counter keeps climbing, so a logout under an in-flight subscribe must not make that - // connection's own reply look like it came from a replaced one - stubRegistrations() - const subscribed = deferredSubscription() - spyOnBootstrap() + applyClientState({appState: T.RPCGen.MobileAppState.foreground}) + expect(await settled(step)).toBe(false) - onEngineConnected() - useConfigState.getState().dispatch.setLoggedIn(true) - useConfigState.getState().dispatch.setLoggedIn(false) // resetAllStores runs here + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + expect(await settled(step)).toBe(true) + await expect(step).resolves.toBeUndefined() + }) - subscribed({ - appState: T.RPCGen.MobileAppState.foreground, - session: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser'}, - version: {counter: 1, epoch: 4244}, - }) - await new Promise(resolve => setImmediate(resolve)) + test('a new connection waits afresh', async () => { + connect(async () => Promise.resolve()) + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + await sessionSettledStep() - expect(useConfigState.getState().loggedIn).toBe(true) - expect(useCurrentUserState.getState().username).toBe('testuser') + 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('a failed subscription leaves the session to the bootstrap status', async () => { - // no reply and no channels either: if the status cannot own the session here, a provisioned - // user lands on the login screen with nothing left that could put them back - stubRegistrations() - jest - .spyOn(T.RPCGen, 'notifyCtlSetNotificationsRpcPromise') - .mockRejectedValue(new Error('no notifications')) - spyOnBootstrap() + 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)) - onEngineConnected() + const step = sessionSettledStep() await new Promise(resolve => setImmediate(resolve)) - onBootstrapStatusChanged({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', - } as never) + expect(setNotifications).toHaveBeenCalledTimes(2) - expect(useConfigState.getState().loggedIn).toBe(true) - expect(useCurrentUserState.getState().username).toBe('testuser') + applyClientState({appState: T.RPCGen.MobileAppState.foreground, session}) + await expect(step).resolves.toBeUndefined() + }) + + 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 }) }) @@ -347,37 +314,3 @@ describe('onNetworkOnlineChanged', () => { expect(reRead).not.toHaveBeenCalled() }) }) - -describe('onLoggedInChanged', () => { - beforeEach(() => { - jest.useFakeTimers() - }) - afterEach(() => { - jest.useRealTimers() - jest.restoreAllMocks() - resetAllStores() - }) - - test('applies the stored status identity when the session catches up with it', () => { - // the status is read before the login notification lands, so its identity is held back; a - // status identical to the stored one never notifies again, so the login has to apply it - jest.spyOn(T.RPCGen, 'loginGetConfiguredAccountsRpcPromise').mockResolvedValue([]) - jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue({} as never) - useDaemonState.setState({ - bootstrapStatus: { - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', - } as never, - }) - useConfigState.setState({loggedIn: true}) - - onLoggedInChanged(true) - - expect(useCurrentUserState.getState().username).toBe('testuser') - expect(useCurrentUserState.getState().uid).toBe('u1') - }) -}) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index f762f799c004..4141dcf2978e 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -70,7 +70,6 @@ const subscribeValue = ( }) type ConfigState = ReturnType -type DaemonState = ReturnType type RouterState = ReturnType // ─── Bootstrap steps ────────────────────────────────────────────────────────── @@ -178,9 +177,6 @@ export const onNetworkOnlineChanged = (online?: boolean, previous?: boolean) => export const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { if (loggedIn) { - // a status read before we knew we were logged in was held back then; a status identical to - // the stored one does not notify again, so apply its identity from here - applyStatusIdentity(useDaemonState.getState().bootstrapStatus) // runtime login: refresh bootstrap status. During the handshake this is already in // flight, and the store dedupes it. ignorePromise(useDaemonState.getState().dispatch.loadDaemonBootstrapStatus()) @@ -208,52 +204,6 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } -// Only a status that agrees with the session we are in describes the current user: a read that -// spans a logout describes the previous one, and resetAllStores has already cleared them. -const applyStatusIdentity = (bootstrap: DaemonState['bootstrapStatus']) => { - if (!bootstrap?.loggedIn || !useConfigState.getState().loggedIn) { - return - } - const {deviceID, deviceName, uid, username} = bootstrap - useCurrentUserState.getState().dispatch.setBootstrap({deviceID, deviceName, uid, username}) - if (username) { - useConfigState.getState().dispatch.setDefaultUsername(username) - } -} - -const applyUnversionedStatusSession = (bootstrap: NonNullable) => { - // Only while the connected service has said it cannot settle the session: no setNotifications - // reply at all (a service too old for it, or a subscribe that failed and left us with no - // channels either), or a reply taken before the service's startup login attempt had settled. - // The config store clears this the moment a real session version is accepted, so the fallback - // hands back to the versioned stream as soon as there is one. - if (!useConfigState.getState().dispatch.sessionIsUnversioned()) { - return - } - const {httpSrvInfo, loggedIn} = bootstrap - const configDispatch = useConfigState.getState().dispatch - if (httpSrvInfo) { - configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token) - } - if (!loggedIn && useConfigState.getState().userSwitching) { - logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') - return - } - configDispatch.setLoggedIn(loggedIn) -} - -export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { - if (!bootstrap) { - return - } - // The session first, then the identity, which is applied only if it agrees with the session: - // the line below may set the session this status describes, and setLoggedIn writes the store - // synchronously, so the read inside applyStatusIdentity sees it. Nothing outside this function - // is involved -- swapping these two lines is what would break it. - applyUnversionedStatusSession(bootstrap) - applyStatusIdentity(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 @@ -262,12 +212,8 @@ export const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus // 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, version?: T.RPCGen.StateVersion) => { - if (!isMobile || state === undefined) { - return - } - if (!useConfigState.getState().dispatch.acceptAppStateVersion(version)) { - logger.info('[AppState] older than the applied state, ignoring') +export const applyMobileAppState = (state: T.RPCGen.MobileAppState) => { + if (!isMobile) { return } switch (state) { @@ -282,8 +228,8 @@ export const applyMobileAppState = (state?: T.RPCGen.MobileAppState, version?: T useShellState.getState().dispatch.setMobileAppState('background') break default: - // a fifth state the service grew and we have not mapped: it has already taken the version, - // so say so rather than leaving the store silently stuck on the one before it + // 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`) } } @@ -331,55 +277,46 @@ const drainPushTapRoute = async () => { } } -// The reply to setNotifications: the state as of the moment this connection subscribed, so there -// is no read to order against the subscription. An old service returns nothing here and the -// bootstrap status keeps that job -- see applyUnversionedStatusSession. -export const applyClientState = (clientState?: T.RPCGen.ClientState, generation?: number) => { - // A reply from a connection a later handshake has already replaced must not write anything: - // the flag below has no connection identity of its own, and a rejection delivered a microtask - // after the reconnect would otherwise re-arm the fallback on the new connection. - if (generation !== undefined && generation !== useDaemonState.getState().handshakeGeneration) { - logger.info('[Bootstrap] dropping a subscription reply from a replaced connection') - return - } - const session = clientState?.session - useConfigState.getState().dispatch.setSessionIsUnversioned(!session) - if (!clientState || !session) { - logger.info( - clientState - ? '[Bootstrap] setNotifications answered before the login attempt settled; the status owns the session' - : '[Bootstrap] no client state from setNotifications; this service predates it' - ) - // the status may already be in the store from before we knew that, and a status identical to - // the stored one does not notify again - onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus) - } - if (!clientState) { - return - } - const {appState, httpSrvInfo, version} = clientState +// 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 completed login and logout and every cleared +// session, 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, and there is no earlier reading to order against. - // appState is generated as required, but a service older than it omits the field, so it really - // can be undefined here -- applyMobileAppState is what treats that as "nothing was said". - applyMobileAppState(appState, version) + // process started: this is what catches it up. + applyMobileAppState(appState) const configDispatch = useConfigState.getState().dispatch if (httpSrvInfo) { - configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token, version) + configDispatch.setHTTPSrvInfo(httpSrvInfo.address, httpSrvInfo.token) } if (!session) { + logger.info('[Bootstrap] the service has not settled its startup login yet') return } - if (!configDispatch.acceptSessionVersion(version)) { - logger.info('[Bootstrap] a login or logout is newer than this snapshot, ignoring') - return - } + settleSession() const {deviceID, deviceName, loggedIn, uid, username} = session - if (!loggedIn && useConfigState.getState().userSwitching) { - // policy, not ordering: keep the session and the user we have until the switch lands. The - // snapshot's identity is empty when it says logged out, so it must not be applied either. - logger.info('[Bootstrap] ignoring loggedIn=false snapshot during account switch') - return + // Another user than the one we are logged in as is a logout and then a login, whether or not the + // logged-out clientState between them reached us: on desktop an account switch resets the engine + // on the loggedOut event, which can drop the clientState right behind it. Logging out is what + // clears the previous account's stores. + if (loggedIn && useConfigState.getState().loggedIn && uid !== useCurrentUserState.getState().uid) { + 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 @@ -390,6 +327,47 @@ export const applyClientState = (clientState?: T.RPCGen.ClientState, generation? configDispatch.setLoggedIn(loggedIn) } +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) + } +} + const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { const next = nextNavState as Util.NavState const prev = previousNavState as Util.NavState @@ -436,38 +414,10 @@ export const onEngineConnected = () => { useConfigState.getState().dispatch.onEngineConnected() - const subscribe = async (generation: number) => { - let clientState: T.RPCGen.ClientState | undefined - try { - // prettier-ignore - clientState = 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, - }, - }) - } catch (error) { - logger.warn('error in toggling notifications: ', error) - // clientState stays undefined: no reply and no channels either, so nothing versioned will - // reach this connection and the bootstrap status is all we have, exactly as for a service - // too old to answer at all - } - // outside the try on purpose: a throw from applying a good reply must not be read as a - // failed subscribe and re-run the unversioned fallback over half-applied versioned state - applyClientState(clientState, generation) - } - // a new connection has told us nothing yet; the reply is what settles it - useConfigState.getState().dispatch.setSessionIsUnversioned(false) + awaitSessionAgain() + subscription = subscribe() ignorePromise(drainPushTapRoute()) - // startHandshake first so this connection has its generation before the subscribe goes out. - // Nothing orders the two RPCs any more: the subscription reply is what carries the session and - // the http address, so the bootstrap read has nothing left to race with. useDaemonState.getState().dispatch.startHandshake() - ignorePromise(subscribe(useDaemonState.getState().handshakeGeneration)) } export const onEngineDisconnected = () => { @@ -485,6 +435,7 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.configuredAccounts, onConfiguredAccountsChanged) ) - _sharedUnsubs.push(subscribeValue(useDaemonState, s => s.bootstrapStatus, onBootstrapStatusChanged)) - _sharedUnsubs.push(subscribeValue(useShellState, s => s.networkStatus?.online, onNetworkOnlineChanged)) _sharedUnsubs.push( @@ -522,11 +471,12 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'keybase.1.NotifyApp.pushTapRouteAvailable': ignorePromise(drainPushTapRoute()) break - case 'keybase.1.NotifyApp.mobileAppStateChanged': { - const {state, version} = action.payload.params - applyMobileAppState(state, version) + 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 e08abf03d354..c9671c4222ea 100644 --- a/shared/constants/rpc/index.tsx +++ b/shared/constants/rpc/index.tsx @@ -71,6 +71,7 @@ type Chat1ResponseActionMap = { } type Keybase1IncomingAction = + 'keybase.1.NotifyApp.clientState' | 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyApp.pushTapRouteAvailable' | 'keybase.1.NotifyAudit.boxAuditError' | diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx index 5094563d8f75..d3297ee69744 100644 --- a/shared/constants/rpc/rpc-gen.tsx +++ b/shared/constants/rpc/rpc-gen.tsx @@ -11,12 +11,16 @@ 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,readonly version: StateVersion}, + inParam: {readonly state: MobileAppState}, outParam: void, }, 'keybase.1.NotifyApp.pushTapRouteAvailable': { @@ -80,7 +84,7 @@ export type MessageTypes = { outParam: void, }, 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - inParam: {readonly info: HttpSrvInfo,readonly version: StateVersion}, + inParam: {readonly info: HttpSrvInfo}, outParam: void, }, 'keybase.1.NotifyService.handleKeybaseLink': { @@ -96,11 +100,11 @@ export type MessageTypes = { outParam: void, }, 'keybase.1.NotifySession.loggedIn': { - inParam: {readonly username: string,readonly signedUp: boolean,readonly version: StateVersion}, + inParam: {readonly username: string,readonly signedUp: boolean}, outParam: void, }, 'keybase.1.NotifySession.loggedOut': { - inParam: {readonly version: StateVersion}, + inParam: undefined, outParam: void, }, 'keybase.1.NotifySimpleFS.simpleFSArchiveStatusChanged': { @@ -845,7 +849,7 @@ export type MessageTypes = { }, 'keybase.1.notifyCtl.setNotifications': { inParam: {readonly channels: NotificationChannels}, - outParam: ClientState, + outParam: void, }, 'keybase.1.pgp.pgpKeyGenDefault': { inParam: {readonly createUids: PGPCreateUids}, @@ -2554,7 +2558,7 @@ export type CheckResult = {readonly proofResult: ProofResult,readonly time: Time 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 version: StateVersion,readonly session?: ClientSession | null,readonly httpSrvInfo?: HttpSrvInfo | null,readonly appState: MobileAppState,} +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,} @@ -2954,7 +2958,6 @@ export type SocialAssertion = {readonly user: string,readonly service: SocialAss export type SocialAssertionService = string export type StartProofResult = {readonly sigID: SigID,} export type StartStatus = {readonly log: string,} -export type StateVersion = {readonly epoch: number,readonly counter: number,} export type Status = {readonly code: number,readonly name: string,readonly desc: string,readonly fields?: ReadonlyArray | null,} export type StellarAccount = {readonly accountID: string,readonly federationAddress: string,readonly sigID: SigID,readonly hidden: boolean,} export type Stream = {readonly fd: number,} @@ -3137,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.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' +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' diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index af72f6e83c50..2ea66bcdc4a1 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -87,15 +87,6 @@ const initialStore: Store = { export type State = Store & { dispatch: { - // an app lifecycle state notification or snapshot: applied only if it is newer than the last - // applied one. The fan-out is one goroutine per connection, so two of these can arrive in - // either order, and applying the older one last would leave us permanently wrong. - acceptAppStateVersion: (version?: T.RPCGen.StateVersion) => boolean - // a login or logout notification: applied only if it is newer than the last applied one - acceptSessionVersion: (version?: T.RPCGen.StateVersion) => boolean - // whether the connected service has told us it cannot settle the session -- see the closure - sessionIsUnversioned: () => boolean - setSessionIsUnversioned: (unversioned: boolean) => void checkForUpdate: () => void initAppUpdateLoop: () => void installerRan: () => void @@ -117,7 +108,7 @@ export type State = Store & { setChatStaticConfig: (s: T.Chat.StaticConfig) => void setDefaultUsername: (u: string) => void setGlobalError: (e?: unknown) => void - setHTTPSrvInfo: (address: string, token: string, version?: T.RPCGen.StateVersion) => void + setHTTPSrvInfo: (address: string, token: string) => void setJustDeletedSelf: (s: string) => void setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void @@ -129,43 +120,8 @@ export type State = Store & { } } -// A version we cannot compare is no ordering at all: a service too old to send one, or one built -// from an intermediate commit of this branch, which sends a bare number rather than a record. -const isComparableVersion = (version?: T.RPCGen.StateVersion): version is T.RPCGen.StateVersion => - !!version && typeof version.counter === 'number' && typeof version.epoch === 'number' - -// A different epoch is a different service process: its counter started over, so -// it is not comparable and its state is by definition the newer one. -const isNewerVersion = (next: T.RPCGen.StateVersion, applied?: T.RPCGen.StateVersion) => - next.epoch !== applied?.epoch || next.counter > applied.counter - export const useConfigState = Z.createZustand('config', (set, get) => { let inflightRefreshAccounts: Promise | undefined - // The http server address and the session change at any time and say so with versioned - // notifications; the setNotifications reply carries both under one version. The service stamps - // every one of them from one counter, so only a strictly newer version wins. The reply is - // labelled before the state it carries, so it is never newer than its label: dropping it on a - // tie loses nothing, because anything it holds beyond its label is a change already on its way - // as its own notification. - const applied: { - appState?: T.RPCGen.StateVersion - http?: T.RPCGen.StateVersion - session?: T.RPCGen.StateVersion - } = {} - const acceptVersion = (kind: 'appState' | 'http' | 'session', version?: T.RPCGen.StateVersion) => { - // a service too old to send a version gives us nothing to order by, so everything it sends is - // applied in the order it arrives, as it was before versions existed - if (!isComparableVersion(version)) return true - if (!isNewerVersion(version, applied[kind])) return false - applied[kind] = version - return true - } - // Set by the init layer from each setNotifications reply: true while the connected service has - // said it cannot settle the session, which is the only time the unversioned bootstrap status may - // own it. Cleared here rather than there, the moment a real session version is accepted, because - // that is the service settling it after all -- an account that is genuinely logged out announces - // nothing, so the status stays authoritative for it. - let sessionIsUnversioned = false const _checkForUpdate = async () => { try { @@ -217,14 +173,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } const dispatch: State['dispatch'] = { - acceptAppStateVersion: version => acceptVersion('appState', version), - acceptSessionVersion: version => { - const accepted = acceptVersion('session', version) - if (accepted && isComparableVersion(version)) { - sessionIsUnversioned = false - } - return accepted - }, checkForUpdate: () => { const f = async () => { await _checkForUpdate() @@ -355,10 +303,8 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(f()) }, onEngineConnected: () => { - // The applied versions are kept: a restarted service announces a different epoch, which is - // always newer, and a service that is still the same one kept counting across the reconnect. - // 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 // If ever you want to get OOBMs for a different system, then you need to enter it here. @@ -401,34 +347,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { break } case 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - const {info, version} = action.payload.params - get().dispatch.setHTTPSrvInfo(info.address, info.token, version) - break - } - case 'keybase.1.NotifySession.loggedIn': { - logger.info('keybase.1.NotifySession.loggedIn') - const {loggedIn, dispatch} = get() - if (!dispatch.acceptSessionVersion(action.payload.params.version)) { - logger.info('keybase.1.NotifySession.loggedIn: older than the applied session, ignoring') - break - } - // only send this if we think we're not logged in - if (!loggedIn) { - dispatch.setLoggedIn(true) - } - break - } - case 'keybase.1.NotifySession.loggedOut': { - logger.info('keybase.1.NotifySession.loggedOut') - const {loggedIn, dispatch} = get() - if (!dispatch.acceptSessionVersion(action.payload.params.version)) { - logger.info('keybase.1.NotifySession.loggedOut: older than the applied session, ignoring') - break - } - // 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) - } + get().dispatch.setHTTPSrvInfo(action.payload.params.info.address, action.payload.params.info.token) break } default: @@ -558,17 +477,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }) } }, - setHTTPSrvInfo: (address, token, version) => { - if (!acceptVersion('http', version)) { - logger.info(`[HTTPSrv] ignoring ${address}: version ${JSON.stringify(version)} is not newer`) - return - } + setHTTPSrvInfo: (address, token) => { set(s => { s.httpSrv.address = address s.httpSrv.token = token }) }, - sessionIsUnversioned: () => sessionIsUnversioned, setJustDeletedSelf: self => { set(s => { s.justDeletedSelf = self @@ -596,9 +510,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { Object.assign(s.outOfDate, outOfDate) }) }, - setSessionIsUnversioned: unversioned => { - sessionIsUnversioned = unversioned - }, setStartupDetails: st => { set(s => { if (s.startup.loaded) { diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index d9df2ac87a1e..b9e0c87a174d 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -108,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/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts index 4562115e8a9c..b1987f4ffee4 100644 --- a/shared/stores/tests/client-state.test.ts +++ b/shared/stores/tests/client-state.test.ts @@ -3,136 +3,82 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '../config' import {useCurrentUserState} from '../current-user' -import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/shared' +import {useShellState} from '../shell' +import {_onEngineIncoming, applyClientState} from '@/constants/init/shared' -const epoch = 1000 -const version = (counter: number, e = epoch): T.RPCGen.StateVersion => ({counter, epoch: e}) +const g = globalThis as unknown as {isMobile: boolean} -const clientState = ( - session: Partial = {}, - over: Partial = {} -): T.RPCGen.ClientState => ({ +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: {deviceID: 'd1', deviceName: 'testuser-mac', loggedIn: true, uid: 'u1', username: 'testuser', ...session}, - version: version(1), + session: session(), ...over, }) -const notifyHTTP = (address: string, v?: T.RPCGen.StateVersion) => - useConfigState.getState().dispatch.onEngineIncoming({ - payload: {params: {info: {address, token: 'token'}, version: v}}, - type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', - } as never) +const notifyClientState = (state: T.RPCGen.ClientState) => + _onEngineIncoming({payload: {params: {state}}, type: 'keybase.1.NotifyApp.clientState'} as never) -const notifySession = (kind: 'loggedIn' | 'loggedOut', v?: T.RPCGen.StateVersion) => +const notifyHTTP = (address: string) => useConfigState.getState().dispatch.onEngineIncoming({ - payload: { - params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser', version: v} : {version: v}, - }, - type: `keybase.1.NotifySession.${kind}`, + payload: {params: {info: {address, token: 'token'}}}, + type: 'keybase.1.NotifyService.HTTPSrvInfoUpdate', } as never) -// The applied versions live outside the store and deliberately survive resetAllStores, so each -// test gets its own epoch instead of relying on a reset that no longer exists. -let testEpoch = epoch -beforeEach(() => { - testEpoch++ -}) afterEach(() => { + g.isMobile = false jest.restoreAllMocks() resetAllStores() }) -describe('the setNotifications snapshot', () => { - test('applies the session, the current user and the http address', () => { - applyClientState( - clientState({}, {httpSrvInfo: {address: '127.0.0.1:1', token: 'token'}, version: version(1, testEpoch)}) +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('loses to a session notification that is already newer', () => { - notifySession('loggedOut', version(7, testEpoch)) - useConfigState.setState({loggedIn: false}) - - applyClientState(clientState({loggedIn: true}, {version: version(6, testEpoch)})) + 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) - expect(useCurrentUserState.getState().username).toBe('') }) - test('is dropped on a tie, which costs nothing: it is labelled before the state it carries', () => { - notifySession('loggedOut', version(4, testEpoch)) - useConfigState.setState({loggedIn: false}) - - applyClientState(clientState({loggedIn: true}, {version: version(4, testEpoch)})) - + 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) - }) - - test('from a restarted service wins although its counter started over', () => { - notifySession('loggedOut', version(9, testEpoch)) - useConfigState.setState({loggedIn: false}) - - applyClientState(clientState({loggedIn: true}, {version: version(1, testEpoch + 500)})) - - expect(useConfigState.getState().loggedIn).toBe(true) - }) - - test('is ignored during an account switch when it says logged out', () => { - useConfigState.setState({loggedIn: true, userSwitching: true}) - useCurrentUserState.setState({username: 'testuser'}) - - applyClientState( - clientState({loggedIn: false, uid: '', username: ''}, {version: version(1, testEpoch)}) - ) - - expect(useConfigState.getState().loggedIn).toBe(true) - // a logged-out snapshot carries an empty identity; applying it would blank the user the - // guard just decided to keep - expect(useCurrentUserState.getState().username).toBe('testuser') - }) -}) - -describe('notification ordering', () => { - test('a notification older than the applied one is ignored', () => { - notifySession('loggedOut', version(5, testEpoch)) - expect(useConfigState.getState().loggedIn).toBe(false) - - useConfigState.setState({loggedIn: false}) - notifySession('loggedIn', version(4, testEpoch)) - expect(useConfigState.getState().loggedIn).toBe(false) - }) + expect(useCurrentUserState.getState().username).toBe('') - test('a notification with the version already applied is ignored', () => { - notifySession('loggedIn', version(5, testEpoch)) + applyClientState(clientState()) expect(useConfigState.getState().loggedIn).toBe(true) - notifySession('loggedOut', version(5, testEpoch)) + applyClientState(clientState({session: null})) expect(useConfigState.getState().loggedIn).toBe(true) }) - test('the http address and the session are ordered separately off one counter', () => { - notifyHTTP('127.0.0.1:2', version(3, testEpoch)) - notifySession('loggedIn', version(5, testEpoch)) - // stamped before the login, so a single applied version would reject it, but it is newer than - // the address we have and the address is what it describes - notifyHTTP('127.0.0.1:3', version(4, testEpoch)) - - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') - }) - - test('the applied versions survive an engine reconnect to the same service', () => { - notifyHTTP('127.0.0.1:2', version(9, testEpoch)) - useConfigState.getState().dispatch.onEngineConnected() - notifyHTTP('127.0.0.1:3', version(8, testEpoch)) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') - }) - 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 @@ -143,67 +89,103 @@ describe('notification ordering', () => { } }) - applyClientState(clientState({loggedIn: true}, {version: version(1, testEpoch)})) + applyClientState(clientState()) unsub() expect(seen).toBe('testuser') }) - test('an address stamped with counter 0 is applied', () => { - // the http server can start before NotifyRouter exists, so its first update returns early and - // the reply carries a live address labelled 0; the epoch is what makes that newer than nothing - applyClientState( - clientState( - {}, - {httpSrvInfo: {address: '127.0.0.1:7', token: 'token'}, version: version(0, testEpoch)} - ) - ) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:7') - }) - test('logging out keeps the http server address', () => { - notifyHTTP('127.0.0.1:2', version(1, testEpoch)) + notifyHTTP('127.0.0.1:2') useConfigState.getState().dispatch.resetState() expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:2') }) -}) -describe('the bootstrap status identity', () => { - test('is not applied when the status disagrees with the session we are in', () => { - // the read can span a logout: GetBootstrapStatus waits out the startup login attempt, and a - // logout announced meanwhile has already reset the stores - applyClientState( - clientState({loggedIn: false, uid: '', username: ''}, {version: version(1, testEpoch)}) - ) + 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) - onBootstrapStatusChanged({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', + 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('') - expect(useCurrentUserState.getState().uid).toBe('') }) - test('is applied when it agrees', () => { - applyClientState( - clientState({loggedIn: true, uid: 'u1', username: 'testuser'}, {version: version(1, testEpoch)}) - ) + test('the same user again is not a switch', () => { + applyClientState(clientState()) + markAccountState() + const {changes, unsub} = loginChanges() - onBootstrapStatusChanged({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', - } as never) + applyClientState(clientState()) + unsub() - expect(useCurrentUserState.getState().username).toBe('testuser') + expect(changes).toEqual([]) + expect(accountStateCleared()).toBe(false) }) }) diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index 956e58fec1c1..ed44e1e3c656 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -159,8 +159,7 @@ describe('a superseded read', () => { }) test('does not write its status over the newer load', async () => { - // a reconnect invalidates in-flight reads whatever any version says: the generation orders - // client attempts, which the service's counter knows nothing about + // a reconnect invalidates in-flight reads: the generation orders client attempts let resolveLosing!: (bs: T.RPCGen.BootstrapStatus) => void jest .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') diff --git a/shared/stores/tests/legacy-service.test.ts b/shared/stores/tests/legacy-service.test.ts deleted file mode 100644 index fd3f8035c43e..000000000000 --- a/shared/stores/tests/legacy-service.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/// -import * as T from '@/constants/types' -import {resetAllStores} from '@/util/zustand' -import {useConfigState} from '../config' -import {useCurrentUserState} from '../current-user' -import {useDaemonState} from '../daemon' -import {applyClientState, onBootstrapStatusChanged} from '@/constants/init/shared' - -// Its own file: whether the connected service can settle the session is module state in the init -// layer that outlives resetAllStores, and jest gives each file a fresh module registry. - -const notifySession = (kind: 'loggedIn' | 'loggedOut', version?: T.RPCGen.StateVersion) => - useConfigState.getState().dispatch.onEngineIncoming({ - payload: { - params: kind === 'loggedIn' ? {signedUp: false, username: 'testuser', version} : {version}, - }, - type: `keybase.1.NotifySession.${kind}`, - } as never) - -const status = (over: Partial = {}) => - ({ - deviceID: 'd1', - deviceName: 'testuser-mac', - loggedIn: true, - registered: true, - uid: 'u1', - username: 'testuser', - ...over, - }) as T.RPCGen.BootstrapStatus - -// the applied versions live outside the store and survive resetAllStores on purpose, so each -// test gets its own epoch rather than a counter that has to beat every earlier test's -let testEpoch = 1000 - -const snapshot = (over: Partial = {}): T.RPCGen.ClientState => ({ - appState: T.RPCGen.MobileAppState.foreground, - session: {deviceID: 'd2', deviceName: 'testuser-other', loggedIn: true, uid: 'u2', username: 'testuser-mac'}, - version: {counter: 1, epoch: testEpoch}, - ...over, -}) - -beforeEach(() => { - testEpoch++ - useConfigState.setState(st => { - // httpSrv is process-wide and survives resetAllStores on purpose - st.httpSrv = {address: '', token: ''} - }) -}) -afterEach(() => { - jest.restoreAllMocks() - resetAllStores() -}) - -describe('a service that cannot settle the session', () => { - test('has its bootstrap status own the session and the http address', () => { - applyClientState(undefined) - onBootstrapStatusChanged(status({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') - }) - - test('applies its unversioned notifications in arrival order', () => { - applyClientState(undefined) - onBootstrapStatusChanged(status()) - expect(useConfigState.getState().loggedIn).toBe(true) - - notifySession('loggedOut') - expect(useConfigState.getState().loggedIn).toBe(false) - }) - - test('is applied even when its status landed before we knew the service was old', () => { - // a status identical to the stored one does not notify again, so learning that the service - // has no snapshot has to re-apply what is already in the store - useDaemonState.setState({bootstrapStatus: status()}) - expect(useConfigState.getState().loggedIn).toBe(false) - - applyClientState(undefined) - - expect(useConfigState.getState().loggedIn).toBe(true) - expect(useCurrentUserState.getState().username).toBe('testuser') - }) - - test('stops owning the session the moment a service does answer with a snapshot', () => { - applyClientState(snapshot()) - expect(useCurrentUserState.getState().username).toBe('testuser-mac') - - onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}})) - - expect(useConfigState.getState().httpSrv.address).toBe('') - // the identity still comes from the status: it agrees with the session we are in - expect(useCurrentUserState.getState().username).toBe('testuser') - }) - - test('owns the session again after a downgrade under a live client', () => { - applyClientState(snapshot({version: {counter: 9, epoch: testEpoch}})) - expect(useConfigState.getState().loggedIn).toBe(true) - - // the service is stopped and an older one starts; the reconnect answers with no snapshot - applyClientState(undefined) - onBootstrapStatusChanged(status({httpSrvInfo: {address: '127.0.0.1:9', token: 'token'}, loggedIn: false})) - - expect(useConfigState.getState().loggedIn).toBe(false) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:9') - }) - - test('leaves the session to the status while its startup login attempt has not settled', () => { - // mobile runs the attempt off the Init thread, after the loopback listener is up, so a client - // can subscribe before there is any session to report. A reply that said "logged out" there - // would bar the settled status for the life of the process, repairable only by a notification - // whose send is fire-and-forget. - applyClientState({appState: T.RPCGen.MobileAppState.foreground, version: {counter: 4, epoch: 1000}}) - - onBootstrapStatusChanged(status()) - - expect(useConfigState.getState().loggedIn).toBe(true) - expect(useCurrentUserState.getState().username).toBe('testuser') - }) - - test('still takes the http address from an unsettled reply', () => { - applyClientState({ - appState: T.RPCGen.MobileAppState.foreground, - httpSrvInfo: {address: '127.0.0.1:3', token: 'token'}, - version: {counter: 4, epoch: 1000}, - }) - expect(useConfigState.getState().httpSrv.address).toBe('127.0.0.1:3') - }) - - test('hands the session back the moment the service settles it', () => { - // mobile: the reply lands before tryLogin finishes, so the fallback is armed. Once the - // login notification arrives the service has settled it, and the versioned stream owns the - // session from there -- otherwise an unversioned write outranks every notification for the - // life of the connection. - applyClientState({appState: T.RPCGen.MobileAppState.foreground, version: {counter: 4, epoch: testEpoch}}) - expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(true) - - notifySession('loggedIn', {counter: 5, epoch: testEpoch}) - - expect(useConfigState.getState().dispatch.sessionIsUnversioned()).toBe(false) - expect(useConfigState.getState().loggedIn).toBe(true) - }) - - test('a status spanning a logout cannot resurrect the session it retired', () => { - // GetBootstrapStatus does network work after a wait of up to 30s, and no generation is - // bumped by a logout, so a read started before it resolves afterwards saying loggedIn:true - applyClientState({appState: T.RPCGen.MobileAppState.foreground, version: {counter: 4, epoch: testEpoch}}) - notifySession('loggedIn', {counter: 5, epoch: testEpoch}) - notifySession('loggedOut', {counter: 6, epoch: testEpoch}) - expect(useConfigState.getState().loggedIn).toBe(false) - - onBootstrapStatusChanged(status()) - - expect(useConfigState.getState().loggedIn).toBe(false) - }) -}) From 0e720d7249cbcf3c1e04c19d0a645f82b5e735c5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 21:34:32 -0400 Subject: [PATCH 111/127] fix(notify): push every session change, not only clears A switchUserMu release now queues a clientState whenever it changed the session's validity, user or device. The exception is a promotion that a provisioning, signup or oneshot flow makes before it completes and that leaves a valid session: that flow's SendLogin queues one. This covers a Device prereq that bootstraps the active device from the secret store, and a passphrase unlock that caches the device keys. Neither has a SendLogin behind it. loggedOut is sent as a plain notification. SetChannels no longer re-registers a connection that has already closed. The recorder test helper is renamed to match test_common.go. JS applies a logged-out clientState to the session before anything else, so nothing sees a logged-in session with no user. --- go/libkb/{notify_recorder.go => test_notify_recorder.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename go/libkb/{notify_recorder.go => test_notify_recorder.go} (100%) diff --git a/go/libkb/notify_recorder.go b/go/libkb/test_notify_recorder.go similarity index 100% rename from go/libkb/notify_recorder.go rename to go/libkb/test_notify_recorder.go From f162757525585946538ff738dabc8ab7bb21ab01 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 21:34:44 -0400 Subject: [PATCH 112/127] fix(notify): the session-change changes the previous commit describes The previous commit only staged the recorder's rename. This one holds the changes its message describes: lockSwitchUser queues a clientState on every change to the session except a flow's early promotion, loggedOut is sent as a plain notification, SetChannels ignores a closed connection, and JS applies a logged-out clientState to the session first. --- go/libkb/context.go | 18 +++--- go/libkb/globals.go | 31 +++++++--- go/libkb/logout.go | 2 +- go/libkb/notify_router.go | 49 ++++++++------- go/libkb/notify_router_test.go | 79 ++++++++++++++++++++++++ go/libkb/test_notify_recorder.go | 9 +-- shared/constants/init/shared.test.ts | 20 ++++++ shared/constants/init/shared.tsx | 17 +++-- shared/stores/tests/client-state.test.ts | 16 +++++ 9 files changed, 192 insertions(+), 49 deletions(-) diff --git a/go/libkb/context.go b/go/libkb/context.go index 8611dea64df9..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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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.lockSwitchUser(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 4a92a6f09daf..28b125767b1e 100644 --- a/go/libkb/globals.go +++ b/go/libkb/globals.go @@ -361,16 +361,33 @@ 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 leaves no -// valid session queues a clientState to connected clients, after unlocking; a -// release that leaves a valid one queues nothing, because the login it belongs -// to announces itself when it completes. See connSender for why that is enough. -func (g *GlobalContext) lockSwitchUser(mctx MetaContext, reasonFormat string, args ...any) (release func()) { +// 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() - if !g.ActiveDevice.Valid() { + earlyLogin := promotion && after.valid + if after != before && !earlyLogin { g.NotifyRouter.AnnounceClientState(mctx.Ctx()) } } @@ -379,7 +396,7 @@ func (g *GlobalContext) lockSwitchUser(mctx MetaContext, reasonFormat string, ar // simulateServiceRestart simulates what happens when a service restarts for the // purposes of testing. func (g *GlobalContext) simulateServiceRestart() { - defer g.lockSwitchUser(NewMetaContext(context.TODO(), g), "simulateServiceRestart")() + defer g.lockSwitchUser(NewMetaContext(context.TODO(), g), false, "simulateServiceRestart")() _ = g.ActiveDevice.Clear() } diff --git a/go/libkb/logout.go b/go/libkb/logout.go index f6426b5fd54e..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.lockSwitchUser(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 48a6b30fe006..880a370e4742 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -343,9 +343,9 @@ func NewNotifyRouter(g *GlobalContext) *NotifyRouter { // 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 and loggedOut are calls rather than notifications, and -// callInOrder gives them the same place in line without waiting for a reply. -// A connection therefore receives its jobs in the order they were queued. +// 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 @@ -363,21 +363,21 @@ func NewNotifyRouter(g *GlobalContext) *NotifyRouter { // 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: a clientState is queued after every completed login and -// logout (SendLogin, HandleLogout), and after every switchUserMu release -// that leaves no valid session (GlobalContext.lockSwitchUser). So the last -// session write is either a clear, with a clientState queued right after -// it, or a valid write, which its login completes with SendLogin and so a -// clientState queued after that. 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. A valid write made -// partway through provisioning or signup queues nothing, so a client does -// not see a login before it completes. What this leaves: a flow that fails -// and leaves a valid session it never announces is not pushed until the -// next clientState, and a clientState dequeued partway through a flow reads -// its provisional session. The startup login attempt settling, which turns -// a null session into a real one, queues a clientState too. +// - 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 @@ -536,8 +536,13 @@ func (n *NotifyRouter) removeConnection(id ConnectionID) { func (n *NotifyRouter) SetChannels(i ConnectionID, nc keybase1.NotificationChannels) { 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 s := n.senders[i]; s != nil && wantsClientState(nc) { + if wantsClientState(nc) { s.enqueue(n.sendClientState(context.Background())) } } @@ -619,9 +624,9 @@ func (n *NotifyRouter) HandleLogout(ctx context.Context) { n.announce(ctx, "HandleLogout", 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}).LoggedOut(ctx) - }) + _ = (keybase1.NotifySessionClient{ + Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil), + }).LoggedOut(ctx) }) n.AnnounceClientState(ctx) diff --git a/go/libkb/notify_router_test.go b/go/libkb/notify_router_test.go index ae16a20c79f3..3c582a8c5b56 100644 --- a/go/libkb/notify_router_test.go +++ b/go/libkb/notify_router_test.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/assert" @@ -90,6 +91,60 @@ func TestProvisionalValidWriteQueuesNoClientState(t *testing.T) { 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) { @@ -211,3 +266,27 @@ func TestClientStateReadsWhenSent(t *testing.T) { 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) +} diff --git a/go/libkb/test_notify_recorder.go b/go/libkb/test_notify_recorder.go index 60eadb80f4a2..4a3401653c37 100644 --- a/go/libkb/test_notify_recorder.go +++ b/go/libkb/test_notify_recorder.go @@ -16,11 +16,12 @@ import ( "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 +// 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. +// 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 diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 87284b18b87f..8f50bd1b8308 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -6,6 +6,7 @@ import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' import { applyClientState, + initSharedSubscriptions, loadAccountsStep, onEngineConnected, onNetworkOnlineChanged, @@ -252,6 +253,25 @@ describe('sessionSettledStep', () => { await expect(step).resolves.toBeUndefined() }) + test('is one of the handshake steps', () => { + 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()) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 4141dcf2978e..5f016e5d6d32 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -311,11 +311,16 @@ export const applyClientState = (clientState: T.RPCGen.ClientState) => { } settleSession() const {deviceID, deviceName, loggedIn, uid, username} = session - // Another user than the one we are logged in as is a logout and then a login, whether or not the - // logged-out clientState between them reached us: on desktop an account switch resets the engine - // on the loggedOut event, which can drop the clientState right behind it. Logging out is what - // clears the previous account's stores. - if (loggedIn && useConfigState.getState().loggedIn && uid !== useCurrentUserState.getState().uid) { + 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. + if (useConfigState.getState().loggedIn && uid !== useCurrentUserState.getState().uid) { configDispatch.setLoggedIn(false) } // identity before the session: setLoggedIn fans out synchronously, and every subscriber of a @@ -324,7 +329,7 @@ export const applyClientState = (clientState: T.RPCGen.ClientState) => { if (username) { configDispatch.setDefaultUsername(username) } - configDispatch.setLoggedIn(loggedIn) + configDispatch.setLoggedIn(true) } const subscribe = async () => { diff --git a/shared/stores/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts index b1987f4ffee4..9fc398232507 100644 --- a/shared/stores/tests/client-state.test.ts +++ b/shared/stores/tests/client-state.test.ts @@ -177,6 +177,22 @@ describe('an account switch', () => { 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('the same user again is not a switch', () => { applyClientState(clientState()) markAccountState() From 9186c40d157136665f24b38956bc9c5ec50813c5 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 21:50:52 -0400 Subject: [PATCH 113/127] refactor(chat): drop URL retention now that the service keeps the last address --- .../chat/conversation/local-server-urls.tsx | 69 -------- .../thread-message-state.test.tsx | 155 +++--------------- .../conversation/thread-message-state.tsx | 26 +-- shared/common-adapters/localhost-src.test.ts | 13 +- shared/common-adapters/localhost-src.tsx | 11 +- 5 files changed, 28 insertions(+), 246 deletions(-) delete mode 100644 shared/chat/conversation/local-server-urls.tsx diff --git a/shared/chat/conversation/local-server-urls.tsx b/shared/chat/conversation/local-server-urls.tsx deleted file mode 100644 index 8a0e76bef11a..000000000000 --- a/shared/chat/conversation/local-server-urls.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import * as T from '@/constants/types' -import {parseServiceDecoration} from '@/common-adapters/markdown/service-decoration-parser' - -// The service hands out '' for URLs on its local http server while that server is down (iOS -// background). A message refreshed then must not lose the URLs we already have: they work again -// once the server is back, and nothing else would refill them. - -export const localServerURLKeys: ReadonlySet = new Set(['fileURL', 'previewURL']) - -const decorationRegex = /\$>kb\$(.*?)\$ void): T.RPCChat.EmojiLoadSource => { - if (source.typ !== T.RPCChat.EmojiLoadSourceTyp.httpsrv) return source - if (!source.httpsrv) onEmpty() - return {...source, httpsrv: ''} -} - -const withoutEmojiURLs = (decorated: string) => { - let hasEmpty = false - const onEmpty = () => { - hasEmpty = true - } - const blanked = decorated.replace(decorationRegex, (match, json: string) => { - const d = parseServiceDecoration(json) - if (d?.typ !== T.RPCChat.UITextDecorationTyp.emoji) return match - const noAnimSource = blankSource(d.emoji.noAnimSource, onEmpty) - const source = blankSource(d.emoji.source, onEmpty) - return JSON.stringify({...d, emoji: {...d.emoji, noAnimSource, source}}) - }) - return {blanked, hasEmpty} -} - -// true when incoming decorated text is existing with emoji URLs gone empty, so existing should stay -export const shouldKeepEmojiURLs = (existing: string, incoming: string) => { - if (existing === incoming || !incoming.includes('$>kb$')) return false - const next = withoutEmojiURLs(incoming) - if (!next.hasEmpty) return false - const cur = withoutEmojiURLs(existing) - return !cur.hasEmpty && cur.blanked === next.blanked -} - -type ImageDisplay = T.RPCChat.UnfurlImageDisplay | null | undefined - -const imageKeepingURL = (existing: ImageDisplay, incoming: ImageDisplay) => - incoming && !incoming.url && existing?.url ? {...incoming, url: existing.url} : incoming - -export const unfurlKeepingLocalServerURLs = ( - existing: T.RPCChat.UIMessageUnfurlInfo | undefined, - incoming: T.RPCChat.UIMessageUnfurlInfo -): T.RPCChat.UIMessageUnfurlInfo => { - const cur = existing?.unfurl - const next = incoming.unfurl - if (cur?.unfurlType === T.RPCChat.UnfurlType.generic && next.unfurlType === T.RPCChat.UnfurlType.generic) { - const favicon = imageKeepingURL(cur.generic.favicon, next.generic.favicon) - const media = imageKeepingURL(cur.generic.media, next.generic.media) - if (favicon === next.generic.favicon && media === next.generic.media) return incoming - return {...incoming, unfurl: {...next, generic: {...next.generic, favicon, media}}} - } - if (cur?.unfurlType === T.RPCChat.UnfurlType.giphy && next.unfurlType === T.RPCChat.UnfurlType.giphy) { - const favicon = imageKeepingURL(cur.giphy.favicon, next.giphy.favicon) - const image = imageKeepingURL(cur.giphy.image, next.giphy.image) - const video = imageKeepingURL(cur.giphy.video, next.giphy.video) - if (favicon === next.giphy.favicon && image === next.giphy.image && video === next.giphy.video) { - return incoming - } - return {...incoming, unfurl: {...next, giphy: {...next.giphy, favicon, image, video}}} - } - return incoming -} diff --git a/shared/chat/conversation/thread-message-state.test.tsx b/shared/chat/conversation/thread-message-state.test.tsx index 11a9e720a9c6..688121dba72e 100644 --- a/shared/chat/conversation/thread-message-state.test.tsx +++ b/shared/chat/conversation/thread-message-state.test.tsx @@ -674,7 +674,10 @@ describe('addMessagesToThreadState', () => { }) }) -describe('local server urls that went empty', () => { +// The service keeps its last-bound address in Info()/getURL forever once it has bound once (see +// go/kbhttp/manager.Srv.Info), so a message update can no longer carry a URL that regresses a +// good one to empty: an update now always simply takes whatever the service sent. +describe('local server urls', () => { const textAt = (ord: number, override?: Omit, 'text'>) => makeTextMessage({ id: T.Chat.numberToMessageID(ord), @@ -682,47 +685,9 @@ describe('local server urls that went empty', () => { outboxID: undefined, ...override, }) - const emojiDecoration = (url: string) => { - const decoration: T.RPCChat.UITextDecoration = { - emoji: { - alias: 'party', - isAlias: false, - isBig: false, - isCrossTeam: false, - isReacji: false, - noAnimSource: {httpsrv: url && `${url}&noanim=true`, typ: T.RPCChat.EmojiLoadSourceTyp.httpsrv}, - remoteSource: { - message: {convID: new Uint8Array([1]), isAlias: false, msgID: 5}, - typ: T.RPCChat.EmojiRemoteSourceTyp.message, - }, - source: {httpsrv: url, typ: T.RPCChat.EmojiLoadSourceTyp.httpsrv}, - }, - typ: T.RPCChat.UITextDecorationTyp.emoji, - } - return `$>kb$${Buffer.from(JSON.stringify(decoration)).toString('base64')}$ { - 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('http://127.0.0.1:5000/f') - expect(m.previewURL).toBe('http://127.0.0.1:5000/p') - expect(m.title).toBe('renamed') - }) - - test('a new non-empty url still replaces the old one', () => { + 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'})], {}) @@ -731,128 +696,52 @@ describe('local server urls that went empty', () => { ) }) - test('decorated text keeps its emoji urls when only they went empty', () => { - const state = makeThreadState([]) - const good = `hi ${emojiDecoration(emojiURL)}` - addMessagesToThreadState(state, [textAt(10, {decoratedText: new HiddenString(good)})], {}) - addMessagesToThreadState( - state, - [textAt(10, {decoratedText: new HiddenString(`hi ${emojiDecoration('')}`)})], - {} - ) - expect( - (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).decoratedText?.stringValue() - ).toBe(good) - }) - - test('decorated text that changed otherwise is taken even with empty emoji urls', () => { + test('an empty url in an update now overwrites an existing one', () => { const state = makeThreadState([]) addMessagesToThreadState( state, - [textAt(10, {decoratedText: new HiddenString(`hi ${emojiDecoration(emojiURL)}`)})], - {} - ) - const edited = `bye ${emojiDecoration('')}` - addMessagesToThreadState(state, [textAt(10, {decoratedText: new HiddenString(edited)})], {}) - expect( - (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).decoratedText?.stringValue() - ).toBe(edited) - }) - - test('unfurls keep their image, favicon and video urls when an update carries empty ones', () => { - const image = (url: string): T.RPCChat.UnfurlImageDisplay => ({height: 10, isVideo: false, url, width: 10}) - const generic = (url: string, title: string): T.RPCChat.UIMessageUnfurlInfo => ({ - isCollapsed: false, - unfurl: { - generic: { - favicon: image(url && `${url}/favicon`), - media: image(url && `${url}/media`), - siteName: 'site', - title, - url: 'https://keybase.io', - }, - unfurlType: T.RPCChat.UnfurlType.generic, - }, - unfurlMessageID: T.Chat.numberToMessageID(11), - url: 'https://keybase.io', - }) - const giphy = (url: string): T.RPCChat.UIMessageUnfurlInfo => ({ - isCollapsed: false, - unfurl: { - giphy: { - favicon: image(url && `${url}/favicon`), - image: image(url && `${url}/image`), - video: {...image(url && `${url}/video`), isVideo: true}, - }, - unfurlType: T.RPCChat.UnfurlType.giphy, - }, - unfurlMessageID: T.Chat.numberToMessageID(12), - url: 'https://giphy.com/x', - }) - const local = 'http://127.0.0.1:5000' - const state = makeThreadState([]) - addMessagesToThreadState( - state, - [ - textAt(10, { - unfurls: new Map([ - ['https://keybase.io', generic(local, 'first')], - ['https://giphy.com/x', giphy(local)], - ]), - }), - ], + [makeAttachmentMessage({fileURL: 'http://127.0.0.1:5000/f', previewURL: 'http://127.0.0.1:5000/p'})], {} ) addMessagesToThreadState( state, - [ - textAt(10, { - unfurls: new Map([ - ['https://keybase.io', generic('', 'second')], - ['https://giphy.com/x', giphy('')], - ]), - }), - ], + [makeAttachmentMessage({fileURL: '', previewURL: '', title: 'renamed'})], {} ) - const unfurls = (state.messageMap.get(T.Chat.numberToOrdinal(10)) as T.Chat.MessageText).unfurls - const g = unfurls?.get('https://keybase.io')?.unfurl - expect(g?.unfurlType === T.RPCChat.UnfurlType.generic && g.generic.title).toBe('second') - expect(g?.unfurlType === T.RPCChat.UnfurlType.generic && [g.generic.favicon?.url, g.generic.media?.url]).toEqual([ - `${local}/favicon`, - `${local}/media`, - ]) - const gi = unfurls?.get('https://giphy.com/x')?.unfurl - expect( - gi?.unfurlType === T.RPCChat.UnfurlType.giphy && [gi.giphy.favicon?.url, gi.giphy.image?.url, gi.giphy.video?.url] - ).toEqual([`${local}/favicon`, `${local}/image`, `${local}/video`]) + 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 keep their emoji urls on a merge and on a reaction update', () => { - const good = emojiDecoration(emojiURL) + 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(good, ['testuser'])]])})], {}) addMessagesToThreadState( state, - [textAt(10, {reactions: new Map([[':party:', reaction(emojiDecoration(''), ['testuser', 'testuser-mac'])]])})], + [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(good) + expect(merged?.decorated).toBe('') expect(merged?.users.map(u => u.username)).toEqual(['testuser', 'testuser-mac']) updateReactionsInThreadState(state, [ { - reactions: new Map([[':party:', reaction(emojiDecoration(''), ['testuser'])]]), + 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(good) + 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 e3c6c951611e..bcda97f9e89b 100644 --- a/shared/chat/conversation/thread-message-state.tsx +++ b/shared/chat/conversation/thread-message-state.tsx @@ -1,7 +1,6 @@ import * as Message from '@/constants/chat/message' import * as T from '@/constants/types' import HiddenString from '@/util/hidden-string' -import {localServerURLKeys, shouldKeepEmojiURLs, unfurlKeepingLocalServerURLs} from './local-server-urls' import type {WritableDraft} from '@/util/zustand' type MessageLookup = Pick @@ -144,11 +143,6 @@ const maybeGetOrdinalByMessageID = ( ) => getOrdinalForMessageID(state.messageMap, state.pendingOutboxToOrdinal, messageID, state.messageIDToOrdinal) -const reactionKeepingEmojiURLs = (existing: T.Chat.ReactionDesc | undefined, incoming: T.Chat.ReactionDesc) => - existing && shouldKeepEmojiURLs(existing.decorated, incoming.decorated) - ? {...incoming, decorated: existing.decorated} - : incoming - const mergeMessage = ( existing: WritableDraft, incoming: WritableDraft @@ -160,9 +154,6 @@ const mergeMessage = ( const val = incomingRecord[key] const cur = existingRecord[key] if (val instanceof HiddenString) { - if (cur instanceof HiddenString && shouldKeepEmojiURLs(cur.stringValue(), val.stringValue())) { - continue - } if (!(cur instanceof HiddenString) || !val.equals(cur)) { existingRecord[key] = val } @@ -174,24 +165,11 @@ const mergeMessage = ( } } for (const [k, v] of val as Map) { - if (key === 'reactions') { - const old = (cur as Map).get(k) - ;(cur as Map).set(k, reactionKeepingEmojiURLs(old, v as T.Chat.ReactionDesc)) - } else if (key === 'unfurls') { - const old = (cur as Map).get(k) - ;(cur as Map).set( - k, - unfurlKeepingLocalServerURLs(old, v as T.RPCChat.UIMessageUnfurlInfo) - ) - } else { - ;(cur as Map).set(k, v) - } + ;(cur as Map).set(k, v) } } else { existingRecord[key] = val } - } else if (localServerURLKeys.has(key) && val === '' && typeof cur === 'string' && cur) { - continue } else if (cur !== val) { existingRecord[key] = val } @@ -597,7 +575,7 @@ export const updateReactionsInThreadState = ( for (const emoji of existingOrder) { const incoming = reactions.get(emoji) if (incoming) { - newReactions.set(emoji, reactionKeepingEmojiURLs(m.reactions.get(emoji), incoming)) + newReactions.set(emoji, incoming) } } const remainingEmojis = [...reactions.keys()].filter(emoji => !newReactions.has(emoji)) diff --git a/shared/common-adapters/localhost-src.test.ts b/shared/common-adapters/localhost-src.test.ts index 67180e6801ff..08d5a5b42eeb 100644 --- a/shared/common-adapters/localhost-src.test.ts +++ b/shared/common-adapters/localhost-src.test.ts @@ -1,7 +1,7 @@ /// import {isLocalhostSrc, retryLocalhostSrc} from './localhost-src' -const httpSrv = {address: '127.0.0.1:61234', token: 'newtoken'} +const httpSrv = {address: '127.0.0.1:61234'} test('only local service srcs are retryable', () => { expect(isLocalhostSrc('http://127.0.0.1:5000/av?name=testuser')).toBe(true) @@ -16,16 +16,7 @@ test('a retry points a baked attachment url at the current server port', () => { ) }) -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 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' - ) + expect(retryLocalhostSrc(src, 1, {address: ''})).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 index 9604ac61b41f..d1477b106d4f 100644 --- a/shared/common-adapters/localhost-src.tsx +++ b/shared/common-adapters/localhost-src.tsx @@ -6,14 +6,7 @@ export const isLocalhostSrc = (src: unknown): src is string => // 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. 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}`) - } +export const retryLocalhostSrc = (src: string, attempt: number, httpSrv: {address: string}) => { + const next = httpSrv.address ? src.replace(localhostPrefix, `http://${httpSrv.address}`) : src return `${next}${next.includes('?') ? '&' : '?'}kbRetry=${attempt}` } From 9fc4a734921d98744c4235bfa5f5f7a30a39c892 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 22:10:14 -0400 Subject: [PATCH 114/127] fix(chat): restore the token rewrite in retryLocalhostSrc A bare service-process restart keeps the port but mints a new per-process token, and a reconnect alone doesn't refetch already-rendered thread data, so a stale token= in a baked URL needs the same live rewrite as the address. --- shared/common-adapters/localhost-src.test.ts | 20 ++++++++++++++++++-- shared/common-adapters/localhost-src.tsx | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/shared/common-adapters/localhost-src.test.ts b/shared/common-adapters/localhost-src.test.ts index 08d5a5b42eeb..0f22e90ece6d 100644 --- a/shared/common-adapters/localhost-src.test.ts +++ b/shared/common-adapters/localhost-src.test.ts @@ -1,7 +1,7 @@ /// import {isLocalhostSrc, retryLocalhostSrc} from './localhost-src' -const httpSrv = {address: '127.0.0.1:61234'} +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) @@ -16,7 +16,23 @@ test('a retry points a baked attachment url at the current server port', () => { ) }) +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: ''})).toBe('http://127.0.0.1:5000/att?key=abc&kbRetry=1') + 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 index d1477b106d4f..390f5f24c8f0 100644 --- a/shared/common-adapters/localhost-src.tsx +++ b/shared/common-adapters/localhost-src.tsx @@ -4,9 +4,19 @@ 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. The +// 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}) => { - const next = httpSrv.address ? src.replace(localhostPrefix, `http://${httpSrv.address}`) : src +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}` } From d8a529aff55abd69cc0cc5cd04fa24bc52e85f7f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 22:23:51 -0400 Subject: [PATCH 115/127] refactor(push): ack a tap when navigation consumes it --- shared/constants/init/push-tap.test.ts | 108 +++++++------------ shared/constants/init/shared.tsx | 32 ++---- shared/router-v2/account-link-switch.test.ts | 22 ++++ shared/router-v2/account-link-switch.tsx | 3 + shared/router-v2/deep-link-emitter.tsx | 7 +- shared/router-v2/intent-consumption.test.ts | 32 ++++++ shared/router-v2/linking-initial-url.test.ts | 12 +++ shared/router-v2/linking.tsx | 5 +- shared/stores/navigation-intents.test.ts | 82 ++++++++++++++ shared/stores/navigation-intents.tsx | 36 ++++++- 10 files changed, 236 insertions(+), 103 deletions(-) diff --git a/shared/constants/init/push-tap.test.ts b/shared/constants/init/push-tap.test.ts index f4fc887818bb..7a0100b1822b 100644 --- a/shared/constants/init/push-tap.test.ts +++ b/shared/constants/init/push-tap.test.ts @@ -7,7 +7,7 @@ import {onEngineConnected, _onEngineIncoming} from './shared' const g = globalThis as unknown as {isMobile: boolean} -// shared.tsx remembers the last id it queued for the life of the module, so ids must not repeat +// 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 => ({ @@ -23,7 +23,9 @@ const nudge = () => } 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. +// retires it only if it is still the same tap. Nothing in constants/init calls the ack any more -- +// that happens only once navigation (or account-link-switch) consumes the intent -- so these tests +// exercise it only to prove that absence. 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 @@ -47,8 +49,8 @@ const serviceHolding = (route?: T.RPCGen.PushTapRoute) => { 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 ack -// that can throw. +// 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 => { @@ -103,7 +105,7 @@ afterEach(() => { resetAllStores() }) -test('the nudge queues the armed route and acks it', async () => { +test('the nudge queues the armed route and does not ack it', async () => { const route = chatRoute() const service = serviceHolding(route) @@ -111,8 +113,8 @@ test('the nudge queues the armed route and acks it', async () => { await settle() expect(service.peek).toHaveBeenCalledTimes(1) - expect(service.ack).toHaveBeenCalledWith({id: route.id}) - expect(service.isArmed()).toBe(false) + expect(service.ack).not.toHaveBeenCalled() + expect(service.isArmed()).toBe(true) expect(useNavigationIntentsState.getState().intent).toMatchObject({ targetUid: 'uid-other', url: 'keybase://convid/0000ab', @@ -129,11 +131,11 @@ test('a tap waiting from before this connection is taken on connect', async () = expect(service.peek).toHaveBeenCalledTimes(1) expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') - expect(service.isArmed()).toBe(false) + expect(service.ack).not.toHaveBeenCalled() }) -// The point of splitting peek from ack: a reply that never arrives must cost a repeat, not the -// tap. Nothing else in the app would say the tap had happened. +// 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) @@ -149,66 +151,12 @@ test('a peek whose reply is lost leaves the route armed for the next one', async await settle() expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') - expect(service.isArmed()).toBe(false) -}) - -// 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 only while the intent is still queued or inside its 1.5s -// duplicate window; once the router has navigated and that window has passed, re-queueing would -// navigate a second time. So the id is remembered here too, and only the ack is retried. -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) }) -// The id must be recorded only once the queue has taken the route. Recording it first would leave -// a throw here with the route armed AND marked as queued, so the next peek would skip the queue -// and ack anyway -- retiring a tap that never reached the router, which is the silent loss this -// whole split exists to prevent. -test('an enqueue that throws does not let the next peek retire the route', 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') - expect(service.ack).toHaveBeenCalledWith({id: route.id}) - expect(service.isArmed()).toBe(false) -}) - -// The nudge carries nothing on purpose: acting on it rather than on what the peek reports would -// be a second delivery path, and the pair could then act on one tap twice. -test('a second nudge after the ack queues nothing more', async () => { +// The store, not this layer, is what stops a repeat: nothing here retires the route on read any +// more, so a second peek of the same still-armed id must be turned away by the intent it already +// queued, never by anything drainPushTapRoute tracks itself. +test('a second peek of the same still-armed id does not enqueue a second intent', async () => { const service = serviceHolding(chatRoute()) nudge() @@ -232,8 +180,7 @@ test('a newer tap queued while the older one is still pending upgrades nothing a await settle() expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://devices') - expect(service.ack).toHaveBeenCalledWith({id: devices.id}) - expect(service.isArmed()).toBe(false) + expect(service.ack).not.toHaveBeenCalled() }) test('no waiting tap queues nothing', async () => { @@ -267,3 +214,24 @@ test('desktop never asks for a tap', async () => { 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.tsx b/shared/constants/init/shared.tsx index 5f016e5d6d32..e258667a17ac 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -234,21 +234,16 @@ export const applyMobileAppState = (state: T.RPCGen.MobileAppState) => { } } -// Peek, queue, ack. A tapped notification's route waits in the service until the ack says it has -// been queued, which is what makes a tap exactly-once. Reading it does not retire it: the peek's -// reply can be lost on the way here, and losing it would lose the tap with nothing anywhere to say -// so -- the app would simply open on the wrong screen. So queue first, then ack, and a peek that -// never came back leaves the route armed for the next one. +// 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. -// Ids number the taps of one service process, and on mobile the service is this process, so an id -// means nothing across a restart of either side. That is why the sentinel is 0, which the service -// never assigns, and why this is module state rather than anything durable: it must be forgotten -// exactly when the ids it refers to stop meaning anything. -let enqueuedPushTapID = 0 const drainPushTapRoute = async () => { if (!isMobile) { return @@ -258,22 +253,9 @@ const drainPushTapRoute = async () => { if (!route) { return } - // A repeat of a tap this run already queued means only that the ack did not land; re-queueing - // would navigate a second time, long after the intent store's own duplicate window has passed. - // A reload resets this, which is right: the intent store was reset with it. - if (route.id !== enqueuedPushTapID) { - // Recorded only once the queue actually took it. Recording first would mean a throw here - // left the route armed AND marked as queued, so the next peek would skip the queue and ack - // anyway -- retiring a tap that never reached the router, which is the loss this whole - // split exists to prevent. Both statements run before the await below, so two peeks in - // flight are still ordered by it. - enqueuePushTapRoute(route) - enqueuedPushTapID = route.id - } - await T.RPCGen.appStateAckPushTapRouteRpcPromise({id: route.id}) + enqueuePushTapRoute(route) } catch (error) { - // Nothing is lost by failing here: the route is retired only by an ack that arrived. - logger.warn('[PushTap] failed to drain a tap route, leaving it armed: ', error) + logger.warn('[PushTap] failed to peek a tap route, leaving it armed: ', error) } } diff --git a/shared/router-v2/account-link-switch.test.ts b/shared/router-v2/account-link-switch.test.ts index 1c346c698901..781c3ef2ce2a 100644 --- a/shared/router-v2/account-link-switch.test.ts +++ b/shared/router-v2/account-link-switch.test.ts @@ -1,4 +1,5 @@ /// +import * as T from '@/constants/types' import RPCError from '@/util/rpcerror' import {resetAllStores} from '@/util/zustand' import {subscribeIntentAccountSwitch} from './account-link-switch' @@ -43,6 +44,7 @@ beforeEach(() => { }) afterEach(() => { + jest.restoreAllMocks() unsub?.() unsub = undefined resetAllStores() @@ -85,6 +87,16 @@ test('a tap for an account without a stored secret is dropped', () => { 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 = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + + enqueuePushTapRoute({id: 6161, targetUID: noSecretAccount.uid, url: 'keybase://convid/0000ab'}) + + expect(ack).toHaveBeenCalledWith({id: 6161}) +}) + test('nothing switches before the handshake is done', () => { useDaemonState.setState({handshakeState: 'loading'}) tapFor(otherAccount.uid) @@ -105,6 +117,16 @@ test('a login error drops the tap', () => { expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) +test('a login error dropping the tap acks its route', () => { + const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + enqueuePushTapRoute({id: 6262, targetUID: otherAccount.uid, url: 'keybase://convid/0000ab'}) + expect(ack).not.toHaveBeenCalled() + + useConfigState.setState({loginError: new RPCError('bad', 1), userSwitching: false}) + + expect(ack).toHaveBeenCalledWith({id: 6262}) +}) + test('logging out drops a tap for another account', () => { useConfigState.setState({configuredAccounts: [], loggedIn: true}) tapFor(otherAccount.uid) diff --git a/shared/router-v2/account-link-switch.tsx b/shared/router-v2/account-link-switch.tsx index 9993d767b82e..f50289d52989 100644 --- a/shared/router-v2/account-link-switch.tsx +++ b/shared/router-v2/account-link-switch.tsx @@ -16,6 +16,9 @@ const tapForOtherAccount = () => { // 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 diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index 6a4a534662d4..5e4a746070d9 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -89,9 +89,12 @@ export const emitDeepLink = (url: string) => { // 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. -export const enqueuePushTapRoute = (route: {url: string; targetUID: string}) => { +// +// 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, {targetUid: route.targetUID || undefined}) + .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 ba2f7f122fda..d8331c1c1619 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -1,4 +1,5 @@ /// +import * as T from '@/constants/types' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' @@ -39,6 +40,37 @@ afterEach(() => { resetAllStores() }) +test('consuming an intent acks the tap route it carries', () => { + const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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', () => { const listener = jest.fn() const handleAppLink = jest.fn() diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts index 59bc14f9b184..065a3a3827da 100644 --- a/shared/router-v2/linking-initial-url.test.ts +++ b/shared/router-v2/linking-initial-url.test.ts @@ -49,6 +49,7 @@ beforeEach(() => { }) afterEach(() => { + jest.restoreAllMocks() handleAppLink.mockReset() // resetAllStores deliberately keeps account-targeted intents; drop them here. const {intent, dispatch} = useNavigationIntentsState.getState() @@ -101,6 +102,17 @@ test('a cold tap for the current account is the startup route, ahead of saved st expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) +test('getInitialURL taking a cold tap acks its route', async () => { + const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + setStartup({conversation: 'conv-1'}) + enqueuePushTapRoute({id: 5151, targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) + expect(ack).not.toHaveBeenCalled() + + await expect(getInitialURL()).resolves.toBe('keybase://convid/0000ab') + + expect(ack).toHaveBeenCalledWith({id: 5151}) +}) + test('a cold tap for another account opens saved state and waits for the switch', async () => { setStartup({conversation: 'conv-1'}) enqueuePushTapRoute({targetUID: 'other-uid', url: 'keybase://convid/0000ab'}) diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index cc6b0c53c114..b73e35c4d421 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -97,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 @@ -266,7 +268,8 @@ const customGetStateFromPath = ( // 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. +// 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) diff --git a/shared/stores/navigation-intents.test.ts b/shared/stores/navigation-intents.test.ts index 855bd3914ca8..4ae8b2fe1a6a 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' @@ -12,8 +13,14 @@ const clearIntent = () => { 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 +113,78 @@ 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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. +test('re-enqueuing an already-consumed tap id after its duplicate window has passed queues nothing', () => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockRejectedValue(new Error('disconnected')) + const dispatch = useNavigationIntentsState.getState().dispatch + const id = pushTapID() + dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) + dispatch.acknowledge(useNavigationIntentsState.getState().intent!.id) + + 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() +}) diff --git a/shared/stores/navigation-intents.tsx b/shared/stores/navigation-intents.tsx index b463700030b2..65b55c3220c4 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,15 +37,31 @@ type Store = { const duplicateWindowMs = 1500 +// 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. +const seenPushTapIDs = new Set() + +// Fires the ack once per id, regardless of how many times consumption is reported for it. +const ackPushTap = (pushTapID: number | undefined) => { + if (pushTapID === undefined || seenPushTapIDs.has(pushTapID)) return + seenPushTapIDs.add(pushTapID) + T.RPCGen.appStateAckPushTapRouteRpcPromise({id: pushTapID}).catch((error: unknown) => { + logger.warn('[PushTap] failed to ack a consumed tap route: ', error) + }) +} + export const useNavigationIntentsState = Z.createZustand( 'navigation-intents', (set, get) => { 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, @@ -49,11 +69,15 @@ 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 (pushTapID !== undefined && (pending?.pushTapID === pushTapID || seenPushTapIDs.has(pushTapID))) { + return + } if ( pending?.url === url && (!pending.targetUid || !targetUid || pending.targetUid === targetUid) @@ -82,15 +106,16 @@ export const useNavigationIntentsState = Z.createZustand( 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 } @@ -100,6 +125,7 @@ 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. From 88163ad870d513b2595d585f68f10b9f39526514 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 18 Sep 2026 22:41:13 -0400 Subject: [PATCH 116/127] fix(push): ack a tap id on every path it leaves the store Same-URL merges now adopt the newer tap id instead of stranding the old one; the duplicate-window drop, an outright supersede by another pending intent, and resetState discarding an unscoped intent all ack the route they give up. enqueuePushTapRoute's route id is required, and a redelivery of an already-consumed id retries the ack RPC instead of being silently ignored. --- shared/constants/init/push-tap.test.ts | 55 +++++++++--- shared/router-v2/account-link-switch.test.ts | 21 +++-- shared/router-v2/deep-link-emitter.test.ts | 16 +++- shared/router-v2/deep-link-emitter.tsx | 2 +- shared/router-v2/intent-consumption.test.ts | 7 +- shared/router-v2/linking-initial-url.test.ts | 20 +++-- shared/router-v2/linking.test.ts | 13 ++- shared/stores/navigation-intents.test.ts | 91 +++++++++++++++++++- shared/stores/navigation-intents.tsx | 64 ++++++++++++-- 9 files changed, 244 insertions(+), 45 deletions(-) diff --git a/shared/constants/init/push-tap.test.ts b/shared/constants/init/push-tap.test.ts index 7a0100b1822b..b0be49f5f06e 100644 --- a/shared/constants/init/push-tap.test.ts +++ b/shared/constants/init/push-tap.test.ts @@ -23,9 +23,9 @@ const nudge = () => } 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. Nothing in constants/init calls the ack any more -- -// that happens only once navigation (or account-link-switch) consumes the intent -- so these tests -// exercise it only to prove that absence. +// 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 @@ -98,14 +98,16 @@ beforeEach(() => { afterEach(() => { g.isMobile = false - jest.restoreAllMocks() 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 and does not ack it', async () => { +test('the nudge queues the armed route without acking it', async () => { const route = chatRoute() const service = serviceHolding(route) @@ -153,9 +155,9 @@ test('a peek whose reply is lost leaves the route armed for the next one', async expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://convid/0000ab') }) -// The store, not this layer, is what stops a repeat: nothing here retires the route on read any -// more, so a second peek of the same still-armed id must be turned away by the intent it already -// queued, never by anything drainPushTapRoute tracks itself. +// 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()) @@ -169,8 +171,12 @@ test('a second peek of the same still-armed id does not enqueue a second intent' 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 service = serviceHolding(chatRoute()) + const route = chatRoute() + const service = serviceHolding(route) nudge() await settle() @@ -180,7 +186,36 @@ test('a newer tap queued while the older one is still pending upgrades nothing a await settle() expect(useNavigationIntentsState.getState().intent?.url).toBe('keybase://devices') - expect(service.ack).not.toHaveBeenCalled() + 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 () => { diff --git a/shared/router-v2/account-link-switch.test.ts b/shared/router-v2/account-link-switch.test.ts index 781c3ef2ce2a..7f7256ea8636 100644 --- a/shared/router-v2/account-link-switch.test.ts +++ b/shared/router-v2/account-link-switch.test.ts @@ -14,8 +14,12 @@ const otherAccount = {hasStoredSecret: true, uid: 'uid-other', username: 'testus 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 is mocked below so a leftover, still-pending intent from a +// previous test can be acknowledged in cleanup without an unmocked RPC call. +let nextTapID = 9000 const tapFor = (uid: string) => - enqueuePushTapRoute({targetUID: uid, url: 'keybase://convid/0000ab'}) + enqueuePushTapRoute({id: ++nextTapID, targetUID: uid, url: 'keybase://convid/0000ab'}) let login = jest.fn() let unsub: (() => void) | undefined @@ -25,6 +29,7 @@ const setAccounts = (configuredAccounts: typeof allAccounts) => { } beforeEach(() => { + jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) login = jest.fn() // navigation-intents' resetState deliberately keeps account-targeted intents. const {intent, dispatch} = useNavigationIntentsState.getState() @@ -90,11 +95,12 @@ test('a tap for an account without a stored secret is dropped', () => { // 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 = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const id = ++nextTapID - enqueuePushTapRoute({id: 6161, targetUID: noSecretAccount.uid, url: 'keybase://convid/0000ab'}) + enqueuePushTapRoute({id, targetUID: noSecretAccount.uid, url: 'keybase://convid/0000ab'}) - expect(ack).toHaveBeenCalledWith({id: 6161}) + expect(ack).toHaveBeenCalledWith({id}) }) test('nothing switches before the handshake is done', () => { @@ -118,13 +124,14 @@ test('a login error drops the tap', () => { }) test('a login error dropping the tap acks its route', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) - enqueuePushTapRoute({id: 6262, targetUID: otherAccount.uid, url: 'keybase://convid/0000ab'}) + 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: 6262}) + expect(ack).toHaveBeenCalledWith({id}) }) test('logging out drops a tap for another account', () => { diff --git a/shared/router-v2/deep-link-emitter.test.ts b/shared/router-v2/deep-link-emitter.test.ts index 486961befd43..8a25a45a35a0 100644 --- a/shared/router-v2/deep-link-emitter.test.ts +++ b/shared/router-v2/deep-link-emitter.test.ts @@ -1,7 +1,12 @@ /// +import * as T from '@/constants/types' import {useNavigationIntentsState} from '@/stores/navigation-intents' 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() if (intent) { @@ -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', () => { @@ -64,7 +74,7 @@ test('a foreign link never targets an account', () => { }) test('a tap targets its account', () => { - enqueuePushTapRoute({targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) const {intent} = useNavigationIntentsState.getState() expect(intent?.url).toBe('keybase://convid/0000ab') @@ -73,7 +83,7 @@ test('a tap targets its account', () => { test('a tap for a link a foreign open already queued upgrades that intent', () => { emitDeepLink('keybase://convid/0000ab') - enqueuePushTapRoute({targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'uid-other', url: 'keybase://convid/0000ab'}) expect(useNavigationIntentsState.getState().intent?.targetUid).toBe('uid-other') }) @@ -81,7 +91,7 @@ test('a tap for a link a foreign open already queued upgrades that intent', () = // 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({targetUID: '', url: 'keybase://tabs.peopleTab'}) + enqueuePushTapRoute({id: tapID(), targetUID: '', url: 'keybase://tabs.peopleTab'}) const {intent} = useNavigationIntentsState.getState() expect(intent?.url).toBe('keybase://tabs.peopleTab') diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index 5e4a746070d9..c54e38365def 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -92,7 +92,7 @@ export const emitDeepLink = (url: string) => { // // 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}) => { +export const enqueuePushTapRoute = (route: {url: string; targetUID: string; id: number}) => { logger.info('[PushTap] queued a tap link:', route.url) useNavigationIntentsState .getState() diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index d8331c1c1619..8013ea0d3352 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -28,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') @@ -41,7 +42,7 @@ afterEach(() => { }) test('consuming an intent acks the tap route it carries', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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. @@ -55,7 +56,7 @@ test('consuming an intent acks the tap route it carries', () => { }) test('a stale intent that is dropped without navigating still acks its tap route', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock const now = jest.spyOn(Date, 'now') now.mockReturnValue(1_000) useConfigState.getState().dispatch.setUserSwitching(true) @@ -171,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) - enqueuePushTapRoute({targetUID: 'target-uid', url: 'keybase://convid/switch-target-conversation'}) + 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 065a3a3827da..a25bfe617883 100644 --- a/shared/router-v2/linking-initial-url.test.ts +++ b/shared/router-v2/linking-initial-url.test.ts @@ -43,17 +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(() => { - jest.restoreAllMocks() 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() }) @@ -96,26 +101,27 @@ test('a conversation persisted by this account is kept', async () => { test('a cold tap for the current account is the startup route, ahead of saved state', async () => { setStartup({conversation: 'conv-1'}) - enqueuePushTapRoute({targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) await expect(getInitialURL()).resolves.toBe('keybase://convid/0000ab') expect(useNavigationIntentsState.getState().intent).toBeUndefined() }) test('getInitialURL taking a cold tap acks its route', async () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + const ack = T.RPCGen.appStateAckPushTapRouteRpcPromise as jest.Mock + const id = tapID() setStartup({conversation: 'conv-1'}) - enqueuePushTapRoute({id: 5151, targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) + 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: 5151}) + 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({targetUID: 'other-uid', url: 'keybase://convid/0000ab'}) + 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') @@ -176,7 +182,7 @@ test('the returned initial url is recorded so the same deep link is not re-enque test('a queued tap older than the intent lifetime is not the startup route', async () => { setStartup({conversation: 'conv-1'}) - enqueuePushTapRoute({targetUID: 'current-uid', url: 'keybase://convid/0000ab'}) + 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}}) diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts index bba077d87da1..950b35e02542 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -1,4 +1,5 @@ /// +import * as T from '@/constants/types' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' @@ -16,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) { @@ -25,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') @@ -32,6 +38,7 @@ beforeEach(() => { afterEach(() => { clearIntent() + jest.restoreAllMocks() }) test('waits for navigation readiness before consuming an intent', () => { @@ -66,7 +73,7 @@ test('waits until the intended account is active', () => { const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - enqueuePushTapRoute({targetUID: 'target-uid', url: 'keybase://convid/target-account-conversation'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'target-uid', url: 'keybase://convid/target-account-conversation'}) expect(listener).not.toHaveBeenCalled() setCurrentUser('target-uid') @@ -86,7 +93,7 @@ test('waits for an account switch to finish', () => { const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - enqueuePushTapRoute({targetUID: 'current-uid', url: 'keybase://convid/account-switch-conversation'}) + enqueuePushTapRoute({id: tapID(), targetUID: 'current-uid', url: 'keybase://convid/account-switch-conversation'}) expect(listener).not.toHaveBeenCalled() useConfigState.getState().dispatch.setUserSwitching(false) @@ -102,7 +109,7 @@ test('waits for the replacement router after the current account changes', () => const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - enqueuePushTapRoute({targetUID: 'target-uid', url: 'keybase://convid/replacement-router-conversation'}) + 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. diff --git a/shared/stores/navigation-intents.test.ts b/shared/stores/navigation-intents.test.ts index 4ae8b2fe1a6a..ee003b08fb23 100644 --- a/shared/stores/navigation-intents.test.ts +++ b/shared/stores/navigation-intents.test.ts @@ -174,17 +174,102 @@ test('re-enqueuing a still-pending tap id does not replace or duplicate the inte }) // 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. -test('re-enqueuing an already-consumed tap id after its duplicate window has passed queues nothing', () => { - jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockRejectedValue(new Error('disconnected')) +// 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) + 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 65b55c3220c4..0db269e15a8f 100644 --- a/shared/stores/navigation-intents.tsx +++ b/shared/stores/navigation-intents.tsx @@ -42,17 +42,28 @@ const duplicateWindowMs = 1500 // 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() -// Fires the ack once per id, regardless of how many times consumption is reported for it. -const ackPushTap = (pushTapID: number | undefined) => { - if (pushTapID === undefined || seenPushTapIDs.has(pushTapID)) return - seenPushTapIDs.add(pushTapID) +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', (set, get) => { @@ -75,22 +86,45 @@ export const useNavigationIntentsState = Z.createZustand( const now = Date.now() const {pushTapID, targetUid} = options ?? {} const {intent: pending, lastHandledIntent} = get() - if (pushTapID !== undefined && (pending?.pushTapID === pushTapID || seenPushTapIDs.has(pushTapID))) { - return + + 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) ) { - if (!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. @@ -99,8 +133,17 @@ export const useNavigationIntentsState = Z.createZustand( now - lastHandledIntent.handledAt < duplicateWindowMs && (!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 = { @@ -130,6 +173,8 @@ export const useNavigationIntentsState = Z.createZustand( // 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 @@ -138,6 +183,9 @@ export const useNavigationIntentsState = Z.createZustand( s.navigationReady = false s.navigationReadyForUid = undefined }) + if (discarding) { + ackPushTap(intent?.pushTapID) + } }, setNavigationReady: (ready, uid) => { set(s => { From ca97ddcebdf3e89416f1667e91dbbb11f63c89c3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 08:38:55 -0400 Subject: [PATCH 117/127] refactor(push): Go opens the push window and skips only the display while active --- go/bind/keybase.go | 36 ++++---- go/bind/notifications.go | 74 ++++++++++----- go/bind/notifications_test.go | 89 +++++++++++++++++++ go/libkb/lifecycle/lifecycletest/harness.go | 19 ++-- go/libkb/lifecycle/lifecycletest/scenarios.go | 30 ++++++- .../keybase/ossifrage/AppLifecycleReporter.kt | 42 ++------- .../ossifrage/ChatBroadcastReceiver.kt | 7 +- .../keybase/ossifrage/KeybaseLifecycleBind.kt | 4 - .../KeybasePushNotificationListenerService.kt | 45 ++++------ .../ossifrage/AppLifecycleReporterTest.kt | 77 ++-------------- shared/ios/Keybase/AppDelegate.swift | 2 +- 11 files changed, 237 insertions(+), 188 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 23a36bc8c69d..4d972ff72433 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -1051,26 +1051,32 @@ func AppUIBackground(pusher PushNotifier) int64 { return kbCtx.MobileLifecycle.UIBackground(shouldStayRunningInBackground(), backgroundTaskDeps(pusher)) } -// AppPushWindowBegin holds the app up while a push notification is handled, -// unless the app is active. It returns a token for AppPushWindowEnd: positive -// when the hold opened, 0 when the app is active (skip the work), and -1 when -// the service isn't initialized (no hold, but the work may still run). -func AppPushWindowBegin() int64 { +// inPushWindow runs work, which handles a push or a notification action, +// holding a backgrounded app up while it runs. 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 -1 + return work(false) } - defer kbCtx.Trace("AppPushWindowBegin", nil)() - return kbCtx.MobileLifecycle.PushWindowBegin() + return runPushWindow(kbCtx.MobileLifecycle, runtime.GOOS, shouldStayRunningInBackground, + backgroundTaskDeps(pusher), work) } -// AppPushWindowEnd ends the hold opened by AppPushWindowBegin, first starting -// a background task when work must keep running. -func AppPushWindowEnd(token int64, pusher PushNotifier) { - if !isInited() { - return +func runPushWindow(lc *lifecycle.Controller, goos string, stay func() bool, deps lifecycle.BackgroundTaskDeps, + work func(uiActive bool) error, +) error { + token := lc.PushWindowBegin() + if token == 0 { + return work(true) } - defer kbCtx.Trace("AppPushWindowEnd", nil)() - kbCtx.MobileLifecycle.PushWindowEnd(token, shouldStayRunningInBackground(), backgroundTaskDeps(pusher)) + defer func() { + // iOS suspends the app once native calls the push's completion handler, + // right after this returns, so a background task started here would + // leave it suspended in BACKGROUNDACTIVE. + lc.PushWindowEnd(token, goos == "android" && stay(), deps) + }() + return work(false) } // AppWaitBackgroundTask returns once the background task whose token diff --git a/go/bind/notifications.go b/go/bind/notifications.go index 3298c263805a..5bd044760519 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -114,11 +114,15 @@ 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) }() - return postTextReply(ctx, globals.NewContext(kbCtx, kbChatCtx), strConvID, tlfName, intMessageID, body) + 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 @@ -157,10 +161,15 @@ func postTextReply(ctx context.Context, gc *globals.Context, strConvID, tlfName 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, 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. While the UI is active it only acks: the app already shows the +// message. It reports whether the push was a duplicate. +func displayOnce(dupKey string, n *ChatNotification, pusher PushNotifier, 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 { + pusher.DisplayChatNotification(n) + } + ack() + return false +} diff --git a/go/bind/notifications_test.go b/go/bind/notifications_test.go index 83b5a07679e9..227f3ee5f281 100644 --- a/go/bind/notifications_test.go +++ b/go/bind/notifications_test.go @@ -8,6 +8,8 @@ import ( "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" @@ -98,3 +100,90 @@ func TestPostTextReply(t *testing.T) { 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{ + 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 + ) + stay := func() bool { return true } + 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() + require.Zero(t, h.Controller.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + require.Equal(t, bg, h.AppState.State()) + + unboxFailed := errors.New("unbox failed") + var during keybase1.MobileAppState + err := runPushWindow(h.Controller, platform.String(), stay, pendingDeliveryDeps(), func(uiActive bool) error { + require.False(t, uiActive) + during = h.AppState.State() + return unboxFailed + }) + require.ErrorIs(t, err, unboxFailed) + require.Equal(t, bga, during, "the push is handled in BACKGROUNDACTIVE") + if platform == lifecycletest.IOS { + require.Equal(t, bg, h.AppState.State(), "iOS suspends at the completion handler; nothing stays up") + } else { + 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(), stay, pendingDeliveryDeps(), func(uiActive bool) error { + require.True(t, uiActive) + 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) { + pusher := &recordingPusher{} + acks := 0 + ack := func() { acks++ } + show := func(convID string, uiActive bool) bool { + return displayOnce(convID+"||1", &ChatNotification{ConvID: convID}, pusher, uiActive, ack) + } + require.False(t, show(t.Name()+"active", true)) + require.Empty(t, pusher.displayed, "the app already shows the message") + require.Equal(t, 1, acks, "the push is acked so the server's fallback doesn't show it") + + require.True(t, show(t.Name()+"active", false), "a push handled while active isn't shown later") + require.Empty(t, pusher.displayed) + require.Equal(t, 2, acks) + + require.False(t, show(t.Name()+"background", false)) + require.Equal(t, []string{t.Name() + "background"}, pusher.displayed) + require.Equal(t, 3, acks) +} diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 3bdef60b4808..b9c22d9467f4 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -42,16 +42,16 @@ func (p Platform) InitialState() keybase1.MobileAppState { type Action int const ( - // Nothing reports no event, as when a silent push launches the app - // without a scene, or an Android dialog, permission prompt or picker - // pauses the activity. + // 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. When DidEnterBackground or - // PushWindowEnd starts a background task, they wait until it is polling - // and return true. + // didEnterBackground is UIBackground. PushWindowBegin and PushWindowEnd + // bracket a push or notification action, as the bind layer handles one. + // When DidEnterBackground or PushWindowEnd starts a background task, they + // wait until it is polling and return true. WillEnterForeground DidBecomeActive WillResignActive @@ -156,6 +156,7 @@ type Scenario struct { // records what consumers of the app state observe. type Harness struct { T testing.TB + Platform Platform AppState lifecycle.AppState Clock *FakeClock Controller *lifecycle.Controller @@ -186,6 +187,7 @@ func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *H appState.Update(platform.InitialState()) h := &Harness{ T: t, + Platform: platform, AppState: appState, Clock: NewFakeClock(), failures: make(chan []chat1.OutboxRecord, 1), @@ -302,7 +304,10 @@ func (h *Harness) perform(step Step) bool { h.tokens[step.Slot] = c.PushWindowBegin() return h.tokens[step.Slot] > 0 case PushWindowEnd: - return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], h.stay.Load(), h.deps()) }) + // The bind layer never hands a push window over to a background task + // on iOS. + stay := h.Platform == Android && h.stay.Load() + return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], stay, h.deps()) }) case LiveLocationAcquire: h.liveLocation = c.AcquireBackgroundWork() case LiveLocationRelease: diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 38d01f25f1de..ab354e4464b2 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -68,11 +68,37 @@ var iosToBackgroundTask = []Step{ var Scenarios = []Scenario{ {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, { - Name: "ios background launch by silent push stays in the background, then foreground", + Name: "ios background launch by silent push holds the app up for the push, then foreground", Platform: IOS, - Steps: steps([]Step{step(Nothing, bg), step(BackgroundTaskExpired, bg)}, toForeground), + Steps: steps([]Step{ + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + step(BackgroundTaskExpired, bg), + }, toForeground), + Observed: states(bg, bga, 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).flush().returns(false), + step(WorkStarts, bg), + step(PushWindowBegin, bga).returns(true), + step(PushWindowEnd, bg).flush().returns(false), + }), + Observed: states(bg, ina, fg, ina, bg, bga, bg), + }, { Name: "ios background launch by BGAppRefresh, then foreground", Platform: IOS, 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 index 982d8f64dba4..0187f6e9d080 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -14,8 +14,6 @@ internal interface LifecycleBind { fun uiInactive() fun uiBackground() fun willExit() - fun pushWindowBegin(): Long - fun pushWindowEnd(token: Long) } internal interface LifecycleExecutor { @@ -106,42 +104,12 @@ internal class AppLifecycleReporter( } } -internal enum class InForeground { SKIP, RUN } - -// Runs task in a push window: Go stays up in BACKGROUNDACTIVE while it runs. -// When the app is in the foreground no window opens (Go is already up), and -// the task runs or is skipped per inForeground. Returns whether it ran. -internal fun runPushWindow(bind: LifecycleBind, log: (String) -> Unit, inForeground: InForeground, task: () -> Unit): Boolean { - val token = bind.pushWindowBegin() - if (token == 0L) { - if (inForeground == InForeground.SKIP) { - log("runPushWindow: app is in the foreground, skipping") - return false - } - task() - return true - } - try { - task() - } finally { - // Negative: Go isn't initialized, so no window opened. - if (token > 0) { - bind.pushWindowEnd(token) - } - } - return true -} - -// Sends a notification quick reply, which must go out even with the app in -// the foreground. Returns the text for the replied notification. -internal fun sendQuickReply( - bind: LifecycleBind, - info: (String) -> Unit, - error: (String, Throwable) -> Unit, - send: () -> Unit, -): String = +// Sends a notification quick reply. Returns the text for the replied +// notification. +internal fun sendQuickReply(error: (String, Throwable) -> Unit, send: () -> Unit): String = try { - if (runPushWindow(bind, info, InForeground.RUN, send)) QUICK_REPLY_SENT else QUICK_REPLY_FAILED + send() + QUICK_REPLY_SENT } catch (e: Exception) { error("Failed to send quick reply", e) QUICK_REPLY_FAILED 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 6618f078a6f6..716a3a07350d 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 @@ -32,9 +32,12 @@ class ChatBroadcastReceiver : BroadcastReceiver() { setupKBRuntime(context, false) val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter lifecycleReporter.reportHeadlessStart() + // Go's push window must see the state after the process start + // or stop that came before this reply. lifecycleReporter.awaitReported(2000) - sendQuickReply(KeybaseLifecycleBind(context), { NativeLogger.info(it) }, { msg, e -> NativeLogger.error(msg, e) }) { - Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody) + sendQuickReply({ msg, e -> NativeLogger.error(msg, e) }) { + Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody, + KBPushNotifier(context, Bundle())) } } val repliedNotification = NotificationCompat.Builder(context, KeybasePushNotificationListenerService.CHAT_CHANNEL_ID) 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 index cf60f64fda1e..34f0a34e8ca1 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybaseLifecycleBind.kt @@ -14,8 +14,4 @@ internal class KeybaseLifecycleBind(private val context: Context) : LifecycleBin } override fun willExit() = Keybase.appWillExit(KBPushNotifier(context, Bundle())) - - override fun pushWindowBegin(): Long = Keybase.appPushWindowBegin() - - override fun pushWindowEnd(token: Long) = Keybase.appPushWindowEnd(token, 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 6d218d2c0b1e..f546a9daeabd 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 @@ -19,8 +19,12 @@ 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, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > SEEN_CHAT_NOTIFICATIONS_MAX + } private fun isOtherAccountPushError(ex: Exception): Boolean { return ex.message?.contains("different account") == true } @@ -82,12 +86,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") } @@ -104,34 +108,22 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { var goProcessingSucceeded = false try { - // The push window must see the state after the process + // Go's push window must see the state after the process // start or stop that came before this push. lifecycleReporter.awaitReported(5000) - // In the foreground the app already has the message, and - // must not show a notification for it. - runPushWindow(KeybaseLifecycleBind(applicationContext), { NativeLogger.info(it) }, InForeground.SKIP) { - 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 - } 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 - } - } + // 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 } @@ -229,6 +221,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" 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 index c49dcce94adc..25fd0af6a232 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -12,12 +12,10 @@ import java.util.concurrent.atomic.AtomicReference import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Test private class FakeBind : LifecycleBind { val calls: MutableList = Collections.synchronizedList(mutableListOf()) - var token = 7L var onUiBackground: () -> Unit = {} override fun uiActive() { @@ -36,15 +34,6 @@ private class FakeBind : LifecycleBind { override fun willExit() { calls.add("willExit") } - - override fun pushWindowBegin(): Long { - calls.add("pushWindowBegin") - return token - } - - override fun pushWindowEnd(token: Long) { - calls.add("pushWindowEnd($token)") - } } // Runs nothing until told to, so tests see what was queued and in what order. @@ -221,80 +210,24 @@ class AppLifecycleReporterTest { } } -class RunPushWindowTest { - private val bind = FakeBind() - - private fun run(inForeground: InForeground = InForeground.SKIP, task: () -> Unit = { bind.calls.add("task") }) = - runPushWindow(bind, {}, inForeground, task) - - @Test - fun foregroundSkipsTheTask() { - bind.token = 0 - assertFalse(run()) - assertEquals(listOf("pushWindowBegin"), bind.calls) - } - - @Test - fun foregroundRunsATaskThatMustRunWithoutAWindow() { - bind.token = 0 - assertTrue(run(InForeground.RUN)) - assertEquals(listOf("pushWindowBegin", "task"), bind.calls) - } - - @Test - fun notInitializedRunsTheTaskWithoutAWindow() { - bind.token = -1 - assertTrue(run()) - assertEquals(listOf("pushWindowBegin", "task"), bind.calls) - } - - @Test - fun windowEndsAfterTheTask() { - assertTrue(run()) - assertEquals(listOf("pushWindowBegin", "task", "pushWindowEnd(7)"), bind.calls) - } - - @Test - fun windowEndsWhenTheTaskThrows() { - try { - run { throw IllegalStateException("boom") } - fail("the task's exception propagates") - } catch (e: IllegalStateException) { - assertEquals("boom", e.message) - } - assertEquals(listOf("pushWindowBegin", "pushWindowEnd(7)"), bind.calls) - } -} - class SendQuickReplyTest { - private val bind = FakeBind() - private val infos = mutableListOf() private val errors = mutableListOf>() - private fun send(send: () -> Unit = { bind.calls.add("send") }) = - sendQuickReply(bind, { infos.add(it) }, { msg, e -> errors.add(msg to e) }, send) + private fun send(send: () -> Unit) = sendQuickReply({ msg, e -> errors.add(msg to e) }, send) @Test - fun foregroundReplySends() { - bind.token = 0 - assertEquals(QUICK_REPLY_SENT, send()) - assertEquals(listOf("pushWindowBegin", "send"), bind.calls) + fun replySends() { + var sent = false + assertEquals(QUICK_REPLY_SENT, send { sent = true }) + assertTrue(sent) assertTrue(errors.isEmpty()) } - @Test - fun backgroundReplySendsInAWindow() { - assertEquals(QUICK_REPLY_SENT, send()) - assertEquals(listOf("pushWindowBegin", "send", "pushWindowEnd(7)"), bind.calls) - } - @Test fun failedReplyIsNotReportedAsRepliedAndLogsTheException() { val failure = IllegalStateException("outbox full") assertEquals(QUICK_REPLY_FAILED, send { throw failure }) - assertEquals(listOf("pushWindowBegin", "pushWindowEnd(7)"), bind.calls) assertEquals(listOf("Failed to send quick reply" to failure), errors.toList()) - assertTrue(infos.isEmpty()) } } diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 26b5065a74ad..2c36a8eb42de 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -331,7 +331,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi 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...") From b79089282858e5e416b6dea123a25354213f9058 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 08:44:19 -0400 Subject: [PATCH 118/127] fix(push): skip the active push display on Android only --- go/bind/notifications.go | 19 +++++--- go/bind/notifications_test.go | 45 ++++++++++++------- .../KeybasePushNotificationListenerService.kt | 2 +- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/go/bind/notifications.go b/go/bind/notifications.go index 5bd044760519..5b73d314171b 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -162,8 +162,9 @@ func postTextReply(ctx context.Context, gc *globals.Context, strConvID, tlfName var spoileRegexp = regexp.MustCompile(`!>(.*?)(16, 0.75f, true) { + 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 { From fca7b31fca892f396d7a292ff570a82615f93f2a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 09:17:26 -0400 Subject: [PATCH 119/127] refactor(lifecycle): every lifecycle call is O(1) so native calls Go synchronously UIBackground always opens a background-task hold and returns its token; the task asks deps.Stay first (off the controller's lock) and releases at once when nothing must keep running, so backgrounding passes through BACKGROUNDACTIVE. PushWindowEnd takes an allowTask platform gate instead of a pre-evaluated stay. An open background-task hold is reused by a later start (duplicate didEnterBackground, a push window's end), so one task warns about failures and keeps its maximum duration. The polling loop checks the maximum duration on every tick, even when the outbox query fails, and needs empty polls in a row. iOS and Android call Go's lifecycle entry points synchronously; the iOS serial queue and the Android executor and awaitReported are gone. --- go/bind/keybase.go | 16 +- go/bind/location_test.go | 4 +- go/bind/notifications_test.go | 16 +- go/chat/maps/livelocation_appstate_test.go | 10 +- go/chat/maps/livelocation_watch_test.go | 6 +- go/libkb/appstate.go | 10 +- go/libkb/lifecycle/controller_test.go | 156 ++++++++++++++++-- go/libkb/lifecycle/lifecycle.go | 91 ++++++---- go/libkb/lifecycle/lifecycletest/harness.go | 70 ++++++-- go/libkb/lifecycle/lifecycletest/scenarios.go | 77 ++++++--- go/libkb/lifecycle/scenario_test.go | 5 +- go/service/notify_test.go | 4 +- .../keybase/ossifrage/AppLifecycleReporter.kt | 56 ++----- .../ossifrage/ChatBroadcastReceiver.kt | 3 - .../KeybasePushNotificationListenerService.kt | 3 - .../io/keybase/ossifrage/MainApplication.kt | 2 +- .../ossifrage/AppLifecycleReporterTest.kt | 105 +++--------- shared/ios/Keybase/AppDelegate.swift | 43 +++-- 18 files changed, 406 insertions(+), 271 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 4d972ff72433..165db7193068 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -1040,15 +1040,15 @@ func shouldStayRunningInBackground() bool { return false } -// AppUIBackground reports the app off screen. When work must keep running it -// starts a background task and returns its token for AppWaitBackgroundTask, -// 0 otherwise. +// 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; 0 before Init. func AppUIBackground(pusher PushNotifier) int64 { if !isInited() { return 0 } defer kbCtx.Trace("AppUIBackground", nil)() - return kbCtx.MobileLifecycle.UIBackground(shouldStayRunningInBackground(), backgroundTaskDeps(pusher)) + return kbCtx.MobileLifecycle.UIBackground(backgroundTaskDeps(pusher)) } // inPushWindow runs work, which handles a push or a notification action, @@ -1059,11 +1059,10 @@ func inPushWindow(pusher PushNotifier, work func(uiActive bool) error) error { if !isInited() { return work(false) } - return runPushWindow(kbCtx.MobileLifecycle, runtime.GOOS, shouldStayRunningInBackground, - backgroundTaskDeps(pusher), work) + return runPushWindow(kbCtx.MobileLifecycle, runtime.GOOS, backgroundTaskDeps(pusher), work) } -func runPushWindow(lc *lifecycle.Controller, goos string, stay func() bool, deps lifecycle.BackgroundTaskDeps, +func runPushWindow(lc *lifecycle.Controller, goos string, deps lifecycle.BackgroundTaskDeps, work func(uiActive bool) error, ) error { token := lc.PushWindowBegin() @@ -1074,7 +1073,7 @@ func runPushWindow(lc *lifecycle.Controller, goos string, stay func() bool, deps // iOS suspends the app once native calls the push's completion handler, // right after this returns, so a background task started here would // leave it suspended in BACKGROUNDACTIVE. - lc.PushWindowEnd(token, goos == "android" && stay(), deps) + lc.PushWindowEnd(token, goos == "android", deps) }() return work(false) } @@ -1091,6 +1090,7 @@ func AppWaitBackgroundTask(token int64) { 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/location_test.go b/go/bind/location_test.go index 37a021bf5d95..5608d8835b17 100644 --- a/go/bind/location_test.go +++ b/go/bind/location_test.go @@ -11,7 +11,7 @@ import ( "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" + "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" @@ -42,7 +42,7 @@ func TestLocationUpdateReachesTrackers(t *testing.T) { tracker.SetClock(clock) tracker.TestingCoordsAddedCh = make(chan struct{}, 10) ctx := context.Background() - require.Zero(t, tc.G.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + lifecycletest.ToBackground(tc.G.MobileLifecycle) tracker.StartTracking(ctx, chat1.ConversationID("conv"), 1, clock.Now().Add(time.Hour)) select { diff --git a/go/bind/notifications_test.go b/go/bind/notifications_test.go index 00ff38e96010..0ce66e7c740d 100644 --- a/go/bind/notifications_test.go +++ b/go/bind/notifications_test.go @@ -3,7 +3,9 @@ package keybase import ( "context" "errors" + "fmt" "testing" + "time" "github.com/keybase/client/go/chat/globals" "github.com/keybase/client/go/chat/types" @@ -105,6 +107,7 @@ func TestPostTextReply(t *testing.T) { // 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 }, @@ -119,19 +122,18 @@ func TestBackgroundNotificationOpensAndClosesPushWindow(t *testing.T) { bg = keybase1.MobileAppState_BACKGROUND bga = keybase1.MobileAppState_BACKGROUNDACTIVE ) - stay := func() bool { return true } 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() - require.Zero(t, h.Controller.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + lifecycletest.ToBackground(h.Controller) require.Equal(t, bg, h.AppState.State()) unboxFailed := errors.New("unbox failed") var during keybase1.MobileAppState - err := runPushWindow(h.Controller, platform.String(), stay, pendingDeliveryDeps(), func(uiActive bool) error { + err := runPushWindow(h.Controller, platform.String(), pendingDeliveryDeps(), func(uiActive bool) error { require.False(t, uiActive) during = h.AppState.State() return unboxFailed @@ -148,7 +150,7 @@ func TestBackgroundNotificationOpensAndClosesPushWindow(t *testing.T) { h.Controller.UIActive() ran := false - require.NoError(t, runPushWindow(h.Controller, platform.String(), stay, pendingDeliveryDeps(), func(uiActive bool) error { + require.NoError(t, runPushWindow(h.Controller, platform.String(), pendingDeliveryDeps(), func(uiActive bool) error { require.True(t, uiActive) ran = true return nil @@ -177,7 +179,9 @@ func TestBackgroundNotificationActiveSkipsDisplayButAcks(t *testing.T) { show := func(convID string, uiActive bool) bool { return displayOnce(convID+"||1", &ChatNotification{ConvID: convID}, pusher, goos, uiActive, ack) } - active := t.Name() + "active" + // 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") @@ -192,7 +196,7 @@ func TestBackgroundNotificationActiveSkipsDisplayButAcks(t *testing.T) { require.Len(t, pusher.displayed, displayed) require.Equal(t, 2, acks) - background := t.Name() + "background" + 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) diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go index a1ed20e0b745..843a5eed5e35 100644 --- a/go/chat/maps/livelocation_appstate_test.go +++ b/go/chat/maps/livelocation_appstate_test.go @@ -9,7 +9,7 @@ import ( "github.com/keybase/client/go/protocol/chat1" "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/keybase1" "github.com/stretchr/testify/require" ) @@ -36,7 +36,7 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { } lc := tc.G.MobileLifecycle - require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + 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") @@ -55,7 +55,7 @@ func TestLiveLocationTrackerBackgroundActive(t *testing.T) { third := addTracker(3) l.LocationUpdate(ctx, coord(3)) require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) - require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + lifecycletest.ToBackground(lc) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) removeTracker(third) require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) @@ -78,7 +78,7 @@ func TestLiveLocationTrackerHoldSurvivesWillTerminate(t *testing.T) { l.Unlock() lc := tc.G.MobileLifecycle - require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + lifecycletest.ToBackground(lc) l.LocationUpdate(ctx, coord(1)) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) @@ -108,7 +108,7 @@ func TestRestoredTrackersReleaseHoldWhenEmpty(t *testing.T) { l.Unlock() lc := tc.G.MobileLifecycle - require.Zero(t, lc.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + 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") diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go index 483c8efef446..479f26ced6cc 100644 --- a/go/chat/maps/livelocation_watch_test.go +++ b/go/chat/maps/livelocation_watch_test.go @@ -13,7 +13,7 @@ import ( "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" + "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" @@ -281,7 +281,7 @@ func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { // 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}) - require.Zero(t, tc.G.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + lifecycletest.ToBackground(tc.G.MobileLifecycle) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) for ui.attempts.Load() < 22 { @@ -298,6 +298,6 @@ func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { // 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}) - require.Zero(t, tc.G.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{})) + lifecycletest.ToBackground(tc.G.MobileLifecycle) require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) } diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index 02030304d51b..cc60828686b9 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -120,11 +120,11 @@ func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) (changed bo // 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 only keeps the app alive past this call when Go asked it to -// (AppDelegate.swift ends the background task as soon as AppUIBackground -// returns 0, which is the ordinary backgrounding). 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. +// 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() diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index 7ed5598a3eaf..afedbacdf882 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -5,14 +5,17 @@ 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" @@ -27,8 +30,11 @@ const ( func noop() {} -func noDeliveries() lifecycle.BackgroundTaskDeps { +// 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() {} @@ -42,15 +48,18 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { appState, _ := newAppState(t) flushes := 0 c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }}) - require.Zero(t, c.UIBackground(false, noDeliveries())) + token := c.UIBackground(noDeliveries(false)) + require.Positive(t, token) + c.WaitBackgroundTask(token) require.Equal(t, background, appState.State()) - require.Equal(t, 1, flushes) + // 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, 2, flushes) + require.Equal(t, 3, flushes) second := c.AcquireBackgroundWork() require.False(t, first.Release()) // The stale Release left the newer hold alone. @@ -58,7 +67,7 @@ func TestHoldReleaseIsIdempotent(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) require.True(t, second.Release()) require.Equal(t, background, appState.State()) - require.Equal(t, 3, flushes) + require.Equal(t, 4, flushes) } // Close waits for the running background tasks, so no later call may start @@ -69,7 +78,7 @@ func TestNoTaskStartsAfterClose(t *testing.T) { c := lifecycle.New(appState, lifecycle.Config{}) c.Close() - require.Zero(t, c.UIBackground(true, noDeliveries()), "UIBackground started a task after Close") + 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()) @@ -78,7 +87,7 @@ func TestNoTaskStartsAfterClose(t *testing.T) { push := c.PushWindowBegin() require.Positive(t, push) require.Equal(t, backgroundActive, appState.State()) - require.Zero(t, c.PushWindowEnd(push, true, noDeliveries()), "PushWindowEnd started a task after Close") + require.Zero(t, c.PushWindowEnd(push, true, noDeliveries(true)), "PushWindowEnd started a task after Close") require.Zero(t, lifecycle.Holds(c)) require.Equal(t, background, appState.State()) } @@ -88,7 +97,7 @@ func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { appState.Update(background) c := lifecycle.New(appState, lifecycle.Config{}) defer c.Close() - require.Positive(t, c.UIBackground(true, noDeliveries())) + require.Positive(t, c.UIBackground(noDeliveries(true))) push := c.PushWindowBegin() live := c.AcquireBackgroundWork() notified := 0 @@ -98,11 +107,128 @@ func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) c.BackgroundTaskExpired(func() { notified++ }) require.Equal(t, 1, notified, "nothing was left to expire") - require.Zero(t, c.PushWindowEnd(push, false, noDeliveries())) + require.Zero(t, c.PushWindowEnd(push, false, noDeliveries(true))) 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) } + + 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, true, 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) + 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) { @@ -115,7 +241,7 @@ func TestExitEventsApplyBeforeNotifying(t *testing.T) { do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) }, }, "backgroundTaskExpired": { - prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(true, noDeliveries())) }, + prepare: func(c *lifecycle.Controller) { require.Positive(t, c.UIBackground(noDeliveries(true))) }, do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) }, }, } @@ -177,7 +303,7 @@ func TestHoldsStress(t *testing.T) { if r.Intn(2) == 0 { time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) } - c.PushWindowEnd(token, r.Intn(3) == 0, noDeliveries()) + c.PushWindowEnd(token, r.Intn(2) == 0, noDeliveries(r.Intn(3) == 0)) }) runOwner(func(*rand.Rand) { c.BackgroundSync() }) runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) @@ -196,9 +322,9 @@ func TestHoldsStress(t *testing.T) { case 1: c.UIInactive() case 2: - c.UIBackground(r.Intn(2) == 0, noDeliveries()) + c.UIBackground(noDeliveries(r.Intn(2) == 0)) case 3: - c.UIBackground(false, noDeliveries()) + c.UIBackground(noDeliveries(false)) case 4: if r.Intn(10) == 0 { c.WillTerminate(noop) @@ -219,7 +345,7 @@ func TestHoldsStress(t *testing.T) { t.Run("ends in background", func(t *testing.T) { chaos(t, 300) - c.UIBackground(false, noDeliveries()) + c.WaitBackgroundTask(c.UIBackground(noDeliveries(false))) require.Equal(t, background, appState.State()) require.Equal(t, 0, lifecycle.Holds(c)) }) @@ -227,7 +353,7 @@ func TestHoldsStress(t *testing.T) { t.Run("concurrent holds end in background", func(t *testing.T) { for range 50 { chaos(t, 20) - c.UIBackground(false, noDeliveries()) + c.WaitBackgroundTask(c.UIBackground(noDeliveries(false))) var holders sync.WaitGroup for range 4 { holders.Add(1) diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index d63b0fba16ba..a2a51c89fcbb 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -71,6 +71,10 @@ type Config struct { } type BackgroundTaskDeps struct { + // Stay reports whether any work must keep a backgrounded app running. A + // task asks it first, off the controller's lock: answering reads the + // outbox. + Stay func() bool ActiveDeliveries func(context.Context) ([]chat1.OutboxRecord, error) NextFailure func() (chan []chat1.OutboxRecord, func()) NotifyFailure func([]chat1.OutboxRecord) @@ -229,11 +233,18 @@ func (c *Controller) setUILocked(ui UIState) { } // startTaskLocked opens a background task hold and runs the task that keeps -// it until the work is done. +// 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 } + for id, h := range c.holds { + if h.reason == ReasonBackgroundTask { + return id + } + } h := c.acquireLocked(ReasonBackgroundTask) c.wg.Add(1) go func() { @@ -287,17 +298,15 @@ func (c *Controller) UIInactive() { c.debugLocked("uiInactive", "applied") } -// UIBackground records the UI leaving the screen. When stay says work must -// keep going it starts a background task and returns its hold's token for -// WaitBackgroundTask; otherwise it returns 0. -func (c *Controller) UIBackground(stay bool, deps BackgroundTaskDeps) int64 { +// 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. +func (c *Controller) UIBackground(deps BackgroundTaskDeps) int64 { c.mu.Lock() defer c.mu.Unlock() c.setUILocked(UIBackground) - var token int64 - if stay { - token = c.startTaskLocked(deps) - } + token := c.startTaskLocked(deps) c.applyLocked() c.debugLocked("uiBackground", "background task hold %d", token) return token @@ -365,15 +374,16 @@ func (c *Controller) PushWindowBegin() int64 { return h.id } -// PushWindowEnd ends the push window's hold. If the UI is still in the -// background and stay says work must keep going, it first starts a background -// task. The token it returns is for the test harness; native ignores it. -func (c *Controller) PushWindowEnd(token int64, stay bool, deps BackgroundTaskDeps) int64 { +// PushWindowEnd ends the push window's hold. If allowTask and 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, allowTask bool, deps BackgroundTaskDeps) int64 { c.mu.Lock() defer c.mu.Unlock() var task int64 if h, ok := c.holds[token]; ok && h.reason == ReasonPushWindow { - if stay && c.ui == UIBackground { + if allowTask && c.ui == UIBackground { task = c.startTaskLocked(deps) } c.dropLocked(func(o *Hold) bool { return o == h }) @@ -409,10 +419,17 @@ func (c *Controller) BackgroundSync() string { return msg } -// runBackgroundTask keeps the background task hold h 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. +// 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. @@ -438,30 +455,34 @@ func (c *Controller) runBackgroundTask(h *Hold, deps BackgroundTaskDeps) { } }) g.Go(func() error { - successCount := 0 + // 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): - obrs, err := deps.ActiveDeliveries(ctx) - if err != nil { - c.cfg.Debug("lifecycle: failed to query active deliveries: %s", err) - continue - } - if len(obrs) == 0 { - // 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++ - } - if clock.Now().Round(0).Sub(beginTime) >= c.cfg.BackgroundTaskMaxDuration { - deps.NotifyFailure(obrs) - return errors.New("time expired") - } 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() diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index b9c22d9467f4..0be899073add 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -50,8 +50,9 @@ const ( // willResignActive are UIInactive, didBecomeActive is UIActive, // didEnterBackground is UIBackground. PushWindowBegin and PushWindowEnd // bracket a push or notification action, as the bind layer handles one. - // When DidEnterBackground or PushWindowEnd starts a background task, they - // wait until it is polling and return true. + // 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. WillEnterForeground DidBecomeActive WillResignActive @@ -136,8 +137,8 @@ type Step struct { // Slot names the push window for PushWindowBegin/End. Slot int Want keybase1.MobileAppState - // Flush: local DBs were flushed. - Flush bool + // 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 @@ -170,6 +171,11 @@ type Harness struct { tokens map[int]int64 liveLocation *lifecycle.Hold + // A new background task asks Stay only once stayGate lets it, so the + // recorder sees the BACKGROUNDACTIVE the task may leave at once. + stayGate chan struct{} + closing chan struct{} + task int64 syncDone chan struct{} taskDone chan struct{} running sync.WaitGroup @@ -192,6 +198,8 @@ func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *H 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(), } @@ -215,6 +223,7 @@ func closedChan() chan struct{} { // 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() @@ -228,6 +237,13 @@ 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 }, @@ -267,8 +283,8 @@ func (h *Harness) Do(step Step) { if state != step.Want { t.Fatalf("%v: state %v, want %v", step.Do, state, step.Want) } - if got := h.Flushes() - flushes; got != boolInt(step.Flush) { - t.Fatalf("%v: %d flushes, want %d", step.Do, got, boolInt(step.Flush)) + 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)) @@ -295,7 +311,11 @@ func (h *Harness) perform(step Step) bool { case DidBecomeActive: c.UIActive() case DidEnterBackground: - return h.startsTask(func() int64 { return c.UIBackground(h.stay.Load(), h.deps()) }) + return h.startsTask(func() int64 { + token := c.UIBackground(h.deps()) + require.NotZero(h.T, token, "UIBackground always starts a background task") + return token + }) case WillTerminate: c.WillTerminate(h.warn) case BackgroundTaskExpired: @@ -306,8 +326,8 @@ func (h *Harness) perform(step Step) bool { case PushWindowEnd: // The bind layer never hands a push window over to a background task // on iOS. - stay := h.Platform == Android && h.stay.Load() - return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], stay, h.deps()) }) + allowTask := h.Platform == Android + return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], allowTask, h.deps()) }) case LiveLocationAcquire: h.liveLocation = c.AcquireBackgroundWork() case LiveLocationRelease: @@ -352,14 +372,32 @@ func (h *Harness) perform(step Step) bool { } // startsTask runs a call that may start a background task and, if it did, -// waits until the task is polling. It reports whether the task is running. +// 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) } @@ -386,3 +424,15 @@ func (h *Harness) CheckObserved(want []keybase1.MobileAppState) { 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 has ended. +func ToBackground(c *lifecycle.Controller) { + c.WaitBackgroundTask(c.UIBackground(NoWork())) +} diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index ab354e4464b2..57eab0919456 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -14,8 +14,10 @@ const ( func step(do Action, want keybase1.MobileAppState) Step { return Step{Do: do, Want: want} } -func (s Step) flush() Step { - s.Flush = true +func (s Step) flush() Step { return s.flushes(1) } + +func (s Step) flushes(n int) Step { + s.Flushes = n return s } @@ -92,12 +94,12 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WorkStarts, bg), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bg, ina, fg, ina, bg, bga, bg), + Observed: states(bg, ina, fg, ina, bga, bg, bga, bg), }, { Name: "ios background launch by BGAppRefresh, then foreground", @@ -113,11 +115,11 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), - Observed: states(bg, ina, fg, ina, bg, ina, fg), + Observed: states(bg, ina, fg, ina, bga, bg, ina, fg), }, { Name: "ios quick background and foreground cycles with duplicate events", @@ -125,8 +127,8 @@ var Scenarios = []Scenario{ Steps: steps(toForeground, []Step{ step(WillResignActive, ina), step(WillResignActive, ina), + step(DidEnterBackground, bg).flushes(2).returns(false), step(DidEnterBackground, bg).flush().returns(false), - step(DidEnterBackground, bg).returns(false), step(WillEnterForeground, ina), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -135,11 +137,11 @@ var Scenarios = []Scenario{ step(WillResignActive, ina), step(DidBecomeActive, fg), step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), - Observed: states(bg, ina, fg, ina, bg, ina, fg, ina, fg, ina, bg, ina, fg), + Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, ina, fg, ina, fg, ina, bga, bg, ina, fg), }, { Name: "ios control center or system alert keeps things up", @@ -164,11 +166,11 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), - Observed: states(bg, ina, fg, ina, bg, ina, 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. @@ -289,6 +291,17 @@ var Scenarios = []Scenario{ }), Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), }, + { + // iOS can report didEnterBackground twice; the second report 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, @@ -304,10 +317,10 @@ var Scenarios = []Scenario{ Platform: IOS, Steps: steps(toForeground, []Step{ step(WillResignActive, ina), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(WillTerminate, bg).warn(), }), - Observed: states(bg, ina, fg, ina, bg), + Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios termination from the foreground", @@ -349,7 +362,7 @@ var Scenarios = []Scenario{ Name: "android process stop and start", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), }, toForeground, []Step{ step(WorkStarts, fg), step(DidEnterBackground, bga).flush().returns(true), @@ -357,7 +370,7 @@ var Scenarios = []Scenario{ step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), }), - Observed: states(bga, ina, fg, bg, ina, fg, bga, ina, fg), + Observed: states(bga, ina, fg, bga, bg, ina, fg, bga, ina, fg), }, { Name: "android dialog, permission prompt or picker pause keeps the foreground", @@ -375,11 +388,11 @@ var Scenarios = []Scenario{ Name: "android push window in the background", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bga, ina, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bga, bg, bga, bg), }, { // A process started without UI reports the background before the push window opens. @@ -397,7 +410,7 @@ var Scenarios = []Scenario{ Name: "android push window racing process start", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), step(WillEnterForeground, ina), // No background task outside the background, even with work pending. @@ -407,37 +420,49 @@ var Scenarios = []Scenario{ step(WorkStops, fg), step(PushWindowBegin, fg).returns(false), step(PushWindowEnd, fg).returns(false), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).returns(true), }, toForeground, []Step{ step(DidEnterBackground, bga).flush().returns(false), step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bga, ina, fg, bg, bga, ina, fg, bg, bga, ina, fg, bga, bg), + Observed: states(bga, 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).flush().returns(false), + 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(bga, ina, fg, bg, bga, bg), + Observed: states(bga, 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(bga, ina, fg, bga, bg), }, { Name: "android overlapping push windows", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(PushWindowBegin, bga).slot(0).returns(true), step(PushWindowBegin, bga).slot(1).returns(true), step(PushWindowEnd, bga).slot(0).returns(false), step(PushWindowEnd, bg).slot(1).flush().returns(false), }), - Observed: states(bga, ina, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bga, bg, bga, bg), }, { // BackgroundSyncWorker doesn't init Go, so it only syncs in a process where @@ -473,13 +498,13 @@ var Scenarios = []Scenario{ Name: "android WorkManager BackgroundSync racing a push window", Platform: Android, Steps: steps(toForeground, []Step{ - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).flushes(2).returns(false), step(BackgroundSyncStart, bga).returns(true), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bga).returns(false), step(BackgroundSyncTimerFires, bg).flush(), }), - Observed: states(bga, ina, fg, bg, bga, bg), + Observed: states(bga, ina, fg, bga, bg, bga, bg), }, { // A finishing activity reports willExit while the process lives on, so a diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go index 50e73e90b0b4..8af6864ddbb1 100644 --- a/go/libkb/lifecycle/scenario_test.go +++ b/go/libkb/lifecycle/scenario_test.go @@ -5,6 +5,7 @@ package lifecycle_test import ( "context" + "slices" "strings" "testing" "time" @@ -31,12 +32,12 @@ func TestScenarios(t *testing.T) { h := lifecycletest.NewHarness(t, appState, sc.Platform) defer h.Close() for _, step := range sc.Steps { - before := appState.State() + 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 := step.Want == keybase1.MobileAppState_BACKGROUND && before != keybase1.MobileAppState_BACKGROUND + wantCancel := slices.Contains(h.Recorder.States()[seen:], keybase1.MobileAppState_BACKGROUND) require.Equal(t, wantCancel, canceled, "%v: RPC cancel", step.Do) } h.CheckObserved(sc.Observed) diff --git a/go/service/notify_test.go b/go/service/notify_test.go index b69e0383bd9f..cbaf97281553 100644 --- a/go/service/notify_test.go +++ b/go/service/notify_test.go @@ -9,7 +9,7 @@ import ( "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" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" keybase1 "github.com/keybase/client/go/protocol/keybase1" "github.com/stretchr/testify/require" ) @@ -135,7 +135,7 @@ func TestLastMessagePerFieldIsLatest(t *testing.T) { case 1: g.MobileLifecycle.UIInactive() default: - g.MobileLifecycle.UIBackground(false, lifecycle.BackgroundTaskDeps{}) + lifecycletest.ToBackground(g.MobileLifecycle) } } }() 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 index 0187f6e9d080..2a56c714d619 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -3,8 +3,6 @@ package io.keybase.ossifrage import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.Future import java.util.concurrent.TimeUnit // The Go lifecycle entry points. Kept free of Android and gomobile types so @@ -16,28 +14,22 @@ internal interface LifecycleBind { fun willExit() } -internal interface LifecycleExecutor { - fun submit(task: Runnable): Future<*> -} - -internal class SingleThreadLifecycleExecutor : LifecycleExecutor { - private val executor = Executors.newSingleThreadExecutor { r -> Thread(r, "kb-app-lifecycle") } - - override fun submit(task: Runnable): Future<*> = executor.submit(task) -} - // Reports the app's process lifecycle to Go as events; Go decides the state. // -// Events reach Go in the order they happen, on one background thread: -// uiBackground queries the outbox, so it can't run on the main thread. +// 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. +// +// reportHeadlessStart can run off the main thread (a quick reply's worker), so +// every report holds the lock: its check and report stay atomic, and in order +// with the main thread's events. internal class AppLifecycleReporter( private val bind: LifecycleBind, - private val executor: LifecycleExecutor, private val log: (String) -> Unit, ) : DefaultLifecycleObserver { private var reported = false @@ -45,12 +37,12 @@ internal class AppLifecycleReporter( @Synchronized override fun onStart(owner: LifecycleOwner) { reported = true - enqueue("uiInactive") { bind.uiInactive() } + report("uiInactive") { bind.uiInactive() } } @Synchronized override fun onResume(owner: LifecycleOwner) { - enqueue("uiActive") { bind.uiActive() } + report("uiActive") { bind.uiActive() } } @Synchronized @@ -65,7 +57,7 @@ internal class AppLifecycleReporter( return } reported = true - enqueue("willExit") { bind.willExit() } + report("willExit") { bind.willExit() } } // A process started without UI (a push) starts Go in BACKGROUNDACTIVE with @@ -77,30 +69,18 @@ internal class AppLifecycleReporter( } } - // Waits until every event reported so far has reached Go. - fun awaitReported(timeoutMs: Long) { - try { - executor.submit(Runnable {}).get(timeoutMs, TimeUnit.MILLISECONDS) - } catch (e: Exception) { - log("AppLifecycleReporter: gave up waiting for events to reach Go: $e") - } - } - private fun reportBackground(why: String) { reported = true - enqueue("uiBackground: $why") { bind.uiBackground() } + report("uiBackground: $why") { bind.uiBackground() } } - // Callers hold the lock, so tasks are queued in the order events happen. - private fun enqueue(event: String, call: () -> Unit) { - executor.submit(Runnable { - log("AppLifecycleReporter: $event") - try { - call() - } catch (e: Exception) { - log("AppLifecycleReporter: $event failed: $e") - } - }) + private fun report(event: String, call: () -> Unit) { + log("AppLifecycleReporter: $event") + try { + call() + } catch (e: Exception) { + log("AppLifecycleReporter: $event failed: $e") + } } } 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 716a3a07350d..d46fc35499bd 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 @@ -32,9 +32,6 @@ class ChatBroadcastReceiver : BroadcastReceiver() { setupKBRuntime(context, false) val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter lifecycleReporter.reportHeadlessStart() - // Go's push window must see the state after the process start - // or stop that came before this reply. - lifecycleReporter.awaitReported(2000) sendQuickReply({ msg, e -> NativeLogger.error(msg, e) }) { Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody, 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 d08b5c38e597..17c765403b1d 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 @@ -108,9 +108,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { var goProcessingSucceeded = false try { - // Go's push window must see the state after the process - // start or stop that came before this push. - lifecycleReporter.awaitReported(5000) // 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, 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 81f356a8ad7c..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 @@ -61,7 +61,7 @@ class MainApplication : Application(), ReactApplication { internal val lifecycleReporter by lazy { - AppLifecycleReporter(KeybaseLifecycleBind(this), SingleThreadLifecycleExecutor()) { NativeLogger.info(it) } + AppLifecycleReporter(KeybaseLifecycleBind(this)) { NativeLogger.info(it) } } override fun onCreate() { 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 index 25fd0af6a232..414930e0c66c 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -4,53 +4,29 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import java.util.Collections import java.util.concurrent.CountDownLatch -import java.util.concurrent.Future -import java.util.concurrent.FutureTask 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.assertFalse import org.junit.Assert.assertTrue import org.junit.Test private class FakeBind : LifecycleBind { val calls: MutableList = Collections.synchronizedList(mutableListOf()) - var onUiBackground: () -> Unit = {} + val threads: MutableSet = Collections.synchronizedSet(mutableSetOf()) - override fun uiActive() { - calls.add("uiActive") + private fun record(call: String) { + threads.add(Thread.currentThread()) + calls.add(call) } - override fun uiInactive() { - calls.add("uiInactive") - } - - override fun uiBackground() { - onUiBackground() - calls.add("uiBackground") - } - - override fun willExit() { - calls.add("willExit") - } -} + override fun uiActive() = record("uiActive") -// Runs nothing until told to, so tests see what was queued and in what order. -private class ManualExecutor : LifecycleExecutor { - val queue = mutableListOf() + override fun uiInactive() = record("uiInactive") - override fun submit(task: Runnable): Future<*> { - val future = FutureTask(task, Unit) - queue.add(future) - return future - } + override fun uiBackground() = record("uiBackground") - fun runAll() { - while (queue.isNotEmpty()) { - queue.removeAt(0).run() - } - } + override fun willExit() = record("willExit") } private object Owner : LifecycleOwner { @@ -60,8 +36,7 @@ private object Owner : LifecycleOwner { class AppLifecycleReporterTest { private val bind = FakeBind() - private val executor = ManualExecutor() - private val reporter = AppLifecycleReporter(bind, executor) {} + private val reporter = AppLifecycleReporter(bind) {} private fun launch() { reporter.onCreate(Owner) @@ -74,10 +49,7 @@ class AppLifecycleReporterTest { reporter.onStop(Owner) } - private fun calls(): List { - executor.runAll() - return bind.calls.toList() - } + private fun calls(): List = bind.calls.toList() @Test fun processStartAndStopReportEventsInOrder() { @@ -85,7 +57,6 @@ class AppLifecycleReporterTest { stop() reporter.onStart(Owner) reporter.onResume(Owner) - assertTrue("nothing reaches Go on the calling thread", bind.calls.isEmpty()) assertEquals( listOf( "uiInactive", "uiActive", @@ -96,6 +67,17 @@ class AppLifecycleReporterTest { ) } + @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() @@ -144,16 +126,6 @@ class AppLifecycleReporterTest { assertEquals(listOf("uiInactive", "uiActive", "uiBackground"), calls()) } - @Test - fun awaitReportedWaitsForQueuedEvents() { - val executor = SingleThreadLifecycleExecutor() - val reporter = AppLifecycleReporter(bind, executor) {} - bind.onUiBackground = { Thread.sleep(100) } - reporter.reportHeadlessStart() - reporter.awaitReported(5000) - assertEquals(listOf("uiBackground"), bind.calls.toList()) - } - @Test fun onlyAFinishingActivityExits() { launch() @@ -173,41 +145,6 @@ class AppLifecycleReporterTest { calls(), ) } - - @Test - fun eventsReachGoInOrderOnOneBackgroundThread() { - val threads = Collections.synchronizedSet(mutableSetOf()) - val record = object : LifecycleBind by bind { - override fun uiInactive() { - threads.add(Thread.currentThread()) - bind.uiInactive() - } - - override fun uiActive() { - threads.add(Thread.currentThread()) - bind.uiActive() - } - - override fun uiBackground() { - threads.add(Thread.currentThread()) - // Slow, like the outbox query, so later events queue behind it. - Thread.sleep(5) - bind.uiBackground() - } - } - val ordered = AppLifecycleReporter(record, SingleThreadLifecycleExecutor()) {} - val expected = mutableListOf() - repeat(20) { - ordered.onStart(Owner) - ordered.onResume(Owner) - ordered.onStop(Owner) - expected += listOf("uiInactive", "uiActive", "uiBackground") - } - ordered.awaitReported(10_000) - assertEquals(expected, bind.calls.toList()) - assertEquals(1, threads.size) - assertFalse(threads.contains(Thread.currentThread())) - } } class SendQuickReplyTest { diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 2c36a8eb42de..f38b6eb37267 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -433,10 +433,10 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } -// Hands lifecycle events to Go on one serial queue, so Go sees them in callback -// order without the main thread waiting on Go (didEnterBackground queries the -// chat outbox). Also owns the UIKit background task that keeps the app alive -// while Go decides and does its background work. Main thread only. +// 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 task that keeps 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 @@ -446,19 +446,18 @@ final class AppLifecycleForwarder { // main thread for Go's last work (flush, a pending-message warning). private static let exitWorkTimeout: TimeInterval = 1 - private let queue = DispatchQueue(label: "com.keybase.app.lifecycle", qos: .userInitiated) private var backgroundTask: UIBackgroundTaskIdentifier = .invalid // willEnterForeground and willResignActive. - func uiInactive() { queue.async { Keybasego.KeybaseAppUIInactive() } } - func didBecomeActive() { queue.async { Keybasego.KeybaseAppUIActive() } } + func uiInactive() { Keybasego.KeybaseAppUIInactive() } + func didBecomeActive() { Keybasego.KeybaseAppUIActive() } func willTerminate() { runBounded { Keybasego.KeybaseAppWillExit(PushNotifier()) } } - // Every background entry starts its own task before asking Go, so the app - // can't suspend mid-query, and takes over from a task an earlier entry left + // Every background entry starts its own task, which lasts until Go's + // background task has ended, and takes over from a task an earlier entry left // running: that task's pending end or expiration then finds it no longer // current and does nothing. func didEnterBackground(_ application: UIApplication) { @@ -472,17 +471,15 @@ final class AppLifecycleForwarder { if previous != .invalid { application.endBackgroundTask(previous) } - queue.async { - // A token when Go started a background task, 0 otherwise. - let token = Keybasego.KeybaseAppUIBackground(PushNotifier()) - guard token > 0 else { - DispatchQueue.main.async { self.endBackgroundTask(task) } - return - } - DispatchQueue.global(qos: .default).async { - Keybasego.KeybaseAppWaitBackgroundTask(token) - DispatchQueue.main.async { self.endBackgroundTask(task) } - } + // 0 only while Go isn't running (before Init, after shutdown). + let token = Keybasego.KeybaseAppUIBackground(PushNotifier()) + guard token > 0 else { + endBackgroundTask(task) + return + } + DispatchQueue.global(qos: .default).async { + Keybasego.KeybaseAppWaitBackgroundTask(token) + DispatchQueue.main.async { self.endBackgroundTask(task) } } } @@ -499,11 +496,11 @@ final class AppLifecycleForwarder { UIApplication.shared.endBackgroundTask(task) } - // Queued behind earlier events to keep the order; the wait only bounds how - // long the app stays alive for it. + // 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) - queue.async { + DispatchQueue.global(qos: .userInitiated).async { work() done.signal() } From 13901d16a832a70d3d6b89dd6cbfefb0adc0ffa9 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 09:30:58 -0400 Subject: [PATCH 120/127] refactor(lifecycle): Android starts in BACKGROUND like iOS --- go/kbhttp/manager/manager_test.go | 2 +- go/libkb/appstate.go | 11 ++-- go/libkb/appstate_test.go | 2 +- go/libkb/lifecycle/lifecycle.go | 10 +--- go/libkb/lifecycle/lifecycletest/harness.go | 13 ++--- go/libkb/lifecycle/lifecycletest/scenarios.go | 52 +++++++++---------- go/service/gregor_conn_test.go | 2 +- .../keybase/ossifrage/AppLifecycleReporter.kt | 29 ++--------- .../ossifrage/ChatBroadcastReceiver.kt | 2 - .../KeybasePushNotificationListenerService.kt | 3 -- .../ossifrage/AppLifecycleReporterTest.kt | 22 -------- 11 files changed, 42 insertions(+), 106 deletions(-) diff --git a/go/kbhttp/manager/manager_test.go b/go/kbhttp/manager/manager_test.go index 9224840ab120..05e8f7baf6b9 100644 --- a/go/kbhttp/manager/manager_test.go +++ b/go/kbhttp/manager/manager_test.go @@ -532,7 +532,7 @@ 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, sc.Platform.InitialState(), stopInBackground) + 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) { diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go index cc60828686b9..ab5d886a38ac 100644 --- a/go/libkb/appstate.go +++ b/go/libkb/appstate.go @@ -48,13 +48,10 @@ func NewMobileAppState(g *GlobalContext) *MobileAppState { func initialMobileAppState(goos string) keybase1.MobileAppState { switch goos { - case "android": - // we need this so cold notifications work on android - return keybase1.MobileAppState_BACKGROUNDACTIVE - case "ios": - // iOS launches the process in the background for silent pushes and - // background refresh; the scene life cycle reports foreground once - // the app is actually on screen. + 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 diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go index 421605b61fd2..136dc9d2b458 100644 --- a/go/libkb/appstate_test.go +++ b/go/libkb/appstate_test.go @@ -31,7 +31,7 @@ func requireOpen(t *testing.T, ch <-chan struct{}) { func TestMobileAppStateInitialState(t *testing.T) { require.Equal(t, keybase1.MobileAppState_BACKGROUND, initialMobileAppState("ios")) - require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, initialMobileAppState("android")) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, initialMobileAppState("android")) require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("darwin")) require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("linux")) } diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index a2a51c89fcbb..c72b1f23e608 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -35,7 +35,6 @@ const ( type Reason string const ( - ReasonLaunch Reason = "launch" ReasonBackgroundTask Reason = "backgroundTask" ReasonBackgroundSync Reason = "backgroundSync" ReasonPushWindow Reason = "pushWindow" @@ -152,10 +151,6 @@ func New(appState AppState, cfg Config) *Controller { c.ui = UIActive case keybase1.MobileAppState_INACTIVE: c.ui = UIInactive - case keybase1.MobileAppState_BACKGROUNDACTIVE: - // Android starts its process up; the first UI report ends this. - c.ui = UIBackground - c.acquireLocked(ReasonLaunch) default: c.ui = UIBackground } @@ -222,10 +217,9 @@ func (c *Controller) dropLocked(match func(*Hold) bool) (dropped int) { return dropped } -// setUILocked records a UI report. Any report ends the launch hold; leaving -// the background ends the holds that only keep a backgrounded app alive. +// setUILocked records a UI report. Leaving the background ends the holds that +// only keep a backgrounded app alive. func (c *Controller) setUILocked(ui UIState) { - c.dropLocked(func(h *Hold) bool { return h.reason == ReasonLaunch }) if c.ui == UIBackground && ui != UIBackground { c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask || h.reason == ReasonBackgroundSync }) } diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index 0be899073add..aa9b993541cd 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -31,13 +31,8 @@ func (p Platform) String() string { return "ios" } -// InitialState is the state the service starts in on each platform. -func (p Platform) InitialState() keybase1.MobileAppState { - if p == Android { - return keybase1.MobileAppState_BACKGROUNDACTIVE - } - return keybase1.MobileAppState_BACKGROUND -} +// InitialState is the state the service starts in on both platforms. +const InitialState = keybase1.MobileAppState_BACKGROUND type Action int @@ -187,10 +182,10 @@ const ( maxDuration = 10 * time.Minute ) -// NewHarness moves appState to the platform's initial state and starts +// 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(platform.InitialState()) + appState.Update(InitialState) h := &Harness{ T: t, Platform: platform, diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index 57eab0919456..ad4abec94916 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -350,14 +350,7 @@ var Scenarios = []Scenario{ }), Observed: states(bg, ina, fg, ina, bga, bg), }, - {Name: "android cold launch", Platform: Android, Steps: toForeground, Observed: states(bga, ina, fg)}, - { - // Any first UI report ends the launch hold. - Name: "android cold launch straight to active", - Platform: Android, - Steps: []Step{step(DidBecomeActive, fg)}, - Observed: states(bga, fg), - }, + {Name: "android cold launch", Platform: Android, Steps: toForeground, Observed: states(bg, ina, fg)}, { Name: "android process stop and start", Platform: Android, @@ -370,7 +363,7 @@ var Scenarios = []Scenario{ step(DidBecomeActive, fg), step(BackgroundTaskWait, fg), }), - Observed: states(bga, ina, fg, bga, bg, ina, fg, bga, ina, fg), + Observed: states(bg, ina, fg, bga, bg, ina, fg, bga, ina, fg), }, { Name: "android dialog, permission prompt or picker pause keeps the foreground", @@ -382,7 +375,7 @@ var Scenarios = []Scenario{ // Back from the prompt: the process resumes without a start. step(DidBecomeActive, fg), }), - Observed: states(bga, ina, fg), + Observed: states(bg, ina, fg), }, { Name: "android push window in the background", @@ -392,18 +385,28 @@ var Scenarios = []Scenario{ step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bga, ina, fg, bga, bg, bga, bg), + Observed: states(bg, ina, fg, bga, bg, bga, bg), }, { - // A process started without UI reports the background before the push window opens. + // A process started without UI stays in BACKGROUND until the push window opens. Name: "android push at cold start", Platform: Android, Steps: []Step{ - step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), }, - Observed: states(bga, bg, bga, bg), + 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. @@ -426,7 +429,7 @@ var Scenarios = []Scenario{ step(DidEnterBackground, bga).flush().returns(false), step(PushWindowEnd, bg).flush().returns(false), }), - Observed: states(bga, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg), + 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", @@ -438,7 +441,7 @@ var Scenarios = []Scenario{ step(PushWindowEnd, bga).returns(true), step(BackgroundTaskDelivered, bg).flush(), }), - Observed: states(bga, ina, fg, bga, bg, bga, bg), + Observed: states(bg, ina, fg, bga, bg, bga, bg), }, { Name: "android push window ending during a background task joins it", @@ -450,7 +453,7 @@ var Scenarios = []Scenario{ step(PushWindowEnd, bga).returns(true), step(BackgroundTaskFails, bg).flush().warn(), }), - Observed: states(bga, ina, fg, bga, bg), + Observed: states(bg, ina, fg, bga, bg), }, { Name: "android overlapping push windows", @@ -462,28 +465,25 @@ var Scenarios = []Scenario{ step(PushWindowEnd, bga).slot(0).returns(false), step(PushWindowEnd, bg).slot(1).flush().returns(false), }), - Observed: states(bga, ina, fg, bga, bg, bga, bg), + 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; a push (or quick reply) cold start already reported - // the background. + // something else did, such as a push at cold start. Name: "android WorkManager BackgroundSync after a push cold start", Platform: Android, Steps: []Step{ - step(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), step(BackgroundSyncTimerFires, bg).flush(), }, - Observed: states(bga, bg, bga, bg, bga, bg), + 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(DidEnterBackground, bg).flush().returns(false), step(PushWindowBegin, bga).returns(true), step(PushWindowEnd, bg).flush().returns(false), step(BackgroundSyncStart, bga).returns(true), @@ -491,7 +491,7 @@ var Scenarios = []Scenario{ step(DidBecomeActive, fg), step(BackgroundSyncWait, fg), }, - Observed: states(bga, bg, bga, bg, bga, ina, fg), + Observed: states(bg, bga, bg, bga, ina, fg), }, { // The sync keeps its hold after the push window ends. @@ -504,7 +504,7 @@ var Scenarios = []Scenario{ step(PushWindowEnd, bga).returns(false), step(BackgroundSyncTimerFires, bg).flush(), }), - Observed: states(bga, ina, fg, bga, bg, bga, bg), + Observed: states(bg, ina, fg, bga, bg, bga, bg), }, { // A finishing activity reports willExit while the process lives on, so a @@ -519,6 +519,6 @@ var Scenarios = []Scenario{ step(WorkStarts, bg), step(PushWindowEnd, bg).returns(false), }), - Observed: states(bga, ina, fg, bg, bga, bg), + Observed: states(bg, ina, fg, bg, bga, bg), }, } diff --git a/go/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index ee0c526149aa..cd7639229ebd 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -490,7 +490,7 @@ func TestGregorReconnectWhileSuspendedDoesNotConnect(t *testing.T) { func TestGregorConnScenarioReplay(t *testing.T) { for _, sc := range lifecycletest.Scenarios { t.Run(sc.Name, func(t *testing.T) { - c := setupGregorConn(t, sc.Platform.InitialState()) + 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) { 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 index 2a56c714d619..f644bf3e399e 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleReporter.kt @@ -25,55 +25,32 @@ internal interface LifecycleBind { // UIInactive: only the process lifecycle decides what Go sees. A full-screen // picker or camera stops the process like any other exit. // -// reportHeadlessStart can run off the main thread (a quick reply's worker), so -// every report holds the lock: its check and report stay atomic, and in order -// with the main thread's events. +// 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 { - private var reported = false - - @Synchronized override fun onStart(owner: LifecycleOwner) { - reported = true report("uiInactive") { bind.uiInactive() } } - @Synchronized override fun onResume(owner: LifecycleOwner) { report("uiActive") { bind.uiActive() } } - @Synchronized override fun onStop(owner: LifecycleOwner) { - reportBackground("process stop") + report("uiBackground") { bind.uiBackground() } } // Activity recreation and a task moved to the back are not an exit. - @Synchronized fun onMainActivityDestroy(isFinishing: Boolean, isChangingConfigurations: Boolean) { if (!isFinishing || isChangingConfigurations) { return } - reported = true report("willExit") { bind.willExit() } } - // A process started without UI (a push) starts Go in BACKGROUNDACTIVE with - // nothing to end it; report the background, unless the UI got there first. - @Synchronized - fun reportHeadlessStart() { - if (!reported) { - reportBackground("started without UI") - } - } - - private fun reportBackground(why: String) { - reported = true - report("uiBackground: $why") { bind.uiBackground() } - } - private fun report(event: String, call: () -> Unit) { log("AppLifecycleReporter: $event") try { 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 d46fc35499bd..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 @@ -30,8 +30,6 @@ class ChatBroadcastReceiver : BroadcastReceiver() { "Couldn't send reply - Failed to read input." } else { setupKBRuntime(context, false) - val lifecycleReporter = (context.applicationContext as MainApplication).lifecycleReporter - lifecycleReporter.reportHeadlessStart() sendQuickReply({ msg, e -> NativeLogger.error(msg, e) }) { Keybase.handlePostTextReply(convData.convID, convData.tlfName, convData.lastMsgId, messageBody, 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 17c765403b1d..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 @@ -29,11 +29,8 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { return ex.message?.contains("different account") == true } - private val lifecycleReporter get() = (application as MainApplication).lifecycleReporter - override fun onCreate() { setupKBRuntime(this, false) - lifecycleReporter.reportHeadlessStart() NativeLogger.info("KeybasePushNotificationListenerService created") createNotificationChannel(this) } 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 index 414930e0c66c..0bc3013d0490 100644 --- a/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt +++ b/shared/android/app/src/test/java/io/keybase/ossifrage/AppLifecycleReporterTest.kt @@ -104,28 +104,6 @@ class AppLifecycleReporterTest { ) } - @Test - fun startWithoutUiReportsTheBackgroundOnce() { - reporter.onCreate(Owner) - reporter.reportHeadlessStart() - reporter.reportHeadlessStart() - assertEquals(listOf("uiBackground"), calls()) - reporter.onStart(Owner) - reporter.onResume(Owner) - reporter.reportHeadlessStart() - assertEquals(listOf("uiBackground", "uiInactive", "uiActive"), calls()) - } - - @Test - fun startWithoutUiAfterTheUiReportsNothing() { - reporter.onStart(Owner) - reporter.reportHeadlessStart() - reporter.onResume(Owner) - stop() - reporter.reportHeadlessStart() - assertEquals(listOf("uiInactive", "uiActive", "uiBackground"), calls()) - } - @Test fun onlyAFinishingActivityExits() { launch() From f28d02d8db4692df6ddcbc8d562d586ec01bc35b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 09:45:49 -0400 Subject: [PATCH 121/127] refactor(ios): per-entry background tasks; live location throttle decided in Go --- go/bind/keybase.go | 5 +- go/bind/location_test.go | 85 +++++++++---- go/chat/maps/livelocation.go | 60 +++++++++ go/chat/maps/livelocation_throttle_test.go | 119 ++++++++++++++++++ go/chat/types/interfaces.go | 4 +- go/chat/unfurl/scraper_test.go | 4 + go/libkb/lifecycle/lifecycle.go | 6 +- shared/ios/Keybase/AppDelegate.swift | 51 +++----- shared/ios/Keybase/LocationWatcher.swift | 42 ++----- .../flows/lifecycle-location.test.ts | 4 +- 10 files changed, 279 insertions(+), 101 deletions(-) create mode 100644 go/chat/maps/livelocation_throttle_test.go diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 165db7193068..574c49f06cf6 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -906,7 +906,8 @@ func AppUIInactive() { kbCtx.MobileLifecycle.UIInactive() } -// LocationUpdate reports a location fix from the native location service. +// 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 @@ -915,7 +916,7 @@ func LocationUpdate(lat, lon float64, accuracy int) { } func locationUpdate(tracker types.LiveLocationTracker, lat, lon float64, accuracy int) { - tracker.LocationUpdate(context.Background(), chat1.Coordinate{Lat: lat, Lon: lon, Accuracy: float64(accuracy)}) + tracker.NativeLocationUpdate(context.Background(), chat1.Coordinate{Lat: lat, Lon: lon, Accuracy: float64(accuracy)}) } // DeliverPushTap resolves a tapped notification's payload to the route it opens diff --git a/go/bind/location_test.go b/go/bind/location_test.go index 5608d8835b17..a1c1f600b2f8 100644 --- a/go/bind/location_test.go +++ b/go/bind/location_test.go @@ -2,6 +2,8 @@ package keybase import ( "context" + "encoding/base64" + "fmt" "sync" "testing" "time" @@ -40,39 +42,68 @@ func TestLocationUpdateReachesTrackers(t *testing.T) { tracker := maps.NewLiveLocationTracker(g) clock := clockwork.NewFakeClock() tracker.SetClock(clock) - tracker.TestingCoordsAddedCh = make(chan struct{}, 10) ctx := context.Background() lifecycletest.ToBackground(tc.G.MobileLifecycle) - tracker.StartTracking(ctx, chat1.ConversationID("conv"), 1, clock.Now().Add(time.Hour)) - select { - case <-watcher.starts: - case <-time.After(10 * time.Second): - require.Fail(t, "native watch never started") + 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) - select { - case <-tracker.TestingCoordsAddedCh: - case <-time.After(10 * time.Second): - require.Fail(t, "coordinate never reached the tracker") - } - require.Equal(t, []chat1.Coordinate{{Lat: 40.5, Lon: -73.25, Accuracy: 12}}, - tracker.GetCoordinates(ctx, "not a tracker")) + require.Equal(t, []chat1.Coordinate{fix(40.5)}, waitRecorded(key, 40.5)) require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, tc.G.MobileAppState.State()) - - 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()) + // 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 { @@ -81,7 +112,7 @@ type recordingLiveLocationTracker struct { coords []chat1.Coordinate } -func (r *recordingLiveLocationTracker) LocationUpdate(_ context.Context, coord chat1.Coordinate) { +func (r *recordingLiveLocationTracker) NativeLocationUpdate(_ context.Context, coord chat1.Coordinate) { r.Lock() defer r.Unlock() r.coords = append(r.coords, coord) diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 4942aa847ab3..a3277f5ba129 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "time" @@ -40,6 +41,7 @@ type LiveLocationTracker struct { nativeWatchMu sync.Mutex nativeWatchRefs int + fixThrottle fixThrottle // testing only TestingCoordsAddedCh chan struct{} @@ -317,6 +319,7 @@ func (l *LiveLocationTracker) acquireNativeWatch(w types.LocationWatcher) { defer l.nativeWatchMu.Unlock() l.nativeWatchRefs++ if l.nativeWatchRefs == 1 { + l.fixThrottle = fixThrottle{} w.StartWatching() } } @@ -459,6 +462,63 @@ 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() 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/types/interfaces.go b/go/chat/types/interfaces.go index cfb002e0615f..afc9ec123bdf 100644 --- a/go/chat/types/interfaces.go +++ b/go/chat/types/interfaces.go @@ -466,7 +466,8 @@ type ShareIntentDonator interface { // LocationWatcher runs the OS location service natively (iOS), so live // location keeps working without the UI. Fixes come back through -// LiveLocationTracker.LocationUpdate. When nil, the chat UI watches position. +// LiveLocationTracker.NativeLocationUpdate. When nil, the chat UI watches +// position. type LocationWatcher interface { StartWatching() StopWatching() @@ -583,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 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/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index c72b1f23e608..00e065bf8f03 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -339,9 +339,9 @@ func (c *Controller) WillTerminate(notifyPending func()) { } // BackgroundTaskExpired ends every background task hold: iOS is ending the -// app's background time. Native drops stale expirations, so these are the -// current entry's holds and any older ones still running. Live location, -// push window and sync holds keep their own lifetimes. +// 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 }) diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index f38b6eb37267..062c049a5baa 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -435,7 +435,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // Hands lifecycle events to Go on the main thread, in callback order: every Go // lifecycle call returns at once, except the exit work, which runBounded caps. -// Also owns the UIKit background task that keeps the app alive while Go does +// 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). @@ -446,8 +446,6 @@ final class AppLifecycleForwarder { // main thread for Go's last work (flush, a pending-message warning). private static let exitWorkTimeout: TimeInterval = 1 - private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - // willEnterForeground and willResignActive. func uiInactive() { Keybasego.KeybaseAppUIInactive() } func didBecomeActive() { Keybasego.KeybaseAppUIActive() } @@ -457,45 +455,34 @@ final class AppLifecycleForwarder { } // Every background entry starts its own task, which lasts until Go's - // background task has ended, and takes over from a task an earlier entry left - // running: that task's pending end or expiration then finds it no longer - // current and does nothing. + // 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) { - let owner = BackgroundTaskOwner() - let task = application.beginBackgroundTask(withName: "kb.didEnterBackground") { [weak self] in - self?.backgroundTaskExpired(owner.task) + // 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 } - owner.task = task - let previous = backgroundTask - backgroundTask = task - if previous != .invalid { - application.endBackgroundTask(previous) + task = application.beginBackgroundTask(withName: "kb.didEnterBackground") { + guard task != .invalid else { return } + log.info("background task expired") + self.runBounded { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } + end() } // 0 only while Go isn't running (before Init, after shutdown). let token = Keybasego.KeybaseAppUIBackground(PushNotifier()) guard token > 0 else { - endBackgroundTask(task) + end() return } DispatchQueue.global(qos: .default).async { Keybasego.KeybaseAppWaitBackgroundTask(token) - DispatchQueue.main.async { self.endBackgroundTask(task) } + DispatchQueue.main.async { end() } } } - private func backgroundTaskExpired(_ task: UIBackgroundTaskIdentifier) { - guard task != .invalid, task == backgroundTask else { return } - log.info("background task expired") - runBounded { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } - endBackgroundTask(task) - } - - private func endBackgroundTask(_ task: UIBackgroundTaskIdentifier) { - guard task != .invalid, task == backgroundTask else { return } - backgroundTask = .invalid - UIApplication.shared.endBackgroundTask(task) - } - // 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) { @@ -508,12 +495,6 @@ final class AppLifecycleForwarder { } } -// The expiration handler is created before beginBackgroundTask returns the id -// it needs. -private final class BackgroundTaskOwner { - var task: UIBackgroundTaskIdentifier = .invalid -} - class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { // Extension point for config-plugins diff --git a/shared/ios/Keybase/LocationWatcher.swift b/shared/ios/Keybase/LocationWatcher.swift index a47ab61a1e19..6db76692af97 100644 --- a/shared/ios/Keybase/LocationWatcher.swift +++ b/shared/ios/Keybase/LocationWatcher.swift @@ -1,29 +1,22 @@ import CoreLocation import Keybasego -import UIKit 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; each fix goes back to Go. 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. +// 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 { - // In the background a fix is only reported once the device has moved this far - // since the last one reported. The first fix after starting is reported right - // away, so the move that relaunched the app gets posted. - private static let deferredUpdatesDistance: CLLocationDistance = 65 - // Everything below is main thread only. private let manager = CLLocationManager() private var wanted = false private var running = false - private var lastReported: CLLocation? - private var pending: CLLocation? - private var pendingDistance: CLLocationDistance = 0 + // 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() { @@ -54,23 +47,13 @@ final class LocationWatcher: NSObject, Keybasego.KeybaseNativeLocationWatcherPro func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard running else { return } - for location in locations where location.horizontalAccuracy >= 0 { - if let previous = pending ?? lastReported { - pendingDistance += location.distance(from: previous) - } - pending = location + let fixes = locations.filter { $0.horizontalAccuracy >= 0 }.map { + (coordinate: $0.coordinate, accuracy: Int($0.horizontalAccuracy)) } - guard let location = pending, - lastReported == nil || UIApplication.shared.applicationState == .active - || pendingDistance >= Self.deferredUpdatesDistance - else { return } - lastReported = location - pending = nil - pendingDistance = 0 - let coordinate = location.coordinate - let accuracy = Int(location.horizontalAccuracy) goQueue.async { - Keybasego.KeybaseLocationUpdate(coordinate.latitude, coordinate.longitude, accuracy) + for fix in fixes { + Keybasego.KeybaseLocationUpdate(fix.coordinate.latitude, fix.coordinate.longitude, fix.accuracy) + } } } @@ -100,9 +83,6 @@ final class LocationWatcher: NSObject, Keybasego.KeybaseNativeLocationWatcherPro running = false manager.stopUpdatingLocation() manager.stopMonitoringSignificantLocationChanges() - lastReported = nil - pending = nil - pendingDistance = 0 } } } diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts index eefecfe81d6c..8a3cd491737a 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -29,7 +29,7 @@ import { // 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 native hands to Go, +// - "+ 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, @@ -47,7 +47,7 @@ import { // 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 a backgrounded watcher, which waits for real movement, reports them, +// 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}, From f8b6d01d02a76665252fda3a955479037981ddef Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 10:09:58 -0400 Subject: [PATCH 122/127] fix(service): nil-safe router setters for standalone mode; cover the oneshot promotion --- go/libkb/notify_router.go | 6 +++++ go/libkb/notify_router_test.go | 42 ++++++++++++++++++++++++++++++++++ go/service/gregor_conn_test.go | 6 ++--- go/service/main.go | 6 +++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go index 880a370e4742..ebc114645405 100644 --- a/go/libkb/notify_router.go +++ b/go/libkb/notify_router.go @@ -464,6 +464,9 @@ func (n *NotifyRouter) Shutdown() { // 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.readClientState = read @@ -534,6 +537,9 @@ func (n *NotifyRouter) removeConnection(id ConnectionID) { // 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) { + if n == nil { + return + } n.Lock() defer n.Unlock() s := n.senders[i] diff --git a/go/libkb/notify_router_test.go b/go/libkb/notify_router_test.go index 3c582a8c5b56..d53a83e1ec85 100644 --- a/go/libkb/notify_router_test.go +++ b/go/libkb/notify_router_test.go @@ -290,3 +290,45 @@ func TestSetChannelsAfterCloseRegistersNothing(t *testing.T) { 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/service/gregor_conn_test.go b/go/service/gregor_conn_test.go index cd7639229ebd..c3feee99957b 100644 --- a/go/service/gregor_conn_test.go +++ b/go/service/gregor_conn_test.go @@ -634,9 +634,9 @@ func TestGregorConnStress(t *testing.T) { require.LessOrEqual(t, runtime.NumGoroutine(), baseline, "leaked goroutines") } -// Connects and shutdowns race the connection's own goroutines: OnConnect -// reads the URI, the ping loop watches its connection's ctx, and the -// transport dials. +// 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() diff --git a/go/service/main.go b/go/service/main.go index fe46346885e0..63e04d5a3a36 100644 --- a/go/service/main.go +++ b/go/service/main.go @@ -357,7 +357,9 @@ 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. + // 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) @@ -1083,7 +1085,7 @@ func (d *Service) gregordConnect() (err error) { d.G().Log.Debug("| gregor URI: %s", uri) // Reset a live connection so it authenticates again. Nothing connects - // while the app is in BACKGROUND. + // while the app is in BACKGROUND or the desktop is suspended. return d.gregor.ConnectFresh(uri) } From c64a9173a3ad9c379c5b796625d365809616174e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 10:15:16 -0400 Subject: [PATCH 123/127] fix(lifecycle): iOS pushes hold nothing; a repeat UIBackground starts no task --- go/bind/keybase.go | 25 ++++++------ go/bind/notifications_test.go | 11 ++++-- go/libkb/lifecycle/controller_test.go | 12 ++++-- go/libkb/lifecycle/lifecycle.go | 38 ++++++++++++++----- go/libkb/lifecycle/lifecycletest/harness.go | 26 +++++++------ go/libkb/lifecycle/lifecycletest/scenarios.go | 37 ++++++++++++------ go/libkb/lifecycle/scenario_test.go | 5 ++- shared/ios/Keybase/AppDelegate.swift | 3 +- 8 files changed, 102 insertions(+), 55 deletions(-) diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 574c49f06cf6..d6680cb7629e 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -1043,7 +1043,8 @@ func shouldStayRunningInBackground() bool { // 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; 0 before Init. +// 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 0 @@ -1052,10 +1053,10 @@ func AppUIBackground(pusher PushNotifier) int64 { return kbCtx.MobileLifecycle.UIBackground(backgroundTaskDeps(pusher)) } -// inPushWindow runs work, which handles a push or a notification action, -// holding a backgrounded app up while it runs. 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. +// 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 work(false) @@ -1066,16 +1067,18 @@ func inPushWindow(pusher PushNotifier, work func(uiActive bool) error) error { 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 func() { - // iOS suspends the app once native calls the push's completion handler, - // right after this returns, so a background task started here would - // leave it suspended in BACKGROUNDACTIVE. - lc.PushWindowEnd(token, goos == "android", deps) - }() + defer lc.PushWindowEnd(token, deps) return work(false) } diff --git a/go/bind/notifications_test.go b/go/bind/notifications_test.go index 0ce66e7c740d..dea2bf766df5 100644 --- a/go/bind/notifications_test.go +++ b/go/bind/notifications_test.go @@ -128,8 +128,10 @@ func TestBackgroundNotificationOpensAndClosesPushWindow(t *testing.T) { 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 @@ -139,10 +141,13 @@ func TestBackgroundNotificationOpensAndClosesPushWindow(t *testing.T) { return unboxFailed }) require.ErrorIs(t, err, unboxFailed) - require.Equal(t, bga, during, "the push is handled in BACKGROUNDACTIVE") if platform == lifecycletest.IOS { - require.Equal(t, bg, h.AppState.State(), "iOS suspends at the completion handler; nothing stays up") + 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") @@ -151,7 +156,7 @@ func TestBackgroundNotificationOpensAndClosesPushWindow(t *testing.T) { h.Controller.UIActive() ran := false require.NoError(t, runPushWindow(h.Controller, platform.String(), pendingDeliveryDeps(), func(uiActive bool) error { - require.True(t, uiActive) + require.Equal(t, platform == lifecycletest.Android, uiActive, "only Android's work asks") ran = true return nil })) diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go index afedbacdf882..a4d6516f9034 100644 --- a/go/libkb/lifecycle/controller_test.go +++ b/go/libkb/lifecycle/controller_test.go @@ -78,6 +78,7 @@ func TestNoTaskStartsAfterClose(t *testing.T) { 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()) @@ -87,7 +88,7 @@ func TestNoTaskStartsAfterClose(t *testing.T) { push := c.PushWindowBegin() require.Positive(t, push) require.Equal(t, backgroundActive, appState.State()) - require.Zero(t, c.PushWindowEnd(push, true, noDeliveries(true)), "PushWindowEnd started a task after Close") + 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()) } @@ -97,6 +98,7 @@ func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.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() @@ -107,7 +109,7 @@ func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) { require.Equal(t, backgroundActive, appState.State()) c.BackgroundTaskExpired(func() { notified++ }) require.Equal(t, 1, notified, "nothing was left to expire") - require.Zero(t, c.PushWindowEnd(push, false, noDeliveries(true))) + c.WaitBackgroundTask(c.PushWindowEnd(push, noDeliveries(false))) require.True(t, live.Release()) require.Equal(t, background, appState.State()) } @@ -135,6 +137,7 @@ func TestBackgroundTaskStartsJoinTheRunningTask(t *testing.T) { } deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) } + c.UIInactive() first := c.UIBackground(deps) require.Positive(t, first) select { @@ -144,7 +147,7 @@ func TestBackgroundTaskStartsJoinTheRunningTask(t *testing.T) { } require.Equal(t, first, c.UIBackground(deps), "a duplicate didEnterBackground") push := c.PushWindowBegin() - require.Equal(t, first, c.PushWindowEnd(push, true, deps), "a push window's end") + 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. @@ -172,6 +175,7 @@ func startPolledTask(t *testing.T, maxDuration time.Duration, deps lifecycle.Bac BackgroundTaskMaxDuration: maxDuration, }) t.Cleanup(c.Close) + c.UIInactive() token := c.UIBackground(deps) require.Positive(t, token) done = make(chan struct{}) @@ -303,7 +307,7 @@ func TestHoldsStress(t *testing.T) { if r.Intn(2) == 0 { time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond) } - c.PushWindowEnd(token, r.Intn(2) == 0, noDeliveries(r.Intn(3) == 0)) + c.PushWindowEnd(token, noDeliveries(r.Intn(3) == 0)) }) runOwner(func(*rand.Rand) { c.BackgroundSync() }) runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) }) diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go index 00e065bf8f03..4cf7c1d66055 100644 --- a/go/libkb/lifecycle/lifecycle.go +++ b/go/libkb/lifecycle/lifecycle.go @@ -226,6 +226,16 @@ func (c *Controller) setUILocked(ui UIState) { 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 @@ -234,10 +244,8 @@ func (c *Controller) startTaskLocked(deps BackgroundTaskDeps) int64 { if c.closed { return 0 } - for id, h := range c.holds { - if h.reason == ReasonBackgroundTask { - return id - } + if id := c.runningTaskLocked(); id != 0 { + return id } h := c.acquireLocked(ReasonBackgroundTask) c.wg.Add(1) @@ -296,9 +304,19 @@ func (c *Controller) UIInactive() { // 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() @@ -368,16 +386,16 @@ func (c *Controller) PushWindowBegin() int64 { return h.id } -// PushWindowEnd ends the push window's hold. If allowTask and 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, allowTask bool, deps BackgroundTaskDeps) int64 { +// PushWindowEnd ends the push window's hold. If the UI is still in the +// background, it first hands over to a background task, which keeps the app +// up while work must keep going. The token it returns is for the test harness; +// 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 allowTask && c.ui == UIBackground { + if c.ui == UIBackground { task = c.startTaskLocked(deps) } c.dropLocked(func(o *Hold) bool { return o == h }) diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go index aa9b993541cd..53d5bc8fa29b 100644 --- a/go/libkb/lifecycle/lifecycletest/harness.go +++ b/go/libkb/lifecycle/lifecycletest/harness.go @@ -44,10 +44,13 @@ const ( // 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. + // 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. + // 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 @@ -306,23 +309,22 @@ func (h *Harness) perform(step Step) bool { case DidBecomeActive: c.UIActive() case DidEnterBackground: - return h.startsTask(func() int64 { - token := c.UIBackground(h.deps()) - require.NotZero(h.T, token, "UIBackground always starts a background task") - return token - }) + 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: - // The bind layer never hands a push window over to a background task - // on iOS. - allowTask := h.Platform == Android - return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], allowTask, h.deps()) }) + 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: @@ -427,7 +429,7 @@ func NoWork() lifecycle.BackgroundTaskDeps { } // ToBackground reports the UI in the background with nothing to keep running, -// and returns once the background task that starts has ended. +// 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/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go index ad4abec94916..cc8f0aa9262c 100644 --- a/go/libkb/lifecycle/lifecycletest/scenarios.go +++ b/go/libkb/lifecycle/lifecycletest/scenarios.go @@ -70,14 +70,14 @@ var iosToBackgroundTask = []Step{ var Scenarios = []Scenario{ {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)}, { - Name: "ios background launch by silent push holds the app up for the push, then foreground", + // 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, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), - step(BackgroundTaskExpired, bg), + step(PushWindowBegin, bg).returns(false), + step(PushWindowEnd, bg).returns(false), }, toForeground), - Observed: states(bg, bga, bg, ina, fg), + Observed: states(bg, ina, fg), }, { Name: "ios silent push while active holds nothing", @@ -96,10 +96,10 @@ var Scenarios = []Scenario{ step(WillResignActive, ina), step(DidEnterBackground, bg).flushes(2).returns(false), step(WorkStarts, bg), - step(PushWindowBegin, bga).returns(true), - step(PushWindowEnd, bg).flush().returns(false), + step(PushWindowBegin, bg).returns(false), + step(PushWindowEnd, bg).returns(false), }), - Observed: states(bg, ina, fg, ina, bga, bg, bga, bg), + Observed: states(bg, ina, fg, ina, bga, bg), }, { Name: "ios background launch by BGAppRefresh, then foreground", @@ -128,7 +128,7 @@ var Scenarios = []Scenario{ step(WillResignActive, ina), step(WillResignActive, ina), step(DidEnterBackground, bg).flushes(2).returns(false), - step(DidEnterBackground, bg).flush().returns(false), + step(DidEnterBackground, bg).returns(false), step(WillEnterForeground, ina), step(WillEnterForeground, ina), step(DidBecomeActive, fg), @@ -141,7 +141,7 @@ var Scenarios = []Scenario{ step(WillEnterForeground, ina), step(DidBecomeActive, fg), }), - Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, ina, fg, ina, fg, ina, bga, bg, ina, 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", @@ -292,8 +292,9 @@ var Scenarios = []Scenario{ Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg), }, { - // iOS can report didEnterBackground twice; the second report joins the - // running task instead of starting another that would warn again. + // 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{ @@ -365,6 +366,18 @@ var Scenarios = []Scenario{ }), 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, diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go index 8af6864ddbb1..e885949acbf1 100644 --- a/go/libkb/lifecycle/scenario_test.go +++ b/go/libkb/lifecycle/scenario_test.go @@ -76,8 +76,9 @@ func TestHarnessCloseEndsRunningWork(t *testing.T) { {Do: lifecycletest.BackgroundSyncStart, Want: bga, Returns: lifecycletest.ReturnTrue}, }, "background task": { - {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_BACKGROUND}, - {Do: lifecycletest.DidEnterBackground, Want: bga, Returns: lifecycletest.ReturnTrue}, + {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 { diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 062c049a5baa..2d95db3543ff 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -471,7 +471,8 @@ final class AppLifecycleForwarder { self.runBounded { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) } end() } - // 0 only while Go isn't running (before Init, after shutdown). + // 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() From eb99346ef594de39637a703a1dddb4578c7dfcfc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 10:15:33 -0400 Subject: [PATCH 124/127] fix(lifecycle): displayOnce comment for push handling without a window --- go/bind/notifications.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/bind/notifications.go b/go/bind/notifications.go index 5b73d314171b..93eef9f7e9ec 100644 --- a/go/bind/notifications.go +++ b/go/bind/notifications.go @@ -359,9 +359,9 @@ func handleBackgroundNotification(strConvID, body, serverMessageBody, sender str // 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 still displays, because its display also removes the -// server's generic notification for this message, which can have landed while -// the push was held; a local notification never shows while active. +// 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) { From 0fe1a3ebbd96627870bb9f78eb89d1d357ee4899 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 10:42:33 -0400 Subject: [PATCH 125/127] fix(avatars): arm the background flusher's first wake-up before its goroutine runs --- go/avatars/appstate.go | 6 +++++- go/avatars/fullcaching.go | 4 ++-- go/avatars/urlcaching.go | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/go/avatars/appstate.go b/go/avatars/appstate.go index 72e61751b27a..39e15f0d4827 100644 --- a/go/avatars/appstate.go +++ b/go/avatars/appstate.go @@ -26,16 +26,20 @@ func (f *backgroundFlusher) start(m libkb.MetaContext, flush func(libkb.MetaCont 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 <-m.G().MobileAppState.NextUpdate(state): + 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() diff --git a/go/avatars/fullcaching.go b/go/avatars/fullcaching.go index 392c0b91cd5e..cea758e3abfa 100644 --- a/go/avatars/fullcaching.go +++ b/go/avatars/fullcaching.go @@ -214,9 +214,9 @@ func (c *FullCachingSource) StartBackgroundTasks(mctx libkb.MetaContext) { } c.started = true c.bgFlusher.start(mctx, func(m libkb.MetaContext) { - c.debug(m, "monitorAppState: backgrounded") + c.debug(m, "backgroundFlusher: flushing diskLRU") if err := c.diskLRU.Flush(m.Ctx(), m.G()); err != nil { - c.debug(m, "monitorAppState: unable to flush diskLRU %v", err) + c.debug(m, "backgroundFlusher: unable to flush diskLRU %v", err) } }) c.populateCacheCh = make(chan populateArg, 100) diff --git a/go/avatars/urlcaching.go b/go/avatars/urlcaching.go index 59c7550a6dce..0adf0b7accc9 100644 --- a/go/avatars/urlcaching.go +++ b/go/avatars/urlcaching.go @@ -32,7 +32,7 @@ func NewURLCachingSource(staleThreshold time.Duration, size int) *URLCachingSour func (c *URLCachingSource) StartBackgroundTasks(m libkb.MetaContext) { c.bgFlusher.start(m, func(m libkb.MetaContext) { - c.debug(m, "monitorAppState: backgrounded") + c.debug(m, "backgroundFlusher: flushing diskLRU") c.diskLRU.Flush(m.Ctx(), m.G()) }) } From c8c33049918cb07edda3de1360e4a5434876f593 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 10:42:33 -0400 Subject: [PATCH 126/127] fix(chat): ended runs touch nothing; per-run live location group; drop stale convloader wake-ups --- go/chat/archive.go | 47 +++++++++++------ go/chat/archive_appstate_test.go | 34 +++++++++++++ go/chat/convloader.go | 12 ++--- go/chat/convloader_appstate_test.go | 67 +++++++++++++------------ go/chat/maps/livelocation.go | 10 +++- go/chat/maps/livelocation_watch_test.go | 40 +++++++++++++++ 6 files changed, 157 insertions(+), 53 deletions(-) diff --git a/go/chat/archive.go b/go/chat/archive.go index c3adc00444ff..e410da08b860 100644 --- a/go/chat/archive.go +++ b/go/chat/archive.go @@ -148,17 +148,41 @@ func (r *ChatArchiveRegistry) flushLocked(ctx context.Context) error { return nil } -func (r *ChatArchiveRegistry) flush(ctx context.Context) { +// 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) { +func (r *ChatArchiveRegistry) bgPauseAllJobs(ctx context.Context, stopCh chan struct{}) { r.Lock() defer r.Unlock() + if runEnded(stopCh) { + return + } _ = r.bgPauseAllJobsLocked(ctx) } @@ -172,7 +196,7 @@ func (r *ChatArchiveRegistry) loop(stopCh chan struct{}, state keybase1.MobileAp defer r.Debug(ctx, "loop: shutting down") flushCh := r.clock.After(r.flushDelay) resume := time.NewTimer(r.resumeJobsDelay) - if state != keybase1.MobileAppState_FOREGROUND { + if !archiveRunsIn(state) { resume.Stop() } // changed is refreshed only when the loop reads a new state: a resume @@ -184,17 +208,17 @@ func (r *ChatArchiveRegistry) loop(stopCh chan struct{}, state keybase1.MobileAp case <-stopCh: return nil case <-flushCh: - r.flush(ctx) + 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 state == keybase1.MobileAppState_FOREGROUND { + if archiveRunsIn(state) { resume.Reset(r.resumeJobsDelay) } else { resume.Stop() - r.bgPauseAllJobs(ctx) + r.bgPauseAllJobs(ctx, stopCh) } case <-resume.C: if err := r.resumeAllBgJobs(ctx, stopCh); err != nil { @@ -208,15 +232,10 @@ func (r *ChatArchiveRegistry) resumeAllBgJobs(ctx context.Context, stopCh chan s defer r.Trace(ctx, &err, "resumeAllBgJobs")() r.Lock() defer r.Unlock() - // Stop closes stopCh under this lock, so a closed channel here means this - // run is over, whether or not a later Start (possibly for another user) - // has since replaced r.stopCh. - select { - case <-stopCh: + if runEnded(stopCh) { return nil - default: } - if state := r.G().MobileAppState.State(); state != keybase1.MobileAppState_FOREGROUND { + if state := r.G().MobileAppState.State(); !archiveRunsIn(state) { r.Debug(ctx, "resumeAllBgJobs: not resuming in %v", state) return nil } @@ -441,7 +460,7 @@ func (r *ChatArchiveRegistry) Set(ctx context.Context, cancel types.PauseArchive // 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(); state != keybase1.MobileAppState_FOREGROUND { + 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 diff --git a/go/chat/archive_appstate_test.go b/go/chat/archive_appstate_test.go index 24cb8ce0f7c9..1cb4aad29b2f 100644 --- a/go/chat/archive_appstate_test.go +++ b/go/chat/archive_appstate_test.go @@ -164,6 +164,13 @@ func TestArchiveConcurrentResumesLaunchOnce(t *testing.T) { }) } 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) @@ -184,6 +191,33 @@ func TestArchiveConcurrentResumesLaunchOnce(t *testing.T) { 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. diff --git a/go/chat/convloader.go b/go/chat/convloader.go index b0068cafa0c1..fd8619ace27c 100644 --- a/go/chat/convloader.go +++ b/go/chat/convloader.go @@ -205,6 +205,12 @@ func (b *BackgroundConvLoader) Start(ctx context.Context, uid gregor1.UID) { 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 @@ -308,12 +314,6 @@ func (b *BackgroundConvLoader) suspendedLocked() bool { return b.suspendCount > 0 || suspendInAppState(b.G().MobileAppState.State()) } -func (b *BackgroundConvLoader) isSuspended() bool { - b.Lock() - defer b.Unlock() - return b.suspendedLocked() -} - func (b *BackgroundConvLoader) isRunning() bool { b.Lock() defer b.Unlock() diff --git a/go/chat/convloader_appstate_test.go b/go/chat/convloader_appstate_test.go index b8beaa63a212..f9de567b20e6 100644 --- a/go/chat/convloader_appstate_test.go +++ b/go/chat/convloader_appstate_test.go @@ -12,7 +12,6 @@ import ( "github.com/keybase/client/go/chat/types" "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" @@ -158,16 +157,13 @@ func TestConvLoaderAppStateAcrossRuns(t *testing.T) { for i := range 3 { appState.Update(keybase1.MobileAppState_FOREGROUND) b.Start(context.TODO(), uid) - require.False(t, b.isSuspended(), "run %d: suspended at a foreground Start", i) require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) load := requirePull(t, pulls) appState.Update(keybase1.MobileAppState_BACKGROUND) - require.True(t, b.isSuspended(), "run %d: not suspended in BACKGROUND", i) requireCanceled(i, load) appState.Update(keybase1.MobileAppState_INACTIVE) - require.False(t, b.isSuspended(), "run %d: suspended in INACTIVE", i) load = requirePull(t, pulls) appState.Update(keybase1.MobileAppState_BACKGROUND) @@ -176,7 +172,6 @@ func TestConvLoaderAppStateAcrossRuns(t *testing.T) { // A run started in BACKGROUND loads nothing until the app leaves it. b.Start(context.TODO(), uid) - require.True(t, b.isSuspended(), "run %d: not suspended at a background Start", i) require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) select { case <-pulls.calls: @@ -184,7 +179,6 @@ func TestConvLoaderAppStateAcrossRuns(t *testing.T) { case <-time.After(300 * time.Millisecond): } appState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE) - require.False(t, b.isSuspended(), "run %d: suspended in BACKGROUNDACTIVE", i) load = requirePull(t, pulls) appState.Update(keybase1.MobileAppState_BACKGROUND) requireCanceled(i, load) @@ -200,7 +194,6 @@ func TestConvLoaderBackgroundLaunchStaysSuspended(t *testing.T) { require.False(t, b.Resume(context.TODO())) b.Start(context.TODO(), uid) defer requireConvLoaderStopped(t, b) - require.True(t, b.isSuspended()) require.NoError(t, b.Queue(context.TODO(), convLoaderTestJob())) select { @@ -218,17 +211,47 @@ func TestConvLoaderBackgroundLaunchStaysSuspended(t *testing.T) { } } -// An unbalanced Resume must not release the monitor's suspension. +// An unbalanced Resume, or a Suspend and Resume pair, must not release the +// app-state suspension. func TestConvLoaderResumeKeepsAppStateSuspension(t *testing.T) { - b, _, tc := setupAppStateConvLoader(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())) - require.True(t, b.isSuspended()) b.Suspend(context.TODO()) require.True(t, b.Resume(context.TODO())) - require.True(t, b.isSuspended()) + + 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 @@ -407,21 +430,6 @@ func TestConvLoaderReplacedRunRetryStaysInItsRun(t *testing.T) { require.Equal(t, []gregor1.UID{oldUID}, pulls.uids) } -func TestConvLoaderScenarioReplay(t *testing.T) { - for _, sc := range lifecycletest.Scenarios { - t.Run(sc.Name, func(t *testing.T) { - b, _, tc := setupAppStateConvLoader(t) - b.Start(context.TODO(), gregor1.UID([]byte{1, 2, 3, 4})) - defer requireConvLoaderStopped(t, b) - lifecycletest.Play(t, tc.G.MobileAppState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) { - if got, want := b.isSuspended(), step.Want == keybase1.MobileAppState_BACKGROUND; got != want { - t.Fatalf("step %d %v: suspended %v in %v", i, step.Do, got, step.Want) - } - }) - }) - } -} - func TestConvLoaderAppStateStress(t *testing.T) { b, pulls, tc := setupAppStateConvLoader(t) baseline := runtime.NumGoroutine() @@ -470,11 +478,8 @@ func TestConvLoaderAppStateStress(t *testing.T) { require.FailNow(t, "deadlock") } - for _, state := range states { - tc.G.MobileAppState.Update(state) - b.Start(context.TODO(), uid) - require.Equal(t, state == keybase1.MobileAppState_BACKGROUND, b.isSuspended(), "in %v", state) - } + 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 diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index a3277f5ba129..2f88edb8e7e4 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -30,10 +30,13 @@ 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. @@ -57,6 +60,7 @@ func NewLiveLocationTracker(g *globals.Context) *LiveLocationTracker { updateInterval: 30 * time.Second, maxCoords: 500, clock: clockwork.NewRealClock(), + eg: new(errgroup.Group), } } @@ -79,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 diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go index 479f26ced6cc..0b0c16aff966 100644 --- a/go/chat/maps/livelocation_watch_test.go +++ b/go/chat/maps/livelocation_watch_test.go @@ -301,3 +301,43 @@ func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { 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") + } +} From 1b96df3d46216dff4aaa645b720af40544ec7146 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 19 Sep 2026 10:45:37 -0400 Subject: [PATCH 127/127] fix(js): login leaves the session to clientState; e2e matches every http server start --- .../thread-message-state.test.tsx | 7 ++--- shared/constants/init/shared.test.ts | 6 ++++ shared/constants/init/shared.tsx | 9 +++--- shared/router-v2/account-link-switch.test.ts | 16 ++++++---- shared/router-v2/intent-consumption.test.ts | 2 +- shared/settings/load-settings.tsx | 3 +- shared/stores/config.tsx | 8 ++--- shared/stores/navigation-intents.test.ts | 15 ++++------ shared/stores/tests/client-state.test.ts | 13 ++++++++ shared/stores/tests/config.test.ts | 30 +++++++++++++++++++ .../flows/lifecycle-app-state.test.ts | 11 ++++--- 11 files changed, 84 insertions(+), 36 deletions(-) diff --git a/shared/chat/conversation/thread-message-state.test.tsx b/shared/chat/conversation/thread-message-state.test.tsx index 688121dba72e..76def478788f 100644 --- a/shared/chat/conversation/thread-message-state.test.tsx +++ b/shared/chat/conversation/thread-message-state.test.tsx @@ -674,9 +674,8 @@ describe('addMessagesToThreadState', () => { }) }) -// The service keeps its last-bound address in Info()/getURL forever once it has bound once (see -// go/kbhttp/manager.Srv.Info), so a message update can no longer carry a URL that regresses a -// good one to empty: an update now always simply takes whatever the service sent. +// 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({ @@ -696,7 +695,7 @@ describe('local server urls', () => { ) }) - test('an empty url in an update now overwrites an existing one', () => { + test('an empty url in an update overwrites an existing one', () => { const state = makeThreadState([]) addMessagesToThreadState( state, diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 8f50bd1b8308..f275686779e7 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -4,6 +4,8 @@ import {resetAllStores} from '@/util/zustand' import {ignorePromise} from '@/constants/utils' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' +import {useRouterState} from '@/stores/router' +import {useShellState} from '@/stores/shell' import { applyClientState, initSharedSubscriptions, @@ -254,6 +256,10 @@ describe('sessionSettledStep', () => { }) 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({ diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index e258667a17ac..f3648e963cfa 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -276,8 +276,8 @@ const awaitSessionAgain = () => { // 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 completed login and logout and every cleared -// session, and once the service's startup login attempt settles. +// 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 @@ -301,8 +301,9 @@ export const applyClientState = (clientState: T.RPCGen.ClientState) => { } // 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. - if (useConfigState.getState().loggedIn && uid !== useCurrentUserState.getState().uid) { + // 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 diff --git a/shared/router-v2/account-link-switch.test.ts b/shared/router-v2/account-link-switch.test.ts index 7f7256ea8636..1cb85b304bf1 100644 --- a/shared/router-v2/account-link-switch.test.ts +++ b/shared/router-v2/account-link-switch.test.ts @@ -15,8 +15,8 @@ const noSecretAccount = {hasStoredSecret: false, uid: 'uid-nosecret', username: 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 is mocked below so a leftover, still-pending intent from a -// previous test can be acknowledged in cleanup without an unmocked RPC 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'}) @@ -28,12 +28,15 @@ 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() - // navigation-intents' resetState deliberately keeps account-targeted intents. - const {intent, dispatch} = useNavigationIntentsState.getState() - if (intent) dispatch.acknowledge(intent.id) useNavigationIntentsState.setState({lastHandledIntent: undefined}) useDaemonState.setState({handshakeState: 'done'}) useCurrentUserState.setState({uid: currentAccount.uid, username: currentAccount.username}) @@ -49,10 +52,11 @@ beforeEach(() => { }) afterEach(() => { - jest.restoreAllMocks() unsub?.() unsub = undefined + clearIntent() resetAllStores() + jest.restoreAllMocks() }) test('a tap for the current account does not switch', () => { diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index 8013ea0d3352..0b6ade323c21 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -36,9 +36,9 @@ beforeEach(() => { }) afterEach(() => { - jest.restoreAllMocks() clearIntent() resetAllStores() + jest.restoreAllMocks() }) test('consuming an intent acks the tap route it carries', () => { diff --git a/shared/settings/load-settings.tsx b/shared/settings/load-settings.tsx index a3ae8b713746..9a082d7b4c4a 100644 --- a/shared/settings/load-settings.tsx +++ b/shared/settings/load-settings.tsx @@ -14,8 +14,7 @@ export const loadSettings = () => { } // 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 same rule the versioned session write follows. The - // racing writer is usually an emailsChanged/phoneNumbersChanged notification, but + // 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 diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 2ea66bcdc4a1..d5393e3443be 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -264,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) } diff --git a/shared/stores/navigation-intents.test.ts b/shared/stores/navigation-intents.test.ts index ee003b08fb23..06f179e2b578 100644 --- a/shared/stores/navigation-intents.test.ts +++ b/shared/stores/navigation-intents.test.ts @@ -11,6 +11,11 @@ const clearIntent = () => { dispatch.resetState() } +let ack: jest.SpyInstance +beforeEach(() => { + ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) +}) + afterEach(() => { clearIntent() jest.restoreAllMocks() @@ -118,7 +123,6 @@ test('clears duplicate history across the account store reset', () => { // 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() @@ -128,7 +132,6 @@ test('enqueuing a tap does not ack its route', () => { }) test('acknowledging a tapped intent acks its route', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) @@ -139,7 +142,6 @@ test('acknowledging a tapped intent acks its route', () => { }) test('acknowledging a plain deep link never calls the tap ack', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch dispatch.enqueue('keybase://convid/no-tap') @@ -149,7 +151,6 @@ test('acknowledging a plain deep link never calls the tap ack', () => { }) test('markInitialURLHandled acks the tapped route it clears', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() dispatch.enqueue('keybase://convid/cold-start-tap', {pushTapID: id}) @@ -177,7 +178,6 @@ test('re-enqueuing a still-pending tap id does not replace or duplicate the inte // 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() dispatch.enqueue('keybase://convid/tap-target', {pushTapID: id}) @@ -197,7 +197,6 @@ test('re-enqueuing an already-consumed tap id retries the ack without navigating // 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 ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const older = pushTapID() const newer = pushTapID() @@ -217,7 +216,6 @@ test('merging a newer tap into the same-URL pending intent adopts its id instead }) test('a tap enqueued again inside the duplicate window of its own navigation acks immediately', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const first = pushTapID() dispatch.enqueue('keybase://convid/duplicate-window', {pushTapID: first}) @@ -236,7 +234,6 @@ test('a tap enqueued again inside the duplicate window of its own navigation ack }) test('a pending tap superseded by an unrelated enqueue acks the route it loses', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() dispatch.enqueue('keybase://convid/superseded-tap', {pushTapID: id}) @@ -250,7 +247,6 @@ test('a pending tap superseded by an unrelated enqueue acks the route it loses', }) test('resetState acks the tap route of an unscoped intent it discards', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() // No targetUid: a contact-joined push tap, which never carries an account. @@ -263,7 +259,6 @@ test('resetState acks the tap route of an unscoped intent it discards', () => { }) test('resetState does not ack a targeted intent it keeps', () => { - const ack = jest.spyOn(T.RPCGen, 'appStateAckPushTapRouteRpcPromise').mockResolvedValue(undefined) const dispatch = useNavigationIntentsState.getState().dispatch const id = pushTapID() dispatch.enqueue('keybase://convid/kept-across-reset', {pushTapID: id, targetUid: 'target-uid'}) diff --git a/shared/stores/tests/client-state.test.ts b/shared/stores/tests/client-state.test.ts index 9fc398232507..d46b295c0574 100644 --- a/shared/stores/tests/client-state.test.ts +++ b/shared/stores/tests/client-state.test.ts @@ -193,6 +193,19 @@ describe('an account switch', () => { 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() diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index 8b9bedd78d46..b607e6961daf 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -1,5 +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' @@ -113,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/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts index 219b2480e760..f1f824287fdb 100644 --- a/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -28,11 +28,13 @@ import { // 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 (re)starts. +// "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, so an empty result in the Notification Center test -// means no restart, not a pattern that no longer matches Go's log. +// 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() @@ -52,6 +54,7 @@ describe('app lifecycle: app state', () => { // 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) @@ -175,7 +178,7 @@ describe('app lifecycle: app state', () => { expect.stringMatching(/app focus changed: inactive$/), expect.stringMatching(/app focus changed: active$/), ]) - expect(findLines(goLogSince(goMark), httpSrvStarted)).toEqual([]) + expect(findLines(goLogSince(goMark), httpSrvStartedAny)).toEqual([]) expect((await appSnapshot()).httpSrv.address).toBe(before.httpSrv.address) }) })