Skip to content

Fix fresh-install launch failure: canonical store, prompt promotion, honest splash#99

Merged
kyleve merged 16 commits into
mainfrom
fix/fresh-install-launch-failure
Jul 17, 2026
Merged

Fix fresh-install launch failure: canonical store, prompt promotion, honest splash#99
kyleve merged 16 commits into
mainfrom
fix/fresh-install-launch-failure

Conversation

@kyleve

@kyleve kyleve commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Problem

On a fresh install, the app sat on a blank white screen for ~30 seconds, flashed the full-screen "Couldn't finish launching — (SwiftData.SwiftDataError error 1.)" failure, then recovered into onboarding. Diagnosed from a device screen recording plus the simulator unified log.

Three separate defects lined up:

  1. A store-creation race at t≈0. The launch's open-store step and RegionSpotlightIndexer (via the intent layer's self-opened store) each opened their own ModelContainer over the same not-yet-existing App Group store file (two "Opened SwiftData store" log lines 1ms apart). On a fresh install the launch's open lost the race, stalled, and threw SwiftDataErrorLifecycleFailureView.
  2. Promotion ran too late. The launch reason is misdetected as background(.other) on scene-based cold launches (applicationState reads .background in didFinishLaunching), so the container rendered EmptyView. With the scene already .active at first render, .onChange never fires — and RootView's fallback promotion ran only after await launcher.run(), i.e. behind the whole stuck drive. Hence the blank screen with no splash.
  3. A superseded drive could clobber phase. A cancelled drive's in-flight step that threw a real error still wrote phase = .failed over the promoted drive's state.

Changes

  • One store open per process, structurally enforced — with no singletons of ours. The launch's open-store step performs the process's only store open; everything else reaches it by injection. WhereServices retains the store, attributor, aggregator, and clock it was built with, and WhereServices.forIntents(sharingStoreOf:) — synchronous, non-throwing, and the only way an intents stack is built — derives the GPS-free App Intents stack from them. AppDelegate (the composition root) owns the one IntentServices handoff, registers it with the App Intents framework's AppDependencyManager in didFinishLaunching, and installs the stack into it via a new onServicesReady hook on WhereLaunch.makeLauncher that re-fires on every session (re)start (first launch, retry after failure, reset relaunch). Intents and the entity query resolve the handoff with @Dependency; current() parks (cancellation-aware) until installation instead of ever opening a store. Intent writes ping the same changes() signal the UI refreshes from; Spotlight indexing moved after the launch drive and receives the handoff by parameter. (Two interim shapes — a SwiftDataStore.canonical() get-or-create and an IntentServices.shared static — were reviewed away in favor of this; see the review thread.)
  • LifecycleKit: a superseded drive whose step throws now reports .cancelled instead of writing .failed; WhereBootstrap.makeServices() catch-logs-rethrows so a discarded failure still leaves a trace.
  • RootView: promote an already-active scene before awaiting launcher.run(), so the splash appears within the first frames instead of after the whole background drive.
  • Splash caption: replaced "Updating your data…" with launch-neutral copy ("Getting things ready…", new launch.caption.* keys) — the caption fires on any slow launch (migration, first store creation, plain slowness), so it no longer claims a specific cause on a first run.
  • Docs: root AGENTS.md gains a "Composition: create once, inject down" conventions section distilling the lesson (no singletons; use the platform's DI seam for platform-instantiated consumers, and keep it a handoff-not-factory; derive-don't-re-derive; re-fire hooks on lifecycle re-creation). Where/TODOs.md records the launch-reason misdetection for a proper LifecycleKit-level fix; module docs updated to the injection model.

Testing

  • New regression tests: forIntents(sharingStoreOf:) shares the base's container/clock and writes are visible across stacks (WhereServicesForIntentsSharingTests), the IntentServices handoff semantics — install / park / cancel / replace — on hermetic per-test instances (IntentServicesTests), the onServicesReady hook firing on launch and again on the reset relaunch (WhereLaunchTests), and the superseded-drive phase-clobber (LifecycleRunnerForegroundPromotionTests).
  • Full Stuff-iOS-Tests scheme green locally (iPhone 17, iOS 26.2 simulator); ./swiftformat --lint clean.
  • Best end-to-end check: a fresh device install should now show the splash immediately, no failure flash, and a single "Opened SwiftData store" log line — and Siri intents still resolve (now via @Dependency → the registered handoff).

kyleve added 9 commits July 16, 2026 18:10
On a fresh install, the launch's open-store step and the Spotlight
indexer (via IntentServices -> WhereServices.forIntents()) each opened
their own ModelContainer over the same not-yet-existing App Group store
file, racing to create it; the launch's open could stall and throw
SwiftData.SwiftDataError, flashing the 'Couldn't finish launching'
failure UI (observed on device; double open confirmed in the unified
log).

Add SwiftDataStore.canonical() — an actor-serialized get-or-create of
the process's single Storage.default store — and resolve it from both
WhereBootstrap.makeServices() and forIntents(), so production wiring
shares one container per process and intent writes ping the same
changes() signal the UI refreshes from. make()/inMemory() stay ordinary
factories for deliberately independent opens (debug tooling, tests).
AppDelegate now indexes Spotlight regions after the launch drive
finishes instead of concurrently with it.

Plan step: Share the canonical on-disk SwiftDataStore.
A cancelled (superseded) drive's in-flight step isn't required to be
cancellation-responsive: it can keep working after enterForeground()/
teardown() cancels its drive and then throw a real error. runStep's
catch wrote phase = .failed regardless, letting a dying drive stomp the
superseding drive's state (observed as a flash of the launch failure UI
during the fresh-install store race). Report .cancelled instead when the
drive task is already cancelled — the new drive re-runs the step and
surfaces its own outcome.

Plan step: LifecycleKit cancelled-drive phase clobber.
When a cold launch is misdetected as headless (scene-lifecycle
applicationState reads .background in didFinishLaunching) and the scene
is already .active by the time RootView appears, .onChange(of:
scenePhase) never fires — so the only promotion path was the .task's
check, which ran *after* awaiting launcher.run(), i.e. behind the entire
background drive. The user stared at the container's empty background
surface (a blank screen) until the drive finished; on the fresh-install
store stall that was ~30 seconds with no splash (observed on device).

Promote first, then await run(): enterForeground() is a no-op for a
foreground launch and re-drives a misdetected background one, so the
splash appears within the first frames.

Plan step: RootView promotion order.
…ches

The slow-launch caption promises a data update, but a launch that hasn't
onboarded yet (fresh install, post-reset relaunch) has no data to update
— and a fresh install's very first store creation routinely outlives the
500ms caption delay, so new users saw migration copy that read like an
error state. LaunchSplashView now takes showsDataCaption and RootView
passes model.hasOnboarded, read live so the post-reset relaunch is
covered too; the radar pulse alone carries 'working' on those launches.

Plan step: first-run caption suppression.
Docs-only groundwork: files the applicationState-is-.background-at-
didFinishLaunching misclassification (every cold user launch drives
headless first and gets promoted) with the evidence and constraints, so
the LifecycleKit-level fix isn't lost.

Plan step: record launch-reason misdetection.
…gating

Review follow-up (finding 1): the hasOnboarded gate only covered the
pre-onboarding stint — completeOnboarding() flips the flag before the
launch continues, so the post-onboarding splash re-armed the caption
and a slow first run could still read 'Updating your data…'. The copy
was always a guess about *why* a launch is slow (migration, first store
creation, or plain slowness), so replace it with neutral copy ('Getting
things ready…', keys launch.caption.*) and remove the showsDataCaption
gate entirely — the caption is now honest on every launch, including
first runs and post-reset relaunches.
Review follow-up (finding 2): with the runner deliberately discarding a
superseded drive's error (no .failed clobber), a store-open failure in a
cancelled open-store step left no trace anywhere — makeServices didn't
log on throw and neither does SwiftDataStore.make. Catch-log-rethrow via
WhereLog so the failure is always observable, superseded or not; the
'WhereServices assembled' info line also moves after assembly actually
succeeds.
Review follow-up (finding 3): production wiring resolves canonical();
make(storage:) is the independent-open factory (sibling processes, debug
tooling, tests). Docs-only.
Review follow-up (finding 4): forIntents() is now safely testable (the
canonical store resolves in-memory under tests), so drop the stale
'opens the real App Group store' comment and add a serialized suite
asserting forIntents() shares the canonical container — in both orders
(launch first, intent first) — pinning the fresh-install creation-race
regression this branch fixes.
/// is a reuse default, not a lock-out: a deliberately independent open
/// (debug tooling, tests) keeps using `make(storage:)` / `inMemory()`,
/// which stay ordinary factories with no registration side effects.
public static func canonical() async throws -> SwiftDataStore {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like making this a static, we should avoid singletons. Let's instead initialize the store once and inject it where it needs to go.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Addressed in 598ff3b: the singleton is gone. SwiftDataStore.canonical() and its process-global vendor are removed; the store is opened exactly once by the launch's open-store step, and everything else gets it by injection — WhereServices.forIntents(sharingStoreOf:) builds the GPS-free intents stack over the store an existing service layer holds, and AppDelegate installs that stack into IntentServices.shared.install(_:) after the launch drive finishes (Spotlight indexing rides it). The parameterless forIntents() remains only as the fallback for an intent racing app startup, back on its explicit .localOnly open. The serialized canonical test suites became hermetic injection tests.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kyleve's behalf.

Follow-up in fbff56b: the residual two-store window is gone too. The parameterless forIntents() (the self-open fallback) is deleted — forIntents(sharingStoreOf:) is the only way an intents stack is built and it never opens a store. IntentServices.current() now parks (cancellation-aware) until the launch installs the stack, and installation moved into the open-store step via an onServicesReady hook on WhereLaunch.makeLauncher, so it re-fires on retry-after-failure and reset relaunches. The launch's open is structurally the process's only store open.

kyleve added 4 commits July 17, 2026 09:35
PR feedback: avoid the process-global singleton. SwiftDataStore.canonical()
and its vendor are gone; the store is opened exactly once by the launch's
open-store step (back on a detached task), and everything else shares it
by injection:

- WhereServices.forIntents(sharingStoreOf:) builds the GPS-free intents
  stack over the store an existing service layer already holds.
- After the launch drive finishes, AppDelegate derives that stack from
  the session's services and installs it via the new
  IntentServices.shared.install(_:); Spotlight indexing then rides it.
  WhereSession.services is public so the app target can reach the seam.
- The parameterless forIntents() remains only as the fallback for an
  intent racing app startup, back on an explicit .localOnly open.

Tests swap the serialized canonical suites for hermetic ones pinning
that the shared stack rides the base services' container (and that
writes through it are visible to the base). Docs updated to the
injection model.
…open

PR feedback follow-up: even the rare fallback double open is gone. The
parameterless WhereServices.forIntents() (which opened its own .localOnly
container) is deleted — forIntents(sharingStoreOf:) is now the only way
an intents stack is built, and it never opens a store.

IntentServices becomes a pure handoff: current() returns the installed
stack or parks (cancellation-aware continuation) until the launch
installs one; it can no longer assemble anything. Installation moves
into the open-store step via a new onServicesReady hook on
WhereLaunch.makeLauncher/sequence, so it re-fires on every session
(re)start — first launch, retry after a failed launch, and the reset
relaunch — and completes before the launch proceeds. AppDelegate wires
the hook (derive stack, install); an intent racing a failed launch parks
until Try Again succeeds, bounded by the system's intent time limit.

New IntentServicesTests (hermetic instances: install/park/cancel/replace
semantics, condition-based waits via a waiterCount probe) and
WhereLaunchTests coverage that open-store hands the session's services
to the hook on launch and again on the reset relaunch.
Review follow-ups:

- forIntents(sharingStoreOf:) is now synchronous and non-throwing: it
  reuses the base layer's attributor, aggregator, and clock (newly
  retained on WhereServices) instead of re-reading tracked regions
  through make(). The failure mode where a hook error left every intent
  parked for the process lifetime is unrepresentable, AppDelegate's
  catch-and-log disappears, and the derived stack no longer duplicates
  a live RegionAttribution + changes() subscription — or diverges from
  an injected clock (new sharedStackInheritsTheBaseClock test).
- WhereSession.services reverts to internal: the onServicesReady hook
  delivers the services, so the public widening from the intermediate
  design was dead API.
- WhereIntents/AGENTS.md testing section now forbids calling an
  intent's perform() in tests: it resolves IntentServices.shared, which
  parks with nothing installed in the test host (or silently rides a
  stack another bundle's app-launch test left behind).
- makeForIntents is re-documented as the test seam it now is.
Distills the architecture lesson from the fresh-install store-race fix:
shared resources are created once at the composition root and injected
(never re-resolved from globals); platform-instantiated consumers get a
handoff seam that installs and awaits rather than a self-creating
fallback; derived stacks reuse what the base computed so wiring can't
fail; and composition hooks re-fire wherever the lifecycle re-creates
the thing. Sits beside 'Modeling state' as its ownership/lifetime
counterpart. Docs-only.
Comment thread Where/Where/Sources/AppDelegate.swift Outdated
kyleve added 3 commits July 17, 2026 11:48
…leton

PR feedback: IntentServices.shared is gone. AppDelegate — the
composition root — owns the one IntentServices instance, registers it
with the App Intents framework's dependency container
(AppDependencyManager.shared.add, synchronously in didFinishLaunching,
before the system can deliver an intent), and installs the store-sharing
stack into it via the onServicesReady hook. Every intent and the region
entity query resolve it with @dependency; RegionSpotlightIndexer (called
by us, not the system) receives it by plain parameter injection.

The handoff semantics are unchanged (install / park / cancel /replace);
we just no longer own any global — the one static seam is the platform's
purpose-built DI container. Root AGENTS.md's composition section and the
WhereIntents docs now describe registering with the platform seam;
the testing rule tightens to '@dependency traps when nothing registered'.
PR feedback: replace the local-binding workaround with the explicit
provider closure — the capture list carries the Sendable actor reference
([intentServices = self.intentServices]), which the compiler accepts and
SwiftFormat's redundantSelf rule leaves alone, so the registration is
format-stable without an intermediate let.
Review follow-up: attempted the registration→resolution smoke test and
it cannot exist — AppDependencyManager fatal-errors on any @dependency
access outside 'the intent perform flow' (probe trapped with exactly
that message: 'Dependency values can only be accessed inside of the
intent perform flow and within types conforming to
_SupportsAppDependencies'). The attempt did pay for itself: it proved
a closure-literal registration lands on the async-throwing provider
overload and still resolves inside the framework's flow is unverifiable
in-process either way, so the registration keeps the provider-closure
spelling and the constraint is now documented at the registration site,
in the launch test, and in WhereIntents/AGENTS.md — along with the
double-registration caveat for the app-hosted test bundle. Verifying
the seam means invoking a Siri/Shortcuts intent on a device.
@kyleve
kyleve merged commit 7e78b1a into main Jul 17, 2026
2 checks passed
kyleve added a commit that referenced this pull request Jul 17, 2026
Conflict resolutions:
- AGENTS.md, Where/TODOs.md: clean union of both sides' additions
  (main's synthesized-Codable + composition-root sections and new TODO
  entries alongside our snapshot-testing docs and flakiness ledger).
- LaunchSplashView.swift: main's honest-splash copy (launch-neutral
  "Getting things ready…" caption strings + doc) combined with our
  snapshot determinism work (@MotionIsStatic, radar phase pinned to 0
  under \.isCapturingSnapshot, previewShowsCaption seam).
- PreviewSupport.swift: main's FlightDayIssue sample fixture kept
  alongside our pinned referenceNow dates, pre-onboarded loadedModel,
  and Noop scheduler injection.
- SettingsView.swift: main's "Find issues now" scan section combined
  with our SnapshotDatePickerStandIn capture substitution.
- ScreenHostingTests.swift (modify/delete): kept our deletion — the
  hosting smoke tests were superseded by WhereUISnapshotTests. Main's
  new flightDayDetailViewHosts coverage is ported as a snapshot case
  instead: FlightDayDetailView gains a SnapshotProviding conformance
  (day pinned to referenceNow) and a ScreenSnapshotTests.flightDayDetail
  test with 10 new references.

Re-recorded references (all legitimate upstream UI changes):
- launchSplash.SlowLaunchCaption_iPhone{,_dark}: caption copy changed
  from "Updating your data…" to "Getting things ready…" (#99).
- settings.Default_iPad{,_dark,_contrast,_accessibility}: new
  "Find issues now" row in the Data resolution section (#90); the
  iPhone variants are unchanged because that section sits below the
  captured fold there.
- resolution.WithIssues_{iPhone,iPad}{,_dark,_contrast,_accessibility}
  and resolution.WithIssues_iPad_ax5: new "Flights" section renders the
  flight-day sample issue (#90); iPhone_ax5 unchanged (below the fold).

Stuff-iOS-Tests passes. Snapshot suite: failures triaged in a full
diagnostic run, re-recorded references verified green with a targeted
run; the final all-green full-suite pass was skipped at user request —
PR CI serves as the full-suite verification.

Co-authored-by: Cursor <cursoragent@cursor.com>
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