Files
Matthias a60a76b797 Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
2026-04-19 21:11:32 +02:00

477 lines
16 KiB
Markdown

---
name: axiom-timer-patterns
description: Use when implementing timers, debugging timer crashes (EXC_BAD_INSTRUCTION), Timer stops during scrolling, or choosing between Timer/DispatchSourceTimer/Combine/async timer APIs
license: MIT
metadata:
version: "1.0.0"
last-updated: "2026-02-26"
---
# Timer Safety Patterns
## Overview
Timer-related crashes are among the hardest to diagnose because they're often intermittent and the crash log points to GCD internals, not your code. **Core principle**: DispatchSourceTimer has a state machine — violating it causes deterministic EXC_BAD_INSTRUCTION crashes that look random. Timer (NSTimer) has a RunLoop mode trap that silently stops your timer during scrolling. Both are preventable with the patterns in this skill.
## Example Prompts
- "My timer stops when the user scrolls"
- "EXC_BAD_INSTRUCTION crash in my timer code"
- "Should I use Timer or DispatchSourceTimer?"
- "How do I safely cancel a DispatchSourceTimer?"
- "My DispatchSourceTimer crashes on dealloc"
- "Timer keeps running after I dismiss the view controller"
---
## Part 1: Timer vs DispatchSourceTimer Decision Tree
| Feature | Timer | DispatchSourceTimer | AsyncTimerSequence |
|---------|-------|--------------------|--------------------|
| Thread safety | Main thread only (RunLoop-bound) | Any queue (you choose) | Task-bound (structured concurrency) |
| Scrolling survival | Only in `.common` mode | Always (no RunLoop dependency) | Always (no RunLoop dependency) |
| Precision | Low (RunLoop coalescing) | High (GCD scheduling) | Medium (clock-dependent) |
| Lifecycle complexity | Low (invalidate + nil) | High (state machine, 4 crash patterns) | Low (task cancellation) |
| iOS version | 2.0+ | 8.0+ (GCD) | 16.0+ |
| Use case | UI updates on main thread | Background work, precise timing, custom queues | Modern async code, structured concurrency |
### Quick Decision
```
Need a simple UI update timer?
├─ Yes → Timer (with .common RunLoop mode)
Need precise timing or background queue?
├─ Yes → DispatchSourceTimer (with SafeDispatchTimer wrapper)
Writing modern async/await code on iOS 16+?
├─ Yes → AsyncTimerSequence (ContinuousClock.timer)
Need Combine integration?
└─ Yes → Timer.publish
```
---
## Part 2: RunLoop Mode Gotcha
Timer stops firing during scrolling. This is the single most common timer bug in iOS development.
### Why It Happens
`Timer.scheduledTimer` adds the timer to the current RunLoop in `.default` mode. When the user scrolls (UIScrollView, SwiftUI ScrollView, List), the RunLoop switches to `.tracking` mode. The timer doesn't fire in `.tracking` mode because it was only registered for `.default`.
**Time cost**: Timer mysteriously stops during scroll → 30+ min debugging if you don't know about RunLoop modes.
### ❌ Broken — Timer stops during scrolling
```swift
// BAD: Timer added to .default mode (implicit)
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
// Timer STOPS when user scrolls any UIScrollView or SwiftUI List
```
### ✅ Fixed — Timer survives scrolling
```swift
// GOOD: Explicitly add to .common mode (includes both .default and .tracking)
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
RunLoop.current.add(timer, forMode: .common)
```
### ✅ Fixed — Combine Timer survives scrolling
```swift
// GOOD: Timer.publish with .common mode survives scrolling in SwiftUI
Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.updateProgress()
}
.store(in: &cancellables)
```
**Key**: The `in:` parameter defaults to `.default` if omitted — always specify `.common` explicitly.
### RunLoop Modes
| Mode | When Active | Timer Fires? |
|------|-------------|--------------|
| `.default` | Normal interaction | Yes |
| `.tracking` | During scrolling | Only if added to `.common` |
| `.common` | Pseudo-mode: includes `.default` + `.tracking` | Yes (always) |
---
## Part 3: The 4 DispatchSourceTimer Crash Patterns
Each of these causes **EXC_BAD_INSTRUCTION** — a crash that points to GCD internals, making it hard to trace back to your timer code.
### Crash Frame → Pattern Mapping
When you see EXC_BAD_INSTRUCTION in a crash log, match the top frame:
| Top Crash Frame | Crash Pattern | Fix |
|---|---|---|
| `dispatch_source_cancel` | Crash 2: Cancel while suspended | `resume()` before `cancel()` |
| `_dispatch_source_dispose` | Crash 3: Dealloc while suspended | Resume + cancel before releasing |
| `dispatch_resume` | Crash 4: Resume after cancel | Check `isCancelled` before operating |
| `_dispatch_source_refs_t` / `suspend count` | Crash 1: Unbalanced suspend | Track state, only suspend if running |
### DispatchSourceTimer State Machine
```
activate()
idle ──────────────► running
│ ▲
suspend() │ │ resume()
▼ │
suspended
resume() + cancel()
cancelled (terminal)
CRASH ZONES:
suspended → cancel() = EXC_BAD_INSTRUCTION
suspended → dealloc = EXC_BAD_INSTRUCTION
suspended → suspend() = suspend count underflow on dealloc
cancelled → resume() = EXC_BAD_INSTRUCTION
```
### Crash 1: Suspend While Already Suspended
Calling `suspend()` multiple times without matching `resume()` calls. Each `suspend()` increments an internal counter. On dealloc, if the suspend count isn't zero, GCD crashes.
#### ❌ Crash
```swift
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: 1.0)
timer.setEventHandler { doWork() }
timer.activate()
// User triggers pause twice rapidly
timer.suspend() // suspend count = 1
timer.suspend() // suspend count = 2
timer.resume() // suspend count = 1
// Timer deallocated with suspend count = 1 EXC_BAD_INSTRUCTION
```
#### ✅ Safe
```swift
// Track state only suspend if running
var isRunning = true
func pause() {
guard isRunning else { return }
timer.suspend()
isRunning = false
}
func unpause() {
guard !isRunning else { return }
timer.resume()
isRunning = true
}
```
### Crash 2: Cancel While Suspended
GCD requires a dispatch source to be in a non-suspended state before cancellation. Cancelling a suspended timer crashes immediately.
#### ❌ Crash
```swift
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: 1.0)
timer.setEventHandler { doWork() }
timer.activate()
timer.suspend()
timer.cancel() // EXC_BAD_INSTRUCTION can't cancel while suspended
```
#### ✅ Safe
```swift
// ALWAYS resume before cancelling
timer.resume() // Move out of suspended state
timer.cancel() // Now safe to cancel
```
### Crash 3: Dealloc While Suspended
Setting the timer to nil (or letting it go out of scope) while suspended. Deallocation internally attempts cleanup that fails on a suspended source.
#### ❌ Crash
```swift
var timer: DispatchSourceTimer?
func startTimer() {
timer = DispatchSource.makeTimerSource(queue: queue)
timer?.schedule(deadline: .now(), repeating: 1.0)
timer?.setEventHandler { [weak self] in self?.doWork() }
timer?.activate()
}
func pauseTimer() {
timer?.suspend()
}
func cleanup() {
timer = nil // Dealloc while suspended EXC_BAD_INSTRUCTION
}
```
#### ✅ Safe
```swift
func cleanup() {
// Resume before releasing
timer?.resume()
timer?.cancel()
timer = nil // Now safe timer is in cancelled state
}
```
### Crash 4: Operate After Cancel
Calling `resume()` or `suspend()` on a cancelled timer. Cancellation is a terminal state — the timer cannot be reused.
#### ❌ Crash
```swift
timer.cancel()
timer.resume() // EXC_BAD_INSTRUCTION can't resume a cancelled source
```
#### ✅ Safe
```swift
// Track cancellation state
var isCancelled = false
func cancel() {
guard !isCancelled else { return }
timer.cancel()
isCancelled = true
}
func resume() {
guard !isCancelled else { return } // Check before operating
timer.resume()
}
```
---
## Part 4: SafeDispatchTimer Wrapper
Copy-paste this class to prevent all 4 crash patterns. State machine enforces valid transitions.
```swift
final class SafeDispatchTimer {
enum State { case idle, running, suspended, cancelled }
private(set) var state: State = .idle
private let timer: DispatchSourceTimer
init(queue: DispatchQueue = DispatchQueue(label: "safe-dispatch-timer")) {
timer = DispatchSource.makeTimerSource(queue: queue)
}
func schedule(interval: TimeInterval, handler: @escaping () -> Void) {
guard state == .idle else { return }
timer.schedule(deadline: .now() + interval, repeating: interval)
timer.setEventHandler(handler: handler)
timer.activate()
state = .running
}
func suspend() {
guard state == .running else { return }
timer.suspend()
state = .suspended
}
func resume() {
guard state == .suspended else { return }
timer.resume()
state = .running
}
func cancel() {
switch state {
case .suspended:
timer.resume() // Must resume before cancel
timer.cancel()
case .running:
timer.cancel()
case .idle, .cancelled:
return
}
state = .cancelled
}
deinit {
cancel() // Safe cleanup regardless of current state
}
}
```
### Usage
```swift
class BackgroundPoller {
private var timer: SafeDispatchTimer?
func start() {
timer = SafeDispatchTimer()
timer?.schedule(interval: 5.0) { [weak self] in
self?.fetchData()
}
}
func pause() {
timer?.suspend() // Safe no-op if not running
}
func unpause() {
timer?.resume() // Safe no-op if not suspended
}
func stop() {
timer?.cancel() // Safe handles any state
timer = nil
}
}
```
---
## Part 5: Thread Safety
### Always Use a Dedicated Serial Queue
DispatchSourceTimer fires its event handler on the queue you specify at creation. Using a concurrent queue creates race conditions when multiple firings overlap or when you modify shared state from the handler.
#### ❌ Race Condition
```swift
// BAD: Concurrent queue handler can fire while previous invocation is still running
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
timer.setEventHandler {
self.count += 1 // Race condition
self.processItem(count) // Overlapping invocations
}
```
#### ✅ Serial Queue
```swift
// GOOD: Dedicated serial queue handler invocations are serialized
let timerQueue = DispatchQueue(label: "com.app.timer-queue")
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.setEventHandler { [weak self] in
self?.count += 1 // Safe serial queue
self?.processItem(count) // No overlap
}
```
### Main Queue for UI Updates
If your timer handler updates UI, dispatch to main:
```swift
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.setEventHandler { [weak self] in
let result = self?.computeResult()
DispatchQueue.main.async {
self?.updateUI(with: result)
}
}
```
---
## Part 6: Anti-Patterns
| Anti-Pattern | Time Cost | Fix |
|---|---|---|
| Timer in `.default` RunLoop mode | 30+ min debugging scroll freeze | Use `.common` mode |
| No state tracking on DispatchSourceTimer | EXC_BAD_INSTRUCTION crash, hours to diagnose | Use SafeDispatchTimer wrapper |
| `timer.cancel()` while suspended | Production crash | `resume()` then `cancel()` |
| Timer on `.global()` queue | Race conditions, intermittent crashes | Dedicated serial queue |
| Force-unwrapping timer | Crash if timer already cancelled | Optional check or state enum |
| Not clearing event handler before cancel | Potential retain cycle | `timer.setEventHandler(handler: nil)` then cancel |
| Timer retains target (selector API) | Memory leak — deinit never called | Use block API with `[weak self]` |
| Creating timer without invalidating previous | Timer accumulation, CPU waste | Always invalidate/cancel before creating new |
| Timer on background thread without RunLoop | Timer silently never fires | Timer requires a RunLoop — use DispatchSourceTimer or AsyncTimerSequence for background work |
---
## Part 7: Pressure Scenarios
### Scenario 1: "Just use Timer.scheduledTimer and move on"
**Setup**: Deadline approaching, need a repeating update every second.
**Pressure**: Timer is simpler than DispatchSourceTimer. "It's just a UI update timer, no need for GCD complexity."
**Expected with skill**: Choose Timer for simple UI updates — but add it to `.common` RunLoop mode so it survives scrolling. Only reach for DispatchSourceTimer when you need precision, background execution, or a custom queue.
**Anti-pattern without skill**: Using `Timer.scheduledTimer` with default `.default` mode → timer stops during scrolling → user reports "progress bar freezes when I scroll" → 30+ min debugging.
**Pushback template**: "Timer is the right choice for a UI update, but we need to add it to `.common` RunLoop mode. Without that, the timer stops every time the user scrolls. It's a 2-line change that prevents a guaranteed bug report."
---
### Scenario 2: "The crash only happens sometimes, let's ship and fix later"
**Setup**: EXC_BAD_INSTRUCTION in production crash logs. Can't reproduce reliably in development.
**Pressure**: "It's rare. Users can reopen the app. We'll fix it in the next release."
**Expected with skill**: Recognize the crash signature as a DispatchSourceTimer state machine violation. All 4 crash patterns are deterministic — they happen every time the specific state transition occurs. The "intermittent" appearance comes from the state transition being timing-dependent, not the crash itself. Apply SafeDispatchTimer wrapper.
**Anti-pattern without skill**: Shipping without fix → crash rate compounds with user count → crash appears in App Store review metrics → rejection risk.
**Pushback template**: "This crash is deterministic — it happens every time the timer is in a specific state. The 'intermittent' part is just the timing of when that state occurs. SafeDispatchTimer is a drop-in replacement that eliminates all 4 crash patterns. It's a 15-minute fix that prevents a production crash."
---
### Scenario 3: "Timer.invalidate() handles cleanup"
**Setup**: Timer being used in a view controller, calling `invalidate()` in `deinit`.
**Pressure**: "invalidate() is the standard cleanup pattern. It's in every tutorial."
**Expected with skill**: Recognize the retain cycle: `Timer.scheduledTimer(timeInterval:target:selector:)` retains its target. If the target is `self` (the view controller), and the view controller holds a strong reference to the timer, you have a retain cycle. `deinit` never gets called because the timer keeps `self` alive. Solution: use `[weak self]` with the block API, and invalidate in `viewWillDisappear` (not `deinit`).
**Anti-pattern without skill**: Timer retains self → deinit never called → invalidate never called → timer keeps firing → memory leak + accumulating timers → eventual crash or battery drain.
**Pushback template**: "The block-based Timer API with `[weak self]` is the fix. The selector-based API retains its target, which means our `deinit` never fires and `invalidate()` never gets called. We also need to move `invalidate()` to `viewWillDisappear` as a safety net."
---
## Related Skills
- `axiom-timer-patterns-ref` — API reference for Timer, DispatchSourceTimer, Combine Timer.publish, AsyncTimerSequence with lifecycle diagrams and platform availability
- `axiom-memory-debugging` — Timer as Pattern 1 memory leak (Timer retains target, RunLoop retains Timer)
- `axiom-energy` — Timer as energy drain pattern (tolerance, coalescing, event-driven alternatives)
## Resources
**WWDC**: 2017-706
**Skills**: timer-patterns-ref, memory-debugging, energy, energy-ref