Skip to content

iOS 27 UIScene adoption and mobile app-state hardening - #29637

Draft
chrisnojima wants to merge 38 commits into
masterfrom
nojima/HOTPOT-uiscene-clean
Draft

chrisnojima wants to merge 38 commits into
masterfrom
nojima/HOTPOT-uiscene-clean

Conversation

@chrisnojima

Copy link
Copy Markdown
Contributor

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.applicationState lags 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

  • Follows Expo's stock template: SceneDelegate: ExpoAppSceneDelegate, a scene manifest, and an AppDelegate that provides the React Native factory.
  • Hardware enter/shift-enter moved to AppDelegate.pressesBegan.

Go: app state is driven by lifecycle events (go/libkb/lifecycle)

  • Native reports events and Go decides the state; applicationState is never read.
  • MobileAppState has a generation. Owners (background task, BackgroundSync, live location, push window) undo only their own transitions.
  • Side effects (flush, RPC cancel) run only on a real change. The flush rotates the memtable instead of a full compaction, and runs single-flight.
  • iOS starts in BACKGROUND.
  • Scenario harness replays iOS/Android event sequences with a fake clock.

Go consumers

  • kbhttp: only BACKGROUND stops it (always up on Android). It keeps one token for the process, restarts dead listeners, and its decisions are atomic with the state read.
  • gregor: connects are gated on app state. Logout goes through the gate. OnConnect's tail can't outlive its connection.
  • chat:
    • convloader, archive and indexer are correct across Start/Stop and races.
    • Attachment/emoji URLs are empty while the server is down.
  • leveldb cleaner, avatars, ephemeral, KBFS: monitors seed from the current state; loops honor shutdown; the kbfs http server follows kbhttp's rules; a cellular pause survives.

iOS native

  • Lifecycle events go to Go off the main thread, in order.
  • The background task is tied to its id, fixing a stranded BACKGROUNDACTIVE.
  • Pushes are delivered to JS exactly once. Queued background pushes are age-limited.
  • Scene disconnect is cleaned up.
  • A push launching the app in the background is no longer treated as a tap.

JS

  • App state is seeded.
  • Push navigation happens only on a tap.
  • Cold-start tap race fixed.
  • An httpSrvInfo notification wins over a stale bootstrap, and bootstrap is re-read after subscribing.
  • Image retry uses the current host:port.
  • Attachment URLs are kept when an empty one arrives.
  • Native listeners are cleaned up on hot reload.

Testing so far

  • Go: unit and scenario tests for every consumer, all under -race, with stress and goroutine-leak checks. Every fix was mutation-checked: reverting it fails its test.
  • JS: jest suites for app state, push gating, http server ordering and URL retention. yarn lint:all is clean.
  • iOS: device build succeeds. Verified on device so far:
    • launch under Xcode 27
    • images load
    • cold and warm deep links

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.
… 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.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant