A new Xcode shipped an app that could not launch

The first build we made with the new Xcode died at launch on every device running the new iOS. Nothing of ours ran, so nothing of ours reported it, and it read as churn.

· The Wordmere team · 5 min read

We found it on a phone in the house. Someone updated to the new iOS, opened the game, and it closed itself. Reinstalling did not help.

Our dashboards showed nothing wrong, and they were not broken. They were telling the truth about a population that no longer included anyone this was happening to.

A trap before any of our code

The phone had analytics sharing turned off, so there was no report from it. We reproduced it on a simulator running the same OS version and got a crash log within a minute.

The process died on a SIGTRAP inside a system function whose name says exactly what it is for: the runtime check for an app that has not adopted the scene lifecycle. The new iOS refuses to run an app that is linked against its SDK and still uses the old, pre-scene application lifecycle. It does not warn. It traps, at launch, before any application code executes.

That last part is the whole story. Not one line of ours ran. No analytics initialisation, no crash reporter, no remote config fetch. From our side the device simply stopped existing.

Why a working app broke without us touching it

Nothing about our lifecycle code had changed. What changed was the machine that built it.

The build in question was the first store build we had made with the new major version of Xcode. A new Xcode means a new SDK, and linking against a new SDK is how the operating system decides which generation of rules to hold you to. Everything we had shipped before was built against the previous SDK and was exempt — so players on the new iOS running our *older* release were completely fine, and players who had taken our newest update were locked out.

Updating the app was the thing that broke it. Staying behind was the safe choice. That is an unpleasant inversion to discover from a phone on your kitchen table.

The compiler upgrade was the change. Nothing in the diff for that release had anything to do with it, which is why nothing in the diff was ever going to explain it.

The cohort that looked like it left

Work out who this hit and the shape is nasty. Every player who had our newest build and then updated their phone disappeared from our data on the day they updated, permanently, with no event marking the transition.

In every retention report, those people are churn. They look exactly like players who got bored. The only way to tell the difference is to go looking at retention split by OS version and build, which nobody does routinely because it is a strange thing to suspect.

We have written before about a crash-free rate that was measuring the wrong population. This is the same disease with the symptom inverted. There, phantom sessions were dragging a number down. Here, the affected sessions vanished entirely and every number looked healthy. A metric that only sees the people your code reaches cannot report on the people it does not.

What the fix actually is

Adopting the scene lifecycle on a React Native app is a config plugin plus a small Swift file. You declare a scene manifest in the app's property list, and you add a scene delegate that creates the window and starts React Native inside the callback for a scene connecting, rather than in the old application-did-finish-launching path. The application delegate keeps the things that genuinely belong to the process — analytics and attribution initialisation — and deep links get forwarded through to the new entry point.

It is about an hour of work once you know. Our plugin is unit-tested against the real generated delegate file rather than a fixture we wrote ourselves, which matters because the generated file changes between framework versions and a fixture would quietly stop representing it.

The fix build that shipped without the fix

This is the part worth the post.

We wrote the plugin, built, and sent it out. It still died. The plugin had not run, because the app configuration edit that registers it had not landed properly, and nothing anywhere in our pipeline checks that a config plugin actually took effect. The build succeeded. The archive validated. The app was byte-for-byte as broken as before.

Config plugins are code that edits generated native projects during a build you do not watch. They can silently not apply. So the release script now opens the built app, reads the property list, and refuses to continue if the scene manifest is not there.

That is the same lesson as several other things that have bitten us: assert against the artefact, not against the source that was supposed to produce it.

Getting word to people whose app will not start

There is a genuinely hard problem underneath the bug. The affected players could not be reached by remote config, because remote config is fetched by our code and our code never ran. There is no in-app message surface in an app that cannot launch.

Push notifications were the only channel left, since those are handled by the system before the app starts, and we used them — targeted at the specific broken build — to tell people an update was waiting. It worked for the minority who had granted notification permission. For everyone else there was no mechanism at all; they got the fix whenever they happened to update.

If you take one operational thing from this: the population you most need to reach in an emergency is the one your own tooling is least able to reach, and push permission is the only lever that still works when the app itself is dead.

Install the new simulator on day one

The preventable version of this is cheap and we were not doing it.

When a new major OS and a new major Xcode appear, install the matching simulator runtime and launch your existing app on it before you ship anything built with the new toolchain. Not a test suite — just open the app and see it draw a screen. The crash reproduced on the first try on a simulator that took ten minutes to download.

A new Xcode major is not a build-tooling upgrade. It is an opt-in to a new set of operating system rules, applied the moment the build is linked, and the first thing it can take away from you is launch.