Crash-protection topic
Crash protection
English | 简体中文
How flutter_ota_kit automatically rolls back when a patch goes wrong,
and how it prevents the same bad patch from being loaded again.
If a patch causes a boot failure or a serious Dart-level error during the early UI, the SDK rolls back to the APK's built-in version on the next cold start and adds the offending patch to a local blacklist.
The whole decision happens on the client without depending on the server. You should still pair it with staged rollouts, crash monitoring, and a server-side kill switch for production — see Monitoring recommendations.
For the broader patch flow, see Architecture. For the public API surface, see API Reference → Boot diagnostics.
Default behavior
The default policy is fail-fast:
Once a patch is confirmed to fail even once after loading, it is dropped and added to the local blacklist.
On the next cold start the app falls back to the built-in version of the APK. The SDK does not retry the same patch, to avoid spreading the failure across more users.
await FlutterPatcher.init(
maxCrashCount: 1, // recommended
verifyAfter: const Duration(seconds: 5), // post-first-frame watch window
);
| Parameter | Default | Description |
|---|---|---|
maxCrashCount |
1 |
Number of consecutive failures before the patch is tripped and blacklisted. Recommended production value. |
verifyAfter |
5 seconds |
Window during which the post-first-frame Dart error hooks keep watching. |
You can raise maxCrashCount, but it's rarely a good idea in
production. Once a patch is known to fail boot, retrying typically
just amplifies the impact.
What counts as a failure
The SDK tries to distinguish "the patch broke us" from "the user / system caused a normal exit".
Counts toward the circuit breaker
- App crashes —
REASON_CRASH - Native crashes —
REASON_CRASH_NATIVE(a.soload failure, JNI crash, or native plugin crash) - ANRs —
REASON_ANR - Initialization failures —
REASON_INITIALIZATION_FAILURE(e.g. JNI / loader errors before any Dart code runs) - Serious Dart errors during early launch / first frame
- Dart errors caught by the framework that nonetheless leave the first frame blank or unusable
Does NOT count
- The user swiping the app away from recents
- The user pressing Home to background the app
- The user force-stopping the app from system settings
- The system reclaiming the process under memory pressure
- Non-first-frame exceptions during normal business flow
The signal quality varies across Android versions; see Android version differences.
Boot success window
Whether a patch is "stable" is decided across two phases. The first phase is a hard requirement; the second is a graceful extension.
┌────────────────────────────────────────────────────────┐
time │ │
↓ ▼ │
────────┼──── markBooting() ──── first frame ─── verifyAfter ──┴─ patch "stable"
│ [set patch_loading=true] [set false] [tear down hooks]
│
on failure inside the verifyAfter window → queue rollback for next boot
on exit before first frame (API 30+) → ApplicationExitInfo
1. First-frame render (hard requirement)
After the patch loads, if the app reaches the first frame, the boot is treated as initially successful and any in-flight circuit-breaker state is cleared.
This avoids misclassifying as patch failures:
- Pressing Home right after the first frame
- Swiping the app from recents shortly after launch
- The system reclaiming the process in the background
2. verifyAfter watch window (graceful extension)
After the first frame, the Dart error hooks keep watching for
verifyAfter (default 5 seconds). The window catches serious
Dart-level failures during the first-screen experience:
- Tapping immediately on the first screen triggers an exception
- The framework caught an exception but the page rendered blank
- Critical first-screen logic threw and left the app unusable
verifyAfter only accumulates while the app is foregrounded. After
the window closes, business-level errors no longer feed back into the
circuit breaker.
Android version differences
Android's signal for "why did the process exit" varies by version.
Android 11+ (API 30+)
Android 11+ supports ActivityManager.getHistoricalProcessExitReasons,
which lets the SDK distinguish:
- Real Dart / framework crashes (
REASON_CRASH) - Native crashes (
REASON_CRASH_NATIVE) - ANRs (
REASON_ANR) - Initialization failures (
REASON_INITIALIZATION_FAILURE) — e.g. JNI / loader errors before any Dart code runs - User-initiated stops, low-memory reclaims, and external SIGKILLs are NOT counted
Looking up the previous session is keyed by the pid we recorded
on attachBaseContext, which the SDK deliberately retains across
successful boots. That's what lets the next cold start still
attribute a post-first-frame native crash to the patch — without
it we'd only catch failures before the patch-loading flag was
cleared.
Android 10 and below
Android 10 and below do not have ApplicationExitInfo. The SDK falls
back to a patch_loading flag persisted in SharedPreferences to
determine "did the previous launch die mid-patch-load".
This means:
- Boot failures before the first frame are detected (the flag is
still
true). - Known blind spot: native crashes or ANRs that happen after the
first frame are not attributable — the flag was already cleared by
markBootSuccess. The Dart-error hooks still cover this window for Dart-level failures. - Dart errors inside the
verifyAfterwindow are still caught by the error hooks.
If your traffic on API < 30 is significant, plug in your existing crash-monitoring system and stop delivering the bad patch from the server side as soon as you see it.
ApplicationExitInfo reason mapping (API 30+)
reason constant |
Counts as crash? |
|---|---|
REASON_CRASH |
✅ Yes |
REASON_CRASH_NATIVE |
✅ Yes |
REASON_ANR |
✅ Yes |
REASON_INITIALIZATION_FAILURE |
✅ Yes |
REASON_USER_REQUESTED |
❌ No |
REASON_USER_STOPPED |
❌ No |
REASON_LOW_MEMORY |
❌ No |
REASON_OTHER |
❌ No |
REASON_SIGNALED (e.g. SIGKILL) |
❌ No |
Blacklist
A patch that triggers an automatic rollback is recorded in the local
blacklist with the composite key (version, md5).
What this means:
- The same patch will be rejected if delivered again
- If you reuse the same
versionfor a new fix, a different MD5 is still allowed - Manual
rollback()does not add the patch to the blacklist - The blacklist persists across APK upgrades, in case the server forgets to delist a known-bad patch
Missing MD5: when the server does not ship
md5(PatchInfo.md5 == ''), the pre-download blacklist check degrades to a version-only match — any blacklist entry sharing thisversionis enough to reject the patch. On the native side the entry'smd5field is filled with the actual md5 computed after download, which keeps it useful for triage.
The blacklist uses FIFO eviction with a cap of 50 entries; older records are dropped beyond that.
Inspect the blacklist
final entries = await FlutterPatcher.blacklist;
for (final e in entries) {
print('${e.version} / ${e.md5} / ${e.reason} / ${e.blacklistedAt}');
}
Clear the blacklist
await FlutterPatcher.clearBlacklist();
clearBlacklist() is for debugging — don't expose it to ordinary users
in production. Blacklist entries are also dropped automatically when
FIFO evicts them (after 50 entries).
Configuration
Crash-protection settings live in FlutterPatcher.init():
await FlutterPatcher.init(
maxCrashCount: 1,
verifyAfter: const Duration(seconds: 5),
);
maxCrashCount
Number of consecutive failures before the patch is tripped and blacklisted.
Default: 1. This is the recommended production value. Higher
values increase the chance of a bad patch reaching more users before
it's blocked.
verifyAfter
The window during which the post-first-frame Dart error hooks keep watching.
Default: 5 seconds. Raise it if your first-screen initialization or interactions are slow; lower it if you only care about the very early window.
Monitoring recommendations
Client-side crash protection is the last line of defense. In production, also monitor and act server-side.
1. Report boot diagnostics
After every cold start, read lastBootDiagnostic and report any
non-healthy state to your analytics backend. The API and field shapes
are documented in API Reference → Boot diagnostics.
Watch these states in particular:
| Status | Meaning | Action |
|---|---|---|
droppedCircuitBreaker |
Patch tripped the circuit breaker | Strong alert; stop delivering |
droppedSignatureInvalid |
Signature verification failed | Alert; investigate the source |
droppedMd5Mismatch |
Local file MD5 does not match the recorded MD5 | Report and investigate |
droppedMetaCorrupted |
Patch metadata is corrupt | Report and investigate |
hookInstallFailed |
FlutterLoader hook failed to install | Check Flutter version compatibility |
// In your app's boot path:
final diag = await FlutterPatcher.lastBootDiagnostic;
if (diag != null && diag.outcome != BootOutcome.success) {
await myAnalytics.track('boot_outcome', {
'outcome': diag.outcome.name,
'version': diag.version,
'timestamp': diag.timestamp.toIso8601String(),
});
}
2. Server-side automatic delisting
If the same patch produces multiple droppedCircuitBreaker events
in a short window, the server should automatically stop returning
that patch.
Useful dimensions to consider:
- Patch version
- MD5
- Target APK
versionCode - ABI
- Device Android version
- App version
- Time window
3. Staged rollout
A typical ramp:
1% → 5% → 20% → 50% → 100%
Watch crash rate, boot-failure rate, and the key business metrics at each stage. If anything looks wrong, stop delivering the patch immediately — the in-flight users will be protected by the next cold start, and the at-risk users will be protected by the blacklist.
4. Emergency rollback
An emergency rollback only needs the check-update endpoint to stop returning the bad version. Devices that already triggered crash protection have rolled back locally and will refuse to load the same problematic patch again.
# Roll production back to the previous stable bundle
flutter-ota rollback -b supabase -c production --bundle-id 01a059a5-...
# Or just stop returning the bad bundle entirely
flutter-ota bundle disable -b supabase --id 01a059a6-...
Debugging
Logcat
Crash-protection logs use this tag:
adb logcat | grep FlutterPatcher/Guard
You should see entries like:
D/FlutterPatcher/Guard: markBooting() pid=12345
D/FlutterPatcher/Guard: first frame rendered, markBootSuccess()
D/FlutterPatcher/Guard: Dart error in verifyAfter window, count=1
D/FlutterPatcher/Guard: next boot will rollback
D/FlutterPatcher/Guard: ApplicationExitInfo REASON_CRASH for pid 12345
D/FlutterPatcher/Guard: circuit breaker tripped, blacklisting patch
Diagnostic card
example/lib/diag_card.dart renders the diagnostic fields as a visual
card. While debugging on a real device you can directly see:
- Current patch state (installed / not installed / blacklisted)
- The most recent boot diagnostic
- All blacklist entries
- The reason for the most recent rollback
Tap any field to see the raw value; long-press to copy.
Forcing a bad-patch test locally
To verify the rollback path works on a real device:
# 1. Apply a known-good patch first (so the install path is exercised)
flutter-ota build --name 1.0.1 --platform android --arch x86_64
flutter-ota deploy -b supabase -s dist -c production -p android \
--target-app-version 1.0.0 --force
# 2. Now deliberately break a Dart file and build again
echo "void main() => throw 'intentional crash';" > lib/main.dart
flutter-ota build --name 1.0.2
flutter-ota deploy -b supabase -s dist -c production -p android \
--target-app-version 1.0.0 --force
# 3. Open the app → crash → close → reopen → 1.0.1 is back
This is the only way to truly verify the rollback. Unit tests can't exercise the native boot hook.
Implementation details (for contributors and the curious)
This section is for people modifying the crash-protection logic or debugging edge cases. The first half of the doc is what you need to use the system.
Circuit-breaker timeline
The state machine lives in
android/src/main/kotlin/com/flutter_ota_kit/flutter_ota_kit/CrashGuard.kt.
Keys are SharedPreferences-backed: KEY_PATCH_LOADING, KEY_CRASH_COUNT,
KEY_LAST_BOOTING_PID.
| When | What happens |
|---|---|
Application.attachBaseContext |
markBooting(): write patch_loading=true and the current pid (synchronous commit()). |
Dart FlutterPatcher.init() |
Defensive fallback write of patch_loading=true if the native write failed. |
| First frame rendered | markBootSuccess(): clear patch_loading and crash_count. Keep KEY_LAST_BOOTING_PID so the next cold start can still query ExitInfo for this pid. Start the verifyAfter timer. |
Foreground time accumulates verifyAfter |
Tear down the Dart-error-hook watch window. |
| Dart error hook fires inside the window | Count one failure and queue a rollback on the next cold start. |
Next cold start shouldLoadPatch |
On API 30+ with a known pid, query getHistoricalProcessExitReasons for the precise exit reason. Otherwise fall back to patch_loading. |
crash_count >= maxCrashCount |
Delete the patch directory, add to the blacklist, fall back to the APK's built-in version. |
Dart-side blank-screen safety net
A bad patch doesn't always crash the process. A Dart-level throw
caught by the framework can leave the process alive but the screen
blank or unusable.
To handle that, init() installs Flutter-level error hooks during
the verifyAfter window:
PlatformDispatcher.instance.onErrorFlutterError.onError
Either firing inside the window counts as one patch failure and queues a rollback on disk.
The SDK does not install Android's
Thread.UncaughtExceptionHandler. Native (JNI /.so) crashes are caught indirectly on the next cold start viaApplicationExitInfo(API 30+) or thepatch_loadingfallback (API < 30). Recovery is always cross-process; the current process has already loaded the patched.soand cannot revert without a restart.
After the window closes, both hooks still forward transparently to any prior handler but stop reporting circuit-breaker events to the native side.
Why KEY_LAST_BOOTING_PID survives boot success
The naive design would clear all state after a successful boot. We
deliberately keep the pid of the last successful boot so the next
cold start can still query getHistoricalProcessExitReasons for that
pid. Without this retention, a native crash that happens 2 seconds
after first frame would be invisible to the next boot — the pid would
be gone, and the query would return empty.
The cost is ~8 bytes of SharedPreferences storage. The benefit is accurate attribution of post-first-frame native crashes on Android 11+.
See also
- API Reference —
rollback(),blacklist, and boot diagnostics - Production Playbook — emergency rollback and monitoring
- Configuration — signing and verification options
- Architecture — full system design
Classes
- FlutterPatcher Architecture API-reference Crash-protection
- Android-only Flutter hot-update entrypoint.