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.
477 lines
16 KiB
Markdown
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
|