Skip to content

Commit a99400d

Browse files
authored
Added Sendability support (#21)
1 parent e39c6bc commit a99400d

17 files changed

Lines changed: 169 additions & 278 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ playground.xcworkspace
4444
#
4545
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
4646
# hence it is not needed unless you have added a package configuration file to your project
47-
# .swiftpm
47+
.swiftpm/
4848

4949
.build/
5050

@@ -88,3 +88,6 @@ fastlane/test_output
8888
# https://github.com/johnno1962/injectionforxcode
8989

9090
iOSInjectionProject/
91+
92+
# Ignore DS Store from Mac
93+
.DS_Store

.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Lines changed: 0 additions & 7 deletions
This file was deleted.

.swiftpm/xcode/xcshareddata/xcschemes/AsyncTimeSequences.xcscheme

Lines changed: 0 additions & 91 deletions
This file was deleted.

FormatScript.sh

Lines changed: 0 additions & 6 deletions
This file was deleted.

Package.swift

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,47 @@
1-
// swift-tools-version:5.5
1+
// swift-tools-version:6.0
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription
55

66
let package = Package(
7-
name: "AsyncTimeSequences",
8-
platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)],
9-
products: [
10-
// Products define the executables and libraries a package produces, and make them visible to other packages.
11-
.library(
12-
name: "AsyncTimeSequences",
13-
targets: ["AsyncTimeSequences"]
14-
),
15-
.library(
16-
name: "AsyncTimeSequencesSupport",
17-
targets: ["AsyncTimeSequencesSupport"]
18-
),
19-
],
20-
dependencies: [
21-
// Dependencies declare other packages that this package depends on.
22-
],
23-
targets: [
24-
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
25-
// Targets can depend on other targets in this package, and on products in packages this package depends on.
26-
.target(
27-
name: "AsyncTimeSequences",
28-
dependencies: [],
29-
path: "Sources/AsyncTimeSequences"
30-
),
31-
.target(
32-
name: "AsyncTimeSequencesSupport",
33-
dependencies: [
34-
"AsyncTimeSequences",
35-
],
36-
path: "Sources/AsyncTimeSequencesSupport"
37-
),
38-
.testTarget(
39-
name: "AsyncTimeSequencesTests",
40-
dependencies: [
41-
"AsyncTimeSequences",
42-
"AsyncTimeSequencesSupport"
43-
],
44-
path: "Tests"
45-
),
46-
]
7+
name: "AsyncTimeSequences",
8+
platforms: [.iOS(.v13), .macOS(.v13), .watchOS(.v6), .tvOS(.v13)],
9+
products: [
10+
// Products define the executables and libraries a package produces, and make them visible to other packages.
11+
.library(
12+
name: "AsyncTimeSequences",
13+
targets: ["AsyncTimeSequences"]
14+
),
15+
.library(
16+
name: "AsyncTimeSequencesSupport",
17+
targets: ["AsyncTimeSequencesSupport"]
18+
),
19+
],
20+
dependencies: [
21+
// Dependencies declare other packages that this package depends on.
22+
],
23+
targets: [
24+
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
25+
// Targets can depend on other targets in this package, and on products in packages this package depends on.
26+
.target(
27+
name: "AsyncTimeSequences",
28+
dependencies: [],
29+
path: "Sources/AsyncTimeSequences"
30+
),
31+
.target(
32+
name: "AsyncTimeSequencesSupport",
33+
dependencies: [
34+
"AsyncTimeSequences"
35+
],
36+
path: "Sources/AsyncTimeSequencesSupport"
37+
),
38+
.testTarget(
39+
name: "AsyncTimeSequencesTests",
40+
dependencies: [
41+
"AsyncTimeSequences",
42+
"AsyncTimeSequencesSupport",
43+
],
44+
path: "Tests"
45+
),
46+
]
4747
)

Sources/AsyncTimeSequences/AsyncScheduler/AsyncScheduler.swift

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import Foundation
99

10-
public typealias AsyncSchedulerHandler = () async -> Void
10+
public typealias AsyncSchedulerHandler = @Sendable () async -> Void
1111

1212
public protocol AsyncScheduler: Actor {
1313
var now: TimeInterval { get }
@@ -23,6 +23,7 @@ public actor MainAsyncScheduler: AsyncScheduler {
2323
lazy var idCounter: UInt = 0
2424
lazy var completedElementIds = Set<UInt>()
2525
lazy var cancelledElementIds = Set<UInt>()
26+
private var isCompletingElement = false
2627

2728
public var now: TimeInterval {
2829
Date().timeIntervalSince1970
@@ -34,8 +35,6 @@ public actor MainAsyncScheduler: AsyncScheduler {
3435
/// - parameter handler: async closure to be executed when 'after' time elapses
3536
///
3637
/// - Returns: reference to a Task which supports cancellation
37-
///
38-
/// - Complexity: O(log n) where n is the number of elements currently scheduled
3938
@discardableResult
4039
public func schedule(
4140
after: TimeInterval,
@@ -52,36 +51,69 @@ public actor MainAsyncScheduler: AsyncScheduler {
5251

5352
increaseCounterId()
5453

55-
return Task {
56-
try? await Task.sleep(nanoseconds: UInt64(after * 1_000_000_000))
57-
await complete(currentId: currentId, cancelled: Task.isCancelled)
58-
}
54+
return createScheduledExecutionTask(currentId: currentId, after: after)
5955
}
6056

6157
/// Based on the timeIntervalSince1970 from Date, the smallest intervals will need
6258
/// to complete before other elements' handlers can be executed. Due to the nature
6359
/// of Tasks, there could be some situations where some tasks scheduled to finish
6460
/// before others finish first. This could potentially have unwanted behaviors on
65-
/// objects scheduling events. To address this matter, a minimum priority queue
66-
/// is critical to always keep the first element that should be completed in the
67-
/// top of the queue. Once its task completes, a Set will keep track of all
61+
/// objects scheduling events.
62+
///
63+
/// - parameter currentId: integer variable denoting handler/task id
64+
///
65+
/// - Returns: reference to a Task which supports cancellation
66+
private func createScheduledExecutionTask(
67+
currentId: UInt,
68+
after: TimeInterval
69+
) -> Task<Void, Never> {
70+
return Task {
71+
try? await Task.sleep(for: .seconds(after))
72+
73+
completedElementIds.insert(currentId)
74+
if Task.isCancelled {
75+
cancelledElementIds.insert(currentId)
76+
}
77+
78+
// Make sure that only one complete method is running at all times.
79+
// The reason why this is important is because there is an inner await in a while loop which
80+
// releases the execution of this actor and it cause race conditions if another scheduled
81+
// task completes within the time this method is executing causing a weird state where two
82+
// while loops might have erroneous values and destroy the serial execution intended from
83+
// this method.
84+
guard !isCompletingElement else { return }
85+
86+
// Block any other Tasks from calling complete from this point.
87+
isCompletingElement = true
88+
89+
await complete(currentId: currentId)
90+
91+
// Allow any future callers of this method to call complete.
92+
isCompletingElement = false
93+
}
94+
}
95+
96+
/// This method runs the completion handler for a given scheduled item matching the `currentId`.
97+
///
98+
/// A minimum priority queue is critical to always keep the first element that should be
99+
/// completed in the top of the queue. Once its task completes, a Set will keep track of all
68100
/// completed ID tasks that are yet to be executed. If the current top element of
69101
/// the queue has already completed, its closure will execute. This will repeat
70102
/// until all completed top elements of the queue are executed.
71103
/// The obvious drawback of this handling, is that a small delay could be
72104
/// introduced to some scheduled async-closures. Ideally, this would be in the
73105
/// order of micro/nanoseconds depending of the system load.
74106
///
75-
/// - parameter currentId: integer variable denoting handler/task id
76-
/// - parameter cancelled: boolean flag required to determine whether or not to execute the handler
107+
/// This method will execute an inner loop resolving all available completed elements.
108+
///
109+
/// Note that his actor switches execution and during this `paused` time another scheduled task
110+
/// can complete and call `complete`. It is important to have only one `complete` running at
111+
/// all times (specifically, due the inner while loop).
112+
///
113+
/// This method should only be called from within `createScheduledExecutionTask`.
77114
///
78115
/// - Complexity: O(log n) where n is the number of elements currently scheduled
79-
private func complete(currentId: UInt, cancelled: Bool) async {
80-
completedElementIds.insert(currentId)
81-
if cancelled {
82-
cancelledElementIds.insert(currentId)
83-
}
84-
116+
private func complete(currentId: UInt) async {
85117
while let minElement = queue.peek, completedElementIds.contains(minElement.id) {
86118
queue.removeFirst()
87119
completedElementIds.remove(minElement.id)

Sources/AsyncTimeSequences/AsyncScheduler/AsyncSchedulerHandlerElement.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct AsyncSchedulerHandlerElement {
1616
extension AsyncSchedulerHandlerElement: Comparable {
1717
static func < (lhs: AsyncSchedulerHandlerElement, rhs: AsyncSchedulerHandlerElement) -> Bool {
1818
if lhs.time == rhs.time {
19-
return lhs.id <= rhs.id
19+
return lhs.id < rhs.id
2020
}
2121
return lhs.time < rhs.time
2222
}

Sources/AsyncTimeSequences/Debounce/AsyncDebounceSequence.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import Combine
99
import Foundation
1010

11-
public struct AsyncDebounceSequence<Base: AsyncSequence> {
11+
public struct AsyncDebounceSequence<Base: AsyncSequence> where Base.Element: Sendable {
1212
@usableFromInline
1313
let base: Base
1414

@@ -26,7 +26,7 @@ public struct AsyncDebounceSequence<Base: AsyncSequence> {
2626
}
2727
}
2828

29-
extension AsyncSequence {
29+
extension AsyncSequence where Element: Sendable {
3030
@inlinable
3131
public __consuming func debounce(
3232
for interval: TimeInterval,
@@ -36,7 +36,7 @@ extension AsyncSequence {
3636
}
3737
}
3838

39-
extension AsyncDebounceSequence: AsyncSequence {
39+
extension AsyncDebounceSequence: AsyncSequence where Base.Element: Sendable {
4040

4141
public typealias Element = Base.Element
4242
/// The type of iterator that produces elements of the sequence.
@@ -107,7 +107,7 @@ extension AsyncDebounceSequence: AsyncSequence {
107107
}
108108

109109
@usableFromInline
110-
struct Debounce {
110+
struct Debounce: @unchecked Sendable {
111111
private var baseIterator: Base.AsyncIterator
112112
private let actor: DebounceActor
113113

@@ -138,13 +138,13 @@ extension AsyncDebounceSequence: AsyncSequence {
138138
@inlinable
139139
public __consuming func makeAsyncIterator() -> AsyncStream<Base.Element>.Iterator {
140140
return AsyncStream { (continuation: AsyncStream<Base.Element>.Continuation) in
141+
var debounce = Debounce(
142+
baseIterator: base.makeAsyncIterator(),
143+
continuation: continuation,
144+
interval: interval,
145+
scheduler: scheduler
146+
)
141147
Task {
142-
var debounce = Debounce(
143-
baseIterator: base.makeAsyncIterator(),
144-
continuation: continuation,
145-
interval: interval,
146-
scheduler: scheduler
147-
)
148148
await debounce.start()
149149
}
150150
}.makeAsyncIterator()

0 commit comments

Comments
 (0)