axiom-timer-patterns

安装量: 45
排名: #16472

安装

npx skills add https://github.com/charleswiltgen/axiom --skill axiom-timer-patterns
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
// 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
// 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
// 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
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
// 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
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
// 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
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
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
timer
.
cancel
(
)
timer
.
resume
(
)
// EXC_BAD_INSTRUCTION — can't resume a cancelled source
✅ Safe
// 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.
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
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
// 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
// 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:
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."
返回排行榜