Skip to content

shorebird: gate auto-updater kickoff on iOS data protection availability#126

Closed
eseidel wants to merge 1 commit into
shorebird/devfrom
shorebird/gate-update-on-protected-data
Closed

shorebird: gate auto-updater kickoff on iOS data protection availability#126
eseidel wants to merge 1 commit into
shorebird/devfrom
shorebird/gate-update-on-protected-data

Conversation

@eseidel
Copy link
Copy Markdown

@eseidel eseidel commented Apr 8, 2026

Summary

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 (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() on UIApplication.protectedDataAvailable so 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 — declares void StartUpdateWhenProtectedDataAvailable(std::function<void()> start_fn).

  • protected_data.cc — default implementation used on every platform except iOS. Calls start_fn immediately 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 for UIApplication.sharedApplication access), checks protectedDataAvailable, and either:

    1. Calls start_fn immediately if protected data is available now, or
    2. Registers a one-shot observer for UIApplicationProtectedDataDidBecomeAvailableNotification that unregisters itself and calls start_fn once the notification fires (typically the first time the user unlocks the device after boot).

    Includes a fallback for app extensions (where sharedApplication is nil): in that case we start immediately rather than blocking forever, since app extensions do not have a full UIApplication lifecycle.

  • BUILD.gn conditionally compiles the iOS .mm source and links Foundation.framework + UIKit.framework. Non-iOS builds pick up the default .cc.

  • shorebird.cc routes its existing Updater::Instance().StartUpdateThread() call through the new helper. Both ConfigureShorebird() overloads are updated identically.

  • shorebird_unittests.cc gains a test that the default implementation invokes start_fn synchronously. The iOS path requires a UIApplication to exercise and is covered by on-device integration testing rather than a unit test.

Why not check this inside the updater (Rust) layer

  1. Threading. UIApplication.sharedApplication is 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.
  2. Layering. Keeping the updater platform-agnostic means it stays testable on any host and iOS-specific concepts never leak into Rust code.
  3. Avoid wasted work. Gating at the engine layer means we do not even start the updater thread until it can actually succeed — no wasted network, no wasted download, no wasted state read.

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 translates PermissionDenied at the state-write boundary into a benign SHOREBIRD_UPDATE_DEFERRED status instead of an exception.

The two layers together cover:

  1. Auto-update kickoff on a locked device → this PR prevents the updater from starting.
  2. Manual Dart-initiated update on a locked device → Rust fallback translates the failure into a non-error status.
  3. Device becomes locked mid-update → Rust fallback catches the eventual state-write failure.

Test plan

  • Unit test for the default (non-iOS) implementation of StartUpdateWhenProtectedDataAvailable — verifies synchronous immediate invocation.
  • iOS on-device verification on a freshly booted, still-locked device: confirm that the updater does not start until unlock, and that it starts immediately if data is already available.
  • Non-iOS platforms (Android, macOS, desktop): no behavior change expected — default impl calls start_fn synchronously.
  • Full engine build + tests (CI).

Open questions / future work

  • Should the engine also respond to UIApplicationProtectedDataWillBecomeUnavailable to pause any in-flight updater work? Probably not worth it for v1 — the Rust-side fallback handles the mid-update case.
  • Can we instrument this path so we can confirm in telemetry that the hypothesis holds (i.e. that PermissionDenied failures drop to near-zero after this lands)?

@eseidel eseidel force-pushed the shorebird/gate-update-on-protected-data branch 3 times, most recently from e0cf619 to 7c59c93 Compare April 8, 2026 06:20
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.
@eseidel eseidel force-pushed the shorebird/gate-update-on-protected-data branch from 7c59c93 to 7fffb3a Compare April 8, 2026 06:25
@eseidel
Copy link
Copy Markdown
Author

eseidel commented Apr 8, 2026

Closing without landing. The Rust-side fallback in shorebirdtech/updater#336 is sufficient on its own:

  • It covers both entry points (engine auto-update kickoff and manual ShorebirdUpdater.update() from Dart), not just the auto-update path that this PR gated.
  • It ships on the updater cadence rather than the engine cadence, so customers pick it up sooner.
  • The only thing this engine-layer PR added on top of ItemBuilder should also get the index of the item flutter/flutter#336 was avoiding a wasted network round-trip in the narrow window of "background Flutter launch before first unlock after boot" — a tiny slice of launches that doesn't justify the engine-layer complexity.
  • Landing the error-mapping at the Rust layer also preserves telemetry: the locked-device case will surface as SHOREBIRD_UPDATE_DEFERRED rather than disappearing silently at the engine layer, which keeps a signal for whether we ever need to revisit this.

If future telemetry shows the wasted-background-work case is actually material, we can revisit with a much simpler IsPlatformReadyForUpdate() predicate instead of resurrecting the ProtectedDataGate abstraction.

Superseded by shorebirdtech/updater#336.

Refs shorebirdtech/shorebird#3685.

@eseidel eseidel closed this Apr 8, 2026
@eseidel eseidel deleted the shorebird/gate-update-on-protected-data branch April 8, 2026 06:44
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.

Engine: gate auto-updater kickoff on iOS protected data availability

1 participant