iOS 27 UIScene adoption and mobile app-state hardening - #29637
Draft
chrisnojima wants to merge 38 commits into
Draft
chrisnojima wants to merge 38 commits into
chrisnojima wants to merge 38 commits into
Conversation
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.
…s 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.
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.
…p 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.
…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.
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.
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.
…le 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.
…rder on change channels
… 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.
… 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.
…nager 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.
… 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.
…tion 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.
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.
…nnection 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.
…arts 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
…d 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
… 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.
…r 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.
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.
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.
…ver 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.
…nd 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.
…on 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.
… 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.
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.
…ationUpdate guards
… window close, schedule one sync job
…e background sync jobs once
…ssion fails instead of hanging
…s, 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.
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.
…e 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.
…issing 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft: work in progress. Remaining work: JS fix round (native scene-activation app state, unfurl URL retention), native iOS live location, Android lifecycle, on-device lifecycle e2e tests, and a final whole-branch review.
Why
Apps built with the iOS 27 SDK (Xcode 27) crash at launch unless they use the UIScene life cycle. Moving to scenes changed when lifecycle callbacks fire:
UIApplication.applicationStatelags inside the forwarded callbacks. That exposed a set of app-state races between native, Go and JS. The first visible symptom was the local http server staying stopped, so images didn't load.What
iOS scene adoption
SceneDelegate: ExpoAppSceneDelegate, a scene manifest, and an AppDelegate that provides the React Native factory.AppDelegate.pressesBegan.Go: app state is driven by lifecycle events (
go/libkb/lifecycle)applicationStateis never read.MobileAppStatehas a generation. Owners (background task, BackgroundSync, live location, push window) undo only their own transitions.Go consumers
iOS native
JS
httpSrvInfonotification wins over a stale bootstrap, and bootstrap is re-read after subscribing.Testing so far
-race, with stress and goroutine-leak checks. Every fix was mutation-checked: reverting it fails its test.yarn lint:allis clean.