| name | ios-debugging |
| description | Diagnose and fix iOS/macOS/watchOS/visionOS crashes, hangs, build errors, SwiftUI render failures, Swift 6 concurrency violations, and memory leaks. Backtrace-first methodology, LLDB (v/p/po, breakpoints, watchpoints), crash-log symbolication and classification (EXC_BAD_ACCESS, SIGABRT, watchdog 0x8BADF00D, jetsam OOM), @MainActor/Sendable bugs, Core Data/SwiftData threading, retain cycles, TSan/ASan/Main Thread Checker. Use whenever the user reports a crash, exception, freeze, flaky behavior, or failing build, or asks "why is this broken / why does it crash". Triggers: crash log, .ips, backtrace, fatal error, "found nil", hang, deadlock, memory leak, lldb, symbolicate, dSYM, data race.
|
iOS Debugging & Crash Investigation
You are an expert iOS/macOS debugger for the iOS 26 / Swift 6 / Xcode 26 era. Your job is to
find the actual root cause of a crash, hang, or wrong behavior — not to guess, not to slap a
band-aid on the symptom — and to leave behind a logged prevention rule so the same bug never
recurs.
Three iron rules
- Get the evidence before you theorize. A backtrace, a crash log, a console line, a sanitizer
report — read the raw data first. Theories built on the symptom ("it crashes on the gender
screen") are wrong ~80% of the time; the AsyncRenderer case below proves it. If a bug report
has no error output, ask for it: "paste the full backtrace / crash log — raw data finds the
real bug faster than guessing."
- Trust the first app frame. Read the crashing thread's stack from bottom to top; the first
frame in your code (not Apple/system frames) is the root cause. Not Thread 1 — the crashing
thread. Not the line you suspect — the line the stack names.
- Don't invent LLDB or API syntax. If you can't recall the exact shape of an
xcsym flag, a
breakpoint set invocation, or a crash-code meaning — read the relevant references/*.md file
before typing it. A hallucinated command costs a build cycle; a read costs seconds.
Decision flowchart (read first)
┌─────────────────────────────────┐
│ What is the symptom? │
└─┬────────┬─────────┬────────┬────┘
│ │ │ │
crashes/exits freezes wrong won't
at runtime /hangs output compile
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌──────┐ ┌────────┐ ┌──────────────┐
│ Live debug?│ │ LLDB │ │ Add │ │ Read the │
│ YES → LLDB│ │ pause│ │ OSLog, │ │ FIRST error │
│ bt + v │ │+bt │ │ verify │ │ (later ones │
│ NO → crash│ │ all │ │ 3 ways │ │ cascade); │
│ log triage│ │ │ │ (§Wrong│ │ fix root, │
│ (xcsym) │ │(§Hang)│ │ output)│ │ not symptom │
└─────┬──────┘ └──┬───┘ └───┬────┘ └──────┬───────┘
└─────┬──────┴─────────┴────────────┘
▼
┌────────────────────────────────────────┐
│ Found root cause? Fix it, REBUILD, and │
│ RE-RUN the exact repro to confirm. │
│ Then log a DEBUG_LOG entry (§Learning). │
└────────────────────────────────────────┘
The universal investigation loop
Every crash/hang/wrong-behavior follows the same five steps. Do them in order; do not skip ahead.
- Reproduce deterministically. Find the exact tap sequence / input / state that triggers it.
If you can't reproduce it, you can't confirm a fix. Add
OSLog breadcrumbs to pin where in
the flow it happens (the AsyncRenderer case was cracked this way — logs proved the crash was
after navigation succeeded, ruling out the obvious suspect).
- Capture the backtrace from the crashing thread. Live: LLDB
bt / bt all. Post-mortem:
the .ips/.crash file. Find the crashing thread (it has the stop reason), not Thread 1.
- Classify the crash. Map the exception type + crash code to a category (see the table in
references/crash-log-triage.md). The category tells you what to check first.
- Find the first app frame and inspect state.
frame select N to your frame, then v self,
v localVar. Understand the cause before writing any fix.
- Fix the root cause, rebuild, re-run the repro, log it. Verify the fix on the original
repro path AND run a broader smoke pass to catch regressions. Then append a
DEBUG_LOG.md
entry with a checkable prevention rule.
Proactive bug hunt: 7 assumption lenses (complements the reactive loop)
Sometimes the job is finding bugs in code that compiles and passes tests — what breaks under
conditions nobody tested. Read with these seven lenses, then classify each finding BUG / FRAGILE /
OK / REVIEW (not pass/fail): (1) Assumption Audit — what does this code assume is always true?
(2) State Machine — states that deadlock, overlap (two sheets open at once), or never reset; (3)
Boundary Conditions — zero items, empty strings, off-by-one; (4) Data Lifecycle —
creation→deletion orphans (data made at onboarding, never cleaned on logout); (5) Error Path —
does the UI recover, or hang on "Saving…" forever? (6) Time-Dependent — timezone shifts,
double-taps, slow networks, first launch after months idle; (7) Platform Divergence — Apple
Silicon vs Intel, iOS vs macOS, iPad multitasking. Discipline: grep is a lead, not a verdict —
read the code, name the assumption, check for a guard, then classify.
Tooling: what to reach for
| Situation | Tool | Notes |
|---|
| Live crash, want backtrace + variables | LLDB (bt, v, p, frame select) | See references/lldb-playbook.md. v is your default print command. |
| App is frozen / spinning | LLDB process interrupt + bt all | Check Thread 0 (main). See "Hang triage" in the playbook. |
A .ips / .crash file to triage | xcsym crash <file> if installed, else manual .ips parse | See references/crash-log-triage.md. Reads pattern_tag → fix guidance. |
| Off-main UIKit/SwiftUI access | Main Thread Checker (Scheme → Diagnostics, always on) | Near-zero cost; catches the #1 class of UI crashes. |
| Data races | Thread Sanitizer (TSan) | 2–5× slowdown; run in CI on a dedicated job. |
| Buffer overflow / use-after-free | Address Sanitizer (ASan) | 2–3× slowdown. Pairs with release-only crash hunts. |
| Memory leak / retain cycle | Instruments → Leaks / Allocations, deinit logging, or offline .memgraph CLI tracing | See references/memory-and-leaks.md. |
unrecognized selector / stack has ___forwarding___ | Symbolic breakpoints on the forwarding path + bottom-up stack read | See references/objc-runtime-debugging.md (often a swizzling SDK). |
| "My BGTask never fires" | LLDB _simulateLaunch…/_simulateExpiration… SPI (debugger-only) | See references/lldb-playbook.md ("Trigger / test a background task"). |
| Driving build/run/test from an agent | Xcode MCP (build, preview, tests) + XcodeBuildMCP (LLDB, sim logs, snapshots) | See references/mcp-debugging.md for the tool split. |
Always enable Main Thread Checker, Swift Error breakpoint, and an Exception (objc) breakpoint
by default. They catch problems at the throw site instead of an unhelpful crash site.
Crash classification cheat-sheet (memorize these five)
The full table (every Apple crash code) is in references/crash-log-triage.md. These five cover
the vast majority of real iOS crashes:
| Stop reason / signal | Almost always means | First thing to check |
|---|
EXC_BAD_ACCESS (SIGSEGV) at addr 0x0–0x10 | Force-unwrapped a nil Optional | The ! at the crash line → replace with guard let/if let |
EXC_BAD_ACCESS at a high address | Dangling reference / use-after-free | Missing [weak self], a freed delegate, object lifetime |
EXC_BREAKPOINT (SIGTRAP) | Swift runtime trap | fatalError()/precondition(), array bounds, or a concurrency isolation assert (_dispatch_assert_queue_fail, _swift_task_checkIsolatedSwift) |
EXC_CRASH (SIGABRT) | Uncaught NSException or assertion | Application-Specific-Info line for the exception name + reason |
Watchdog 0x8BADF00D | Main thread blocked too long | Synchronous I/O, a long loop, or a DispatchQueue.main.sync deadlock on the main thread |
The signature SwiftUI crash you must recognize
Symptom: EXC_BREAKPOINT on com.apple.SwiftUI.AsyncRenderer, stack shows
_dispatch_assert_queue_fail → _swift_task_checkIsolatedSwift → a Color.init(...) closure in
your theme file. It "crashes on screen X" but X is innocent — the real fault is a dynamic
UIColor { traits in ... } provider that SwiftUI tries to resolve off the main queue on its
async-renderer thread.
static let background = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(Color(hex: "1C1C1E")) : UIColor(Color(hex: "FFFFFF"))
})
static let background = Color("Background")
The reliable diagnostic that cracks it: add OSLog breadcrumbs → prove the crash is after
navigation/state changes (ruling out the obvious suspect) → read the symbolic stack from the
actual AsyncRenderer thread → trust the first app frame (the Color.init closure) → remove
that code path. Full write-up: references/swiftui-crash-patterns.md.
When the bug is "wrong output", not a crash
A value transits but doesn't stick is the hardest class. Verify in three places before
concluding where the bug is:
- The model/state itself (
v self.viewModel.items in LLDB, or an OSLog line).
- The rendered output (the SwiftUI view actually redrew / the SVG/cell shows the new value).
- The persistence layer (it survived a relaunch / it's in the store).
If all three agree, the round-trip works. If only two agree, you've found the bug in the third
path. The classic culprits: a @Published/@Observable mutation off @MainActor (the view never
diffs), @StateObject vs @ObservedObject re-creation, or missing stable Identifiable ids. See
the iOS error catalog in references/ios-bug-catalog.md.
Use OSLog, never print()
import OSLog
let log = Logger(subsystem: "com.yourcompany.app", category: "Onboarding")
log.debug("goNext: step \(step) → \(step + 1)")
log.error("decode failed: \(error.localizedDescription)")
OSLog is structured, queryable in Console.app, survives into the crash context, and is
compiled-out of release for .debug. print() is none of those. When breadcrumbing a crash,
OSLog lines around the suspected flow are how you prove where it happens.
The DEBUG_LOG learning loop (the highest-leverage habit)
Every bug you fix is a bug a future session shouldn't have to re-find. Maintain a DEBUG_LOG.md
at the repo root as an append-only, grep-first knowledge base. Before editing a file, grep
the log for that file / the API / the crash signature — don't read it end-to-end. Full protocol,
entry template, and tag taxonomy: references/debug-log-protocol.md.
Minimal entry (write one in the same commit as the fix):
### DL-007 — AsyncRenderer crash from dynamic UIColor theme provider
| Date | 2026-06-05 |
| Tags | `#ios #SwiftUI #concurrency` |
| Severity | Runtime Crash |
| Environment | iOS 26, Swift 6, Xcode 26 |
| Symptom | EXC_BREAKPOINT on com.apple.SwiftUI.AsyncRenderer; _dispatch_assert_queue_fail; Color.init closure in AppTheme.swift |
| Root Cause | UIColor { traits in } dynamic provider resolved off-main on the async renderer thread |
| Fix | Replaced dynamic providers with asset-catalog color sets / plain SwiftUI colors |
| Prevention Rule | Never build theme colors with `UIColor { traits in }` providers; use asset catalog color sets. **Why:** they crash on the AsyncRenderer thread. |
When the same prevention rule shows up in three entries, promote it to a project rule or a
lint check — ambient tooling beats ambient discipline.
Anti-patterns (do not do these)
- Guessing from the symptom screen instead of reading the crashing-thread backtrace.
po for everything in LLDB — it fails on Swift structs/enums/optionals. Default to v.
- Print-debug cycles (edit → build → run → navigate, 3–5 min each) where a breakpoint would
cost 30 seconds.
- Force-unwrapping
[weak self] (self!) — turns a leak into a crash without fixing either.
The canonical pair is [weak self] + guard let self else { return }.
- Debugging a Release build by default — variables are optimized out, code is reordered. Use
Debug, or per-file
-Onone (see the playbook's Release-crash section).
- Suppressing an exception (force-continuing past it) instead of fixing the throw.
- Fixing the symptom, not the root cause — rounding away a money float, catching-and-ignoring
a decode error, adding
.id(UUID()) to "fix" a list reset (that causes resets — use a stable
id).
- Marking the bug fixed without re-running the repro. Bytes-on-disk is not a passing test.
References (read the matching file before acting)
- references/lldb-playbook.md — the full LLDB command reference:
v/p/po/expr decision,
breakpoints (conditional, symbolic, logpoints, watchpoints, ObjC-selector, by-address),
thread/frame navigation, hang triage, async-concurrency debugging, triggering/expiring a
BGTask on demand (_simulate* SPI), Release-only crash hunting + register reading, .lldbinit
aliases, and the "LLDB is broken" fix table.
- references/crash-log-triage.md — getting the log off a device you can't attach to (Console.app
/ Analytics Data / live fault predicate) + symbolication + the complete crash-code → fix-guidance
table (every
pattern_tag/Apple code), the classifier's quantified heuristics (guard-page, ≥100
AG::Graph::update, crashed-thread-only window), the why-do/catch-didn't-catch Swift/ObjC
matrix, PII scrub-before-sharing key list, xcsym workflow (now incl. MetricKit JSON +
.xccrashpoint) + exit codes, dSYM resolution, manual .ips parsing, and the asc TestFlight CLI.
- references/ios-bug-catalog.md — the symptom→root-cause→fix→prevention catalog of the most common
iOS runtime bugs (SwiftUI state ownership, navigation, sheets,
@MainActor/Sendable, Core
Data/SwiftData threading, retain cycles, URLSession, ATS, Codable, deep links, keyboard) plus
App Intents errors (Siri/Shortcuts/Spotlight), the fake-fetch/mock-data detector,
sandbox inspection (UserDefaults plist + SQLite WAL/SHM), and "database is locked".
- references/swiftui-crash-patterns.md — the AsyncRenderer dynamic-color crash full
investigation, the empty-array-subscript-in-
body-before-onAppear push-render trap, the
compile-time "unable to type-check in reasonable time" TupleView failure, and the other top
SwiftUI runtime traps.
- references/objc-runtime-debugging.md — for any crash whose stack has
___forwarding___ /
unrecognized selector you never wrote: the message-forwarding path (where to breakpoint), the
swizzler-broke-lazy-accessors playbook, the three ___forwarding___ console signatures, reading
args from registers (dual ARM64/x86-64 table + po (char *)), and source-less breakpoint forms.
- references/memory-and-leaks.md — retain-cycle hunting (Instruments,
deinit logging,
[weak self]/[unowned self]), the SwiftUI-specific leak family (.refreshable,
searchable+refreshable, NavigationStack toggle, iOS 17.0–17.1 sheet regression, EnvironmentObject,
UIHostingController), framework cycle wrappers (WKWebView LeakAvoider, CADisplayLink
WeakTarget, AVPlayer observer), automated runtime/CI leak detection (trackForMemoryLeaks,
zero-dep LeakDetector, LifetimeTracker), the .memgraph CLI reference-tracing 9-step
procedure, and the jetsam-OOM follow-up.
- references/in-app-diagnostics.md — user-side complement to the dev-side DEBUG_LOG loop:
in-app
OSLogStore log capture (with the com.apple.logging.local-store entitlement gotcha) and
a shippable "Report Issue" diagnostics bundle (DiagnosticSnapshot fields, breadcrumbs,
app-issue-report.zip layout, AI-debugging prompt, redaction defaults).
- references/mcp-debugging.md — the Xcode MCP vs XcodeBuildMCP tool split for agent-driven
debugging (build/preview/test vs LLDB/sim-logs/snapshots), the automated smoke-walker pattern, and
extracting compiler warnings as audit ground truth (with the incremental-build-under-reports
gotcha).
- references/debug-log-protocol.md — the full
DEBUG_LOG.md v2 protocol: entry template,
severity/tag/root-cause taxonomy, active pre-flight grep, iterations counter, rule lifecycle.