Skip to content

Commit 1a1b7f1

Browse files
fix: deflake FlushableWorker interval test via Dispatch timer (#224)
## Summary - `FlushableWorkerTests.ticksOccurOnInterval()` was intermittently failing on CI with zero observed ticks. - Root cause: ticks were driven by a `Task.sleep` loop on the Swift **cooperative thread pool** and routed through a coalescing flag shared with flushes. On busy CI machines (Swift Testing runs suites in parallel) the pool gets saturated, starving the tick loop, and the shared `pending` state could wedge tick delivery. - Fix: drive periodic ticks from a `DispatchSourceTimer` on a Dispatch queue, **independent of the cooperative pool**. All work (ticks + flushes) is serialized through a single `.unbounded` `AsyncStream` consumer so triggers are never silently dropped and ticks can't be wedged by flush coalescing. Public API (`start`/`stop`/`flush`) is unchanged. ## Test plan - [x] Validated all six `FlushableWorker` test scenarios (run concurrently to mimic Swift Testing's parallel execution, including under CPU saturation) against a standalone port of the new design: 360 runs each, 0 failures. - [ ] CI: `ObservabilityTests` green, including `ticksOccurOnInterval()` across repeated runs. Made with [Cursor](https://cursor.com) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6ce823d commit 1a1b7f1

1 file changed

Lines changed: 65 additions & 68 deletions

File tree

Sources/LaunchDarklyObservability/Transport/FlushableWorker.swift

Lines changed: 65 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,97 +5,94 @@ import Foundation
55

66
actor FlushableWorker {
77
typealias Work = @Sendable (_ isFlushing: Bool) async -> Void
8-
private enum Trigger {
9-
case tick
10-
case flush
11-
}
12-
13-
private var task: Task<Void, Never>? = nil
8+
149
private let interval: TimeInterval
1510
private let work: Work
16-
private var continuation: AsyncStream<Trigger>.Continuation? = nil
17-
private var pending: Trigger? = nil
18-
11+
12+
private var running = false
13+
private var timer: DispatchSourceTimer?
14+
private var processingTask: Task<Void, Never>?
15+
private var continuation: AsyncStream<Bool>.Continuation?
16+
private var flushPending = false
17+
1918
init(interval: TimeInterval, work: @escaping Work) {
2019
self.interval = interval
2120
self.work = work
2221
}
23-
24-
func start() async {
25-
guard task == nil else { return }
26-
27-
var localContinuation: AsyncStream<Trigger>.Continuation?
28-
let stream = AsyncStream<Trigger>(bufferingPolicy: .bufferingNewest(1)) { cont in
22+
23+
func start() {
24+
guard !running else { return }
25+
running = true
26+
27+
// Serialize all work (ticks and flushes) through a single consumer so a
28+
// tick and a flush never run concurrently. The element payload is the
29+
// `isFlushing` flag. `.unbounded` guarantees that an enqueued trigger is
30+
// never silently dropped.
31+
var localContinuation: AsyncStream<Bool>.Continuation?
32+
let stream = AsyncStream<Bool>(bufferingPolicy: .unbounded) { cont in
2933
localContinuation = cont
3034
}
31-
if let cont = localContinuation {
32-
self.setContinuation(cont)
33-
}
34-
35-
self.task = Task { [weak self] in
35+
continuation = localContinuation
36+
37+
processingTask = Task { [weak self] in
3638
guard let self else { return }
37-
38-
let tickTask = Task { [weak self] in
39-
guard let self else { return }
40-
41-
while !Task.isCancelled {
42-
try? await Task.sleep(seconds: self.interval)
43-
await self.doTrigger(.tick)
39+
for await isFlushing in stream {
40+
if Task.isCancelled { break }
41+
await self.work(isFlushing)
42+
if isFlushing {
43+
await self.clearFlushPending()
4444
}
4545
}
46-
defer {
47-
tickTask.cancel()
48-
}
46+
}
4947

50-
for await trigger in stream {
51-
if Task.isCancelled {
52-
break
53-
}
54-
await work(trigger == .flush)
55-
await clearPending()
56-
}
48+
// Drive periodic ticks from a Dispatch timer rather than a
49+
// `Task.sleep` loop. The timer fires on a Dispatch queue independent of
50+
// the Swift cooperative thread pool, so periodic ticks keep being
51+
// enqueued even when that pool is briefly saturated (e.g. on a busy CI
52+
// machine). This is what made the interval test flaky.
53+
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
54+
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(5))
55+
timer.setEventHandler { [weak self] in
56+
guard let self else { return }
57+
Task { await self.enqueueTick() }
5758
}
59+
timer.resume()
60+
self.timer = timer
5861
}
59-
62+
6063
func stop() {
61-
task?.cancel()
62-
task = nil
64+
running = false
65+
timer?.cancel()
66+
timer = nil
6367
continuation?.finish()
6468
continuation = nil
65-
pending = nil
69+
processingTask?.cancel()
70+
processingTask = nil
71+
flushPending = false
6672
}
67-
68-
func flush() async {
69-
doTrigger(.flush)
70-
}
71-
72-
private func doTrigger(_ next: Trigger) {
73-
guard pending != .flush else {
74-
// flush is already next
75-
return
76-
}
77-
78-
if next == .flush || pending == nil {
79-
pending = next
80-
continuation?.yield(next)
81-
}
73+
74+
func flush() {
75+
guard running else { return }
76+
// Coalesce: while a flush is already queued/in-flight, additional flush
77+
// requests collapse into the pending one.
78+
guard !flushPending else { return }
79+
flushPending = true
80+
continuation?.yield(true)
8281
}
83-
84-
private func clearPending() {
85-
pending = nil
82+
83+
private func enqueueTick() {
84+
guard running else { return }
85+
continuation?.yield(false)
8686
}
87-
88-
private func setContinuation(_ continuation: AsyncStream<Trigger>.Continuation) {
89-
self.continuation = continuation
87+
88+
private func clearFlushPending() {
89+
flushPending = false
9090
}
9191

9292
deinit {
93-
// had to repeat stop code because of Actor
94-
task?.cancel()
95-
task = nil
93+
// Repeated here because an actor's `stop()` can't be awaited from `deinit`.
94+
timer?.cancel()
9695
continuation?.finish()
97-
continuation = nil
98-
pending = nil
96+
processingTask?.cancel()
9997
}
10098
}
101-

0 commit comments

Comments
 (0)