shorebird: gate auto-updater kickoff on iOS data protection availability#126
Closed
eseidel wants to merge 1 commit into
Closed
shorebird: gate auto-updater kickoff on iOS data protection availability#126eseidel wants to merge 1 commit into
eseidel wants to merge 1 commit into
Conversation
e0cf619 to
7c59c93
Compare
On iOS, files under `Library/Application Support/` inherit the default
`NSFileProtectionCompleteUntilFirstUserAuthentication` class. Before the
user has unlocked the device for the first time since boot, the OS
refuses writes under that directory with EPERM/EACCES. Our updater's
state files (`state.json`, `patches_state.json`) live there.
When the engine kicks off the auto-updater thread during early app boot
on a locked or freshly-booted device, the updater tries to download and
persist a patch, then fails at the state-write step and throws
`UpdateException: File::create for ".../patches_state.json"`. Customer
telemetry shows this as a long tail of failures spread across many
unique installs.
This change introduces a `ProtectedDataGate` abstraction owned by the
`Updater` singleton. The gate decides when
`StartUpdateThreadWhenReady()` may actually run the updater thread.
Installing a new gate cancels any pending start on the previous one,
so the Updater is always the owner of at most one pending observer.
shell/common/shorebird/protected_data.{h,cc} (moved into :updater
target; the :shorebird target no longer references them)
- `ProtectedDataGate` abstract interface with
`StartWhenAvailable(start_fn)` and `CancelPending()`.
- `MakeImmediateProtectedDataGate()` factory returning the default
gate used on every platform that does not install its own. The
immediate gate invokes `start_fn` synchronously and `CancelPending`
is a no-op.
shell/common/shorebird/updater.{h,cc}
- `Updater` gains `SetProtectedDataGate(std::unique_ptr<...>)`,
`StartUpdateThreadWhenReady()`, and `CancelPendingUpdateStart()`.
- The gate member is initialized lazily to the immediate gate on
first access; installing a new gate cancels the previous one's
pending start first.
- `StartUpdateThreadWhenReady` routes through the gate to
`Updater::Instance().StartUpdateThread()`.
shell/platform/darwin/ios/framework/Source/
FlutterShorebirdProtectedDataGate.{h,mm}
- Exposes `MakeIOSProtectedDataGate()` factory. The concrete
`IOSProtectedDataGate` is private (anonymous namespace) and
holds a single `__strong id observer_` NSNotificationCenter
handle.
- `StartWhenAvailable` dispatches to the main queue, cancels any
prior pending observer, checks
`UIApplication.protectedDataAvailable`, and either invokes
`start_fn` immediately or registers a one-shot observer for
`UIApplicationProtectedDataDidBecomeAvailableNotification`.
- `CancelPending` removes the observer. The destructor also calls
the cancel path (dispatching sync to main if needed) so the
gate never leaks an observer past its own lifetime.
- Falls back to immediate start inside app extensions where
`UIApplication.sharedApplication` is nil.
FlutterDartProject.mm
- Calls
`Updater::Instance().SetProtectedDataGate(MakeIOSProtectedDataGate())`
just before `ConfigureShorebird(...)`. The Updater now owns the
gate for the life of the process.
shell/common/shorebird/shorebird.cc
- Both `ConfigureShorebird()` overloads route their
`StartUpdateThread()` call through
`Updater::Instance().StartUpdateThreadWhenReady()`.
Why ownership on the Updater rather than a process-global function
pointer (an earlier draft of this change): putting the gate on the
Updater gives cancellation a real home
(`CancelPendingUpdateStart()`), removes a data-race class on the
previous global, guarantees at most one pending observer across
multiple ConfigureShorebird calls, and makes destructor-driven
cleanup work.
Unit tests exercise the default gate, an installed capturing gate,
cancellation, gate replacement, and restoration of the immediate gate
via `SetProtectedDataGate(nullptr)`. The iOS gate itself requires a
`UIApplication` to exercise meaningfully and is covered by on-device
integration testing.
A complementary defensive fallback in the updater Rust library
(shorebirdtech/updater#336) catches the residual case where the device
becomes locked between the availability check and the actual write, or
where a manual Dart `update()` call happens before first unlock.
Fixes shorebirdtech/shorebird#3685.
7c59c93 to
7fffb3a
Compare
Author
|
Closing without landing. The Rust-side fallback in shorebirdtech/updater#336 is sufficient on its own:
If future telemetry shows the wasted-background-work case is actually material, we can revisit with a much simpler Superseded by shorebirdtech/updater#336. |
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.
Summary
On iOS, files under
Library/Application Support/inherit the defaultNSFileProtectionCompleteUntilFirstUserAuthenticationclass. Before the user has unlocked the device for the first time since boot (and in some edge cases while the device is locked), the OS refuses writes under that directory with EPERM/EACCES. Our updater's state files (state.json,patches_state.json) live there.When the engine kicks off the auto-updater thread during early app boot on a locked or freshly-booted device, the updater tries to download and persist a patch, then fails at the state-write step and throws
UpdateException: File::create for ".../patches_state.json". Customer telemetry shows this as a long tail of failures spread across many unique installs.This PR gates the call to
StartUpdateThread()onUIApplication.protectedDataAvailableso the updater only runs once the state directory is actually writable.Fixes shorebirdtech/shorebird#3685.
Implementation
New abstraction in
shell/common/shorebird/:protected_data.h— declaresvoid StartUpdateWhenProtectedDataAvailable(std::function<void()> start_fn).protected_data.cc— default implementation used on every platform except iOS. Callsstart_fnimmediately since there is nothing to wait for on Android, desktop, or embedded platforms.protected_data_ios.mm— iOS implementation. Dispatches to the main queue (required forUIApplication.sharedApplicationaccess), checksprotectedDataAvailable, and either:start_fnimmediately if protected data is available now, orUIApplicationProtectedDataDidBecomeAvailableNotificationthat unregisters itself and callsstart_fnonce the notification fires (typically the first time the user unlocks the device after boot).Includes a fallback for app extensions (where
sharedApplicationis nil): in that case we start immediately rather than blocking forever, since app extensions do not have a full UIApplication lifecycle.BUILD.gnconditionally compiles the iOS.mmsource and linksFoundation.framework+UIKit.framework. Non-iOS builds pick up the default.cc.shorebird.ccroutes its existingUpdater::Instance().StartUpdateThread()call through the new helper. BothConfigureShorebird()overloads are updated identically.shorebird_unittests.ccgains a test that the default implementation invokesstart_fnsynchronously. The iOS path requires aUIApplicationto exercise and is covered by on-device integration testing rather than a unit test.Why not check this inside the updater (Rust) layer
UIApplication.sharedApplicationis main-thread-only. The updater runs on a background worker thread, so a direct call from Rust would violate Apple's threading rules. Doing it correctly from Rust would require a notification observer registered from the main thread at init plus a cached atomic — which means Obj-C bridging inside the updater crate and a new iOS-only dependency. The engine layer already has clean main-thread UIKit access.Complementary Rust-side fallback
A complementary defensive fallback in the updater Rust library (shorebirdtech/updater#336) catches the residual case where the state write still fails (e.g. if the device becomes locked between the availability check and the actual write, or if a manual
ShorebirdUpdater.update()call from Dart happens before first unlock). That PR translatesPermissionDeniedat the state-write boundary into a benignSHOREBIRD_UPDATE_DEFERREDstatus instead of an exception.The two layers together cover:
Test plan
StartUpdateWhenProtectedDataAvailable— verifies synchronous immediate invocation.start_fnsynchronously.Open questions / future work
UIApplicationProtectedDataWillBecomeUnavailableto pause any in-flight updater work? Probably not worth it for v1 — the Rust-side fallback handles the mid-update case.PermissionDeniedfailures drop to near-zero after this lands)?