Skip to content

Commit 8fa0921

Browse files
jjoelsonandersio
authored andcommitted
Fix schedule(after:interval:leeway:) disposal (#584)
* Fix `schedule(after:interval:leeway:)` disposal Retain timers in `QueueScheduler` so that they will not be cancelled if the user doesn’t retain the returned `Disposable`. * Add pull request # and handle * Use `Atomic` instead of `Lock` * Separate the tests for repeated action disposal * Use `Set` instead of `Array` to store timers * Use `ObjectIdentifier(value).hashValue` * Weakify `self` in the returned `Disposable` * Fix `QueueScheduler` tests * Fix increment in test Specify `pollInterval` on the asynchronous expectation to avoid false positive test success. * Conform to timer wrapper to `Hashable` by self ref `DispatchSourceTimer` doesn’t inherit from `NSObjectProtocol` on Linux. Basing hashing and equality on the wrapper is sufficient because the same wrapper instance can be used insert and remove from the `Set` of retained timers.
1 parent bdd8ea0 commit 8fa0921

File tree

3 files changed

+100
-2
lines changed

3 files changed

+100
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# master
22
*Please add new entries at the top.*
33

4+
1. Fixed `schedule(after:interval:leeway:)` being cancelled when the returned `Disposable` is not retained. (#584, kudos to @jjoelson)
5+
46
# 3.1.0-rc.1
57
1. Fixed a scenario of downstream interruptions being dropped. (#577, kudos to @andersio)
68

Sources/Scheduler.swift

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,26 @@ public final class UIScheduler: Scheduler {
177177
}
178178
}
179179

180+
/// A `Hashable` wrapper for `DispatchSourceTimer`. `Hashable` conformance is
181+
/// based on the identity of the wrapper object rather than the wrapped
182+
/// `DispatchSourceTimer`, so two wrappers wrapping the same timer will *not*
183+
/// be equal.
184+
private final class DispatchSourceTimerWrapper: Hashable {
185+
private let value: DispatchSourceTimer
186+
187+
fileprivate var hashValue: Int {
188+
return ObjectIdentifier(self).hashValue
189+
}
190+
191+
fileprivate init(_ value: DispatchSourceTimer) {
192+
self.value = value
193+
}
194+
195+
fileprivate static func ==(lhs: DispatchSourceTimerWrapper, rhs: DispatchSourceTimerWrapper) -> Bool {
196+
return lhs === rhs
197+
}
198+
}
199+
180200
/// A scheduler backed by a serial GCD queue.
181201
public final class QueueScheduler: DateScheduler {
182202
/// A singleton `QueueScheduler` that always targets the main thread's GCD
@@ -192,9 +212,12 @@ public final class QueueScheduler: DateScheduler {
192212
}
193213

194214
public let queue: DispatchQueue
195-
215+
216+
private var timers: Atomic<Set<DispatchSourceTimerWrapper>>
217+
196218
internal init(internalQueue: DispatchQueue) {
197219
queue = internalQueue
220+
timers = Atomic(Set())
198221
}
199222

200223
/// Initializes a scheduler that will target the given queue with its
@@ -340,8 +363,20 @@ public final class QueueScheduler: DateScheduler {
340363
timer.setEventHandler(handler: action)
341364
timer.resume()
342365

343-
return AnyDisposable {
366+
let wrappedTimer = DispatchSourceTimerWrapper(timer)
367+
368+
timers.modify { timers in
369+
timers.insert(wrappedTimer)
370+
}
371+
372+
return AnyDisposable { [weak self] in
344373
timer.cancel()
374+
375+
if let scheduler = self {
376+
scheduler.timers.modify { timers in
377+
timers.remove(wrappedTimer)
378+
}
379+
}
345380
}
346381
}
347382
}

Tests/ReactiveSwiftTests/SchedulerSpec.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,67 @@ class SchedulerSpec: QuickSpec {
218218
scheduler.queue.resume()
219219
expect{count}.toEventually(equal(timesToRun))
220220
}
221+
222+
it("should repeatedly run actions after a given date when the disposable is not retained") {
223+
let disposable = SerialDisposable()
224+
225+
var count = 0
226+
let timesToIncrement = 3
227+
228+
// Schedule within a function so that the disposable is guaranteed to be deinitialised.
229+
func scheduleAndDeinitDisposable() {
230+
scheduler.schedule(after: Date(), interval: .milliseconds(10), leeway: .seconds(0)) {
231+
expect(Thread.isMainThread) == false
232+
233+
if count < timesToIncrement {
234+
count += 1
235+
}
236+
}
237+
}
238+
239+
scheduleAndDeinitDisposable()
240+
241+
expect(count) == 0
242+
243+
scheduler.queue.resume()
244+
expect(count).toEventually(equal(timesToIncrement), pollInterval: 0.1)
245+
}
246+
247+
it("should cancel repeatedly run actions on disposal") {
248+
// Start two repeating timers, dispose the first, and ensure only the second runs.
249+
250+
let disposable1 = SerialDisposable()
251+
let disposable2 = SerialDisposable()
252+
253+
var count = 0
254+
let timesToRun = 3
255+
256+
let interval = DispatchTimeInterval.milliseconds(10)
257+
258+
disposable1.inner = scheduler.schedule(after: Date(), interval: interval, leeway: .seconds(0)) {
259+
fail("timer not cancelled on disposal")
260+
}
261+
262+
disposable2.inner = scheduler.schedule(after: Date(), interval: interval, leeway: .seconds(0)) {
263+
expect(Thread.isMainThread) == false
264+
265+
count += 1
266+
267+
if count == timesToRun {
268+
disposable2.dispose()
269+
}
270+
}
271+
272+
disposable1.dispose()
273+
274+
expect(count) == 0
275+
276+
scheduler.queue.resume()
277+
278+
// This expectation should take about 2.0 * interval to be fulfilled, and that's
279+
// enough time to ensure that the first timer was actually cancelled.
280+
expect(count).toEventually(equal(timesToRun))
281+
}
221282
}
222283
}
223284

0 commit comments

Comments
 (0)