From 343f92111dc8f603c0021a78b129ad1c47e119a3 Mon Sep 17 00:00:00 2001 From: Mykyta Konopelko Date: Fri, 27 Sep 2024 10:08:49 +0200 Subject: [PATCH] Swift 6.0 --- .github/workflows/swift_macos.yml | 57 ++++++++++ .gitignore | 1 + .spi.yml | 8 ++ Package.resolved | 32 ------ Package.swift | 21 +--- Package@swift-5.6.swift | 46 -------- Package@swift-5.7.swift | 46 -------- Package@swift-5.8.swift | 17 +-- ...swift-5.5.swift => Package@swift-5.9.swift | 22 +--- README.md | 33 ++++++ Source/Atomic.swift | 22 ++-- Source/DelayedQueue.swift | 15 ++- Source/IsolatedMain.swift | 65 +++++++++++ .../PrivacyInfo.xcprivacy | 0 Source/Queue+Queueable.swift | 6 +- Source/Queue.swift | 24 ++++- Source/Queueable.swift | 8 +- Source/UnSendable.swift | 15 +++ TestHelpers/FakeMutexing.swift | 30 ------ TestHelpers/FakeQueue.swift | 86 --------------- Tests/DelayedQueueTests.swift | 67 ++++++------ Tests/IsolatedMainTests.swift | 101 ++++++++++++++++++ .../DelayedQueue+TestHelper.swift | 4 +- Tests/TestHelpers/FakeMutexing.swift | 24 +++++ Tests/TestHelpers/FakeQueueable.swift | 74 +++++++++++++ .../TestHelpers}/Queue+TestHelper.swift | 0 26 files changed, 473 insertions(+), 351 deletions(-) create mode 100644 .github/workflows/swift_macos.yml create mode 100644 .spi.yml delete mode 100644 Package.resolved delete mode 100644 Package@swift-5.6.swift delete mode 100644 Package@swift-5.7.swift rename Package@swift-5.5.swift => Package@swift-5.9.swift (54%) create mode 100644 Source/IsolatedMain.swift rename PrivacyInfo.xcprivacy => Source/PrivacyInfo.xcprivacy (100%) create mode 100644 Source/UnSendable.swift delete mode 100644 TestHelpers/FakeMutexing.swift delete mode 100644 TestHelpers/FakeQueue.swift create mode 100644 Tests/IsolatedMainTests.swift rename {TestHelpers => Tests/TestHelpers}/DelayedQueue+TestHelper.swift (91%) create mode 100644 Tests/TestHelpers/FakeMutexing.swift create mode 100644 Tests/TestHelpers/FakeQueueable.swift rename {TestHelpers => Tests/TestHelpers}/Queue+TestHelper.swift (100%) diff --git a/.github/workflows/swift_macos.yml b/.github/workflows/swift_macos.yml new file mode 100644 index 0000000..1bb4e88 --- /dev/null +++ b/.github/workflows/swift_macos.yml @@ -0,0 +1,57 @@ +name: "NikSativa CI" + +on: + push: + branches: + - "main" + paths: + - ".github/workflows/**" + - "Package.swift" + - "Source/**" + - "Tests/**" + pull_request: + paths: + - ".github/workflows/**" + - "Package.swift" + - "Source/**" + - "Tests/**" + +concurrency: + group: ${{ github.ref_name }} + cancel-in-progress: true +jobs: + macOS: + name: "macOS ${{ matrix.xcode }} ${{ matrix.swift }}" + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - xcode: "Xcode_16" + runsOn: macOS-15 + swift: "6.0" + outputFilter: xcbeautify --renderer github-actions + - xcode: "Xcode_15.4" + runsOn: macOS-14 + swift: "5.10" + outputFilter: xcbeautify --renderer github-actions + - xcode: "Xcode_15.2" + runsOn: macOS-14 + swift: "5.9" + outputFilter: xcbeautify --renderer github-actions + - xcode: "Xcode_14.3" + runsOn: macOS-13 + swift: "5.8" + outputFilter: xcbeautify --renderer github-actions + steps: + - uses: NeedleInAJayStack/setup-swift@feat/swift-6 # swift-actions/setup-swift@main + with: + swift-version: ${{ matrix.swift }} + - uses: actions/checkout@v4 + - name: "Build ${{ matrix.xcode }} ${{ matrix.swift }}" + run: swift build -v | ${{ matrix.outputFilter }} + - name: "Test ${{ matrix.xcode }} ${{ matrix.swift }}" + run: swift test -v | ${{ matrix.outputFilter }} diff --git a/.gitignore b/.gitignore index 632e81f..a6e9389 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ Pods/* Carthage/* Packages/* .swiftpm/* +Package.resolved ## Bundler .bundle/ diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..fa70a9c --- /dev/null +++ b/.spi.yml @@ -0,0 +1,8 @@ +# This is manifest file for the Swift Package Index for it to +# auto-generate and host DocC documentation. +# For reference see https://swiftpackageindex.com/swiftpackageindex/spimanifest/documentation/spimanifest/commonusecases#Host-DocC-documentation-in-the-Swift-Package-Index. + +version: 1 +builder: + configs: + - documentation_targets: [Threading] diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index e26568d..0000000 --- a/Package.resolved +++ /dev/null @@ -1,32 +0,0 @@ -{ - "pins" : [ - { - "identity" : "cwlcatchexception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", - "state" : { - "revision" : "3ef6999c73b6938cc0da422f2c912d0158abb0a0", - "version" : "2.2.0" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "2ef56b2caf25f55fa7eef8784c30d5a767550f54", - "version" : "2.2.1" - } - }, - { - "identity" : "sprykit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/NikSativa/SpryKit.git", - "state" : { - "revision" : "f14ae68bccd324f20cd74d65413cb9c93a5ba4dd", - "version" : "2.2.3" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index fa4f103..b60c4da 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 // swiftformat:disable all import PackageDescription @@ -13,33 +13,20 @@ let package = Package( .watchOS(.v6) ], products: [ - .library(name: "Threading", targets: ["Threading"]), - .library(name: "ThreadingTestHelpers", targets: ["ThreadingTestHelpers"]) + .library(name: "Threading", targets: ["Threading"]) ], dependencies: [ - .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "2.2.3")) + .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "3.0.0")) ], targets: [ .target(name: "Threading", - dependencies: [ - ], path: "Source", resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .target(name: "ThreadingTestHelpers", - dependencies: [ - "Threading", - "SpryKit" - ], - path: "TestHelpers", - resources: [ - .copy("../PrivacyInfo.xcprivacy") + .process("PrivacyInfo.xcprivacy") ]), .testTarget(name: "ThreadingTests", dependencies: [ "Threading", - "ThreadingTestHelpers", "SpryKit" ], path: "Tests") diff --git a/Package@swift-5.6.swift b/Package@swift-5.6.swift deleted file mode 100644 index 8ad04fa..0000000 --- a/Package@swift-5.6.swift +++ /dev/null @@ -1,46 +0,0 @@ -// swift-tools-version:5.6 -// swiftformat:disable all -import PackageDescription - -let package = Package( - name: "Threading", - platforms: [ - .iOS(.v13), - .macOS(.v11), - .macCatalyst(.v13), - .tvOS(.v13), - .watchOS(.v6) - ], - products: [ - .library(name: "Threading", targets: ["Threading"]), - .library(name: "ThreadingTestHelpers", targets: ["ThreadingTestHelpers"]) - ], - dependencies: [ - .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "2.2.3")) - ], - targets: [ - .target(name: "Threading", - dependencies: [ - ], - path: "Source", - resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .target(name: "ThreadingTestHelpers", - dependencies: [ - "Threading", - "SpryKit" - ], - path: "TestHelpers", - resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .testTarget(name: "ThreadingTests", - dependencies: [ - "Threading", - "ThreadingTestHelpers", - "SpryKit" - ], - path: "Tests") - ] -) diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift deleted file mode 100644 index 92dbfe4..0000000 --- a/Package@swift-5.7.swift +++ /dev/null @@ -1,46 +0,0 @@ -// swift-tools-version:5.7 -// swiftformat:disable all -import PackageDescription - -let package = Package( - name: "Threading", - platforms: [ - .iOS(.v13), - .macOS(.v11), - .macCatalyst(.v13), - .tvOS(.v13), - .watchOS(.v6) - ], - products: [ - .library(name: "Threading", targets: ["Threading"]), - .library(name: "ThreadingTestHelpers", targets: ["ThreadingTestHelpers"]) - ], - dependencies: [ - .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "2.2.3")) - ], - targets: [ - .target(name: "Threading", - dependencies: [ - ], - path: "Source", - resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .target(name: "ThreadingTestHelpers", - dependencies: [ - "Threading", - "SpryKit" - ], - path: "TestHelpers", - resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .testTarget(name: "ThreadingTests", - dependencies: [ - "Threading", - "ThreadingTestHelpers", - "SpryKit" - ], - path: "Tests") - ] -) diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift index 688876e..ad98ac6 100644 --- a/Package@swift-5.8.swift +++ b/Package@swift-5.8.swift @@ -13,32 +13,19 @@ let package = Package( ], products: [ .library(name: "Threading", targets: ["Threading"]), - .library(name: "ThreadingTestHelpers", targets: ["ThreadingTestHelpers"]) ], dependencies: [ - .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "2.2.3")) + .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "3.0.0")) ], targets: [ .target(name: "Threading", - dependencies: [ - ], path: "Source", resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .target(name: "ThreadingTestHelpers", - dependencies: [ - "Threading", - "SpryKit" - ], - path: "TestHelpers", - resources: [ - .copy("../PrivacyInfo.xcprivacy") + .process("PrivacyInfo.xcprivacy") ]), .testTarget(name: "ThreadingTests", dependencies: [ "Threading", - "ThreadingTestHelpers", "SpryKit" ], path: "Tests") diff --git a/Package@swift-5.5.swift b/Package@swift-5.9.swift similarity index 54% rename from Package@swift-5.5.swift rename to Package@swift-5.9.swift index c589354..639ccea 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.9.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 // swiftformat:disable all import PackageDescription @@ -9,36 +9,24 @@ let package = Package( .macOS(.v11), .macCatalyst(.v13), .tvOS(.v13), - .watchOS(.v6) + .watchOS(.v6), + .visionOS(.v1) ], products: [ .library(name: "Threading", targets: ["Threading"]), - .library(name: "ThreadingTestHelpers", targets: ["ThreadingTestHelpers"]) ], dependencies: [ - .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "2.2.3")) + .package(url: "https://github.com/NikSativa/SpryKit.git", .upToNextMajor(from: "3.0.0")) ], targets: [ .target(name: "Threading", - dependencies: [ - ], path: "Source", resources: [ - .copy("../PrivacyInfo.xcprivacy") - ]), - .target(name: "ThreadingTestHelpers", - dependencies: [ - "Threading", - "SpryKit" - ], - path: "TestHelpers", - resources: [ - .copy("../PrivacyInfo.xcprivacy") + .process("PrivacyInfo.xcprivacy") ]), .testTarget(name: "ThreadingTests", dependencies: [ "Threading", - "ThreadingTestHelpers", "SpryKit" ], path: "Tests") diff --git a/README.md b/README.md index d6a7d1b..61adca2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Queue.main.sync { ### Queueable Protocol can help you test your code without threading by overriding real implementation via your own mock or existing Fake from SpryKit framework. + ### DelayedQueue Make it simple to manage task execution as parameter at your discretion. You can manage not only in what Queue to execute but also how - sync or async. @@ -38,3 +39,35 @@ you can never go wrong with creating a queue due to explicit parameters ```swift Queue.custom(label: “my line”, attributes: .serial).async ``` + +### Concurrency workarounds + +Some times very difficult to avoid concurrency isolation issues. This is a simple solution to avoid it. Just use isolatedMain queue to execute your task on the main thread without any side effects. + +```swift +Queue.isolatedMain.sync { + // your task on main thread +} + +Queue.isolatedMain.sync { + // or return value from main thread + return 42 +} + +Queue.isolatedMain.sync { + // or throw error from main thread + return try someThrowingFunction() +} +``` + +UnSendable - is a struct that helps you to avoid concurrency check of non-Sendable objects (ex. using UI elements). It is not a silver bullet, but it can help you to avoid some issues. +> [!WARNING] +> **Use at your own risk.** + +```swift +let unsafe = UnSendable(ImageView()) +Queue.main.async { + let view = unsafe.value + // make your magic +} +``` diff --git a/Source/Atomic.swift b/Source/Atomic.swift index 31cda35..8dedf90 100644 --- a/Source/Atomic.swift +++ b/Source/Atomic.swift @@ -1,6 +1,6 @@ import Foundation -public protocol Mutexing { +public protocol Mutexing: Sendable { @discardableResult func sync(execute work: () throws -> R) rethrows -> R @@ -8,8 +8,8 @@ public protocol Mutexing { func trySync(execute work: () throws -> R) rethrows -> R } -public enum Mutex { - public enum Kind { +public enum Mutex: Sendable { + public enum Kind: Sendable { case normal case recursive @@ -41,14 +41,14 @@ public enum Mutex { } } -public enum AtomicOption: Equatable { +public enum AtomicOption: Equatable, Sendable { case async case sync case trySync } @propertyWrapper -public final class Atomic { +public final class Atomic: @unchecked Sendable { private let mutex: Mutexing private var value: Value private let read: AtomicOption @@ -136,7 +136,7 @@ public extension Atomic where Value: ExpressibleByNilLiteral { } } -private protocol Locking { +private protocol Locking: Sendable { func lock() func tryLock() -> Bool func unlock() @@ -183,7 +183,7 @@ extension NSRecursiveLock: Locking { } private enum Impl { - final class Unfair: SimpleMutexing { + final class Unfair: SimpleMutexing, @unchecked Sendable { private var _lock = os_unfair_lock() func lock() { @@ -199,7 +199,7 @@ private enum Impl { } } - struct NSLock: SimpleMutexing { + struct NSLock: SimpleMutexing, @unchecked Sendable { private let _lock: Locking public init(kind: Mutex.Kind) { @@ -224,7 +224,7 @@ private enum Impl { } } - final class PThread: SimpleMutexing { + final class PThread: SimpleMutexing, @unchecked Sendable { private var _lock: pthread_mutex_t = .init() public init(kind: Mutex.Kind) { @@ -265,7 +265,7 @@ private enum Impl { } } - struct Semaphore: Mutexing { + struct Semaphore: Mutexing, @unchecked Sendable { private var _lock = DispatchSemaphore(value: 1) func sync(execute work: () throws -> R) rethrows -> R { @@ -285,7 +285,7 @@ private enum Impl { } } - struct Barrier: Mutexing { + struct Barrier: Mutexing, @unchecked Sendable { private let queue: Queueable init(_ queue: Queueable) { diff --git a/Source/DelayedQueue.swift b/Source/DelayedQueue.swift index 4829f95..67fbfd3 100644 --- a/Source/DelayedQueue.swift +++ b/Source/DelayedQueue.swift @@ -1,6 +1,7 @@ import Foundation -public enum DelayedQueue { +#if swift(>=6.0) +public enum DelayedQueue: Sendable { case absent case sync(Queueable) @@ -8,9 +9,19 @@ public enum DelayedQueue { case asyncAfter(deadline: DispatchTime, queue: Queueable) case asyncAfterWithFlags(deadline: DispatchTime, flags: Queue.Flags, queue: Queueable) } +#else +public enum DelayedQueue: @unchecked Sendable { + case absent + case sync(Queueable) + + case async(Queueable) + case asyncAfter(deadline: DispatchTime, queue: Queueable) + case asyncAfterWithFlags(deadline: DispatchTime, flags: Queue.Flags, queue: Queueable) +} +#endif public extension DelayedQueue { - func fire(_ workItem: @escaping () -> Void) { + func fire(_ workItem: @escaping @Sendable () -> Void) { switch self { case .absent: workItem() diff --git a/Source/IsolatedMain.swift b/Source/IsolatedMain.swift new file mode 100644 index 0000000..b5f88ee --- /dev/null +++ b/Source/IsolatedMain.swift @@ -0,0 +1,65 @@ +import Foundation + +public extension Queue { + static var isolatedMain: IsolatedMain.Type { + return IsolatedMain.self + } +} + +public struct IsolatedMain { + // namespace +} + +#if swift(>=6.0) +public extension IsolatedMain { + @inline(__always) + static func sync(_ closure: @MainActor () -> Void) { + return Queue.main.sync { + return MainActor.assumeIsolated { + return closure() + } + } + } + + @inline(__always) + static func sync(_ closure: @MainActor () -> T) -> T { + return Queue.main.sync { + return MainActor.assumeIsolated { + return closure() + } + } + } + + @inline(__always) + static func sync(_ closure: @MainActor () throws -> T) throws -> T { + return try Queue.main.sync { + return try MainActor.assumeIsolated { + return try closure() + } + } + } +} +#else +public extension IsolatedMain { + @inline(__always) + static func sync(_ closure: () -> Void) { + return Queue.main.sync { + return closure() + } + } + + @inline(__always) + static func sync(_ closure: () -> T) -> T { + return Queue.main.sync { + return closure() + } + } + + @inline(__always) + static func sync(_ closure: () throws -> T) throws -> T { + return try Queue.main.sync { + return try closure() + } + } +} +#endif diff --git a/PrivacyInfo.xcprivacy b/Source/PrivacyInfo.xcprivacy similarity index 100% rename from PrivacyInfo.xcprivacy rename to Source/PrivacyInfo.xcprivacy diff --git a/Source/Queue+Queueable.swift b/Source/Queue+Queueable.swift index 9d835f6..6642874 100644 --- a/Source/Queue+Queueable.swift +++ b/Source/Queue+Queueable.swift @@ -3,20 +3,20 @@ import Foundation // MARK: - Queue + Queueable extension Queue: Queueable { - public func async(execute workItem: @escaping () -> Void) { + public func async(execute workItem: @escaping @Sendable () -> Void) { sdk.async(execute: workItem) } public func asyncAfter(deadline: DispatchTime, flags: Queue.Flags, - execute work: @escaping () -> Void) { + execute work: @escaping @Sendable () -> Void) { sdk.asyncAfter(deadline: deadline, flags: flags.toSDK(), execute: work) } public func asyncAfter(deadline: DispatchTime, - execute work: @escaping () -> Void) { + execute work: @escaping @Sendable () -> Void) { asyncAfter(deadline: deadline, flags: .absent, execute: work) diff --git a/Source/Queue.swift b/Source/Queue.swift index 58bc226..4f52881 100644 --- a/Source/Queue.swift +++ b/Source/Queue.swift @@ -1,17 +1,19 @@ +import Dispatch import Foundation -public struct Queue: Equatable { - public enum Attributes: Equatable { +public struct Queue: Equatable, @unchecked Sendable { + public enum Attributes: Equatable, Sendable { case concurrent case serial } - public enum Flags: Equatable { + public enum Flags: Equatable, Sendable { case absent case barrier } - private enum Kind: Equatable { +#if swift(>=6.0) + private enum Kind: Equatable, Sendable { case main case custom(label: String, qos: DispatchQoS = .default, @@ -23,6 +25,20 @@ public struct Queue: Equatable { case userInitiated case userInteractive } +#else + private enum Kind: Equatable, @unchecked Sendable { + case main + case custom(label: String, + qos: DispatchQoS = .default, + attributes: Attributes = .concurrent) + + case background + case utility + case `default` + case userInitiated + case userInteractive + } +#endif public static var main: Self { return Queue(kind: .main, diff --git a/Source/Queueable.swift b/Source/Queueable.swift index be38f77..06dda14 100644 --- a/Source/Queueable.swift +++ b/Source/Queueable.swift @@ -1,13 +1,13 @@ import Foundation -public protocol Queueable { - func async(execute workItem: @escaping () -> Void) +public protocol Queueable: Sendable { + func async(execute workItem: @escaping @Sendable () -> Void) func asyncAfter(deadline: DispatchTime, flags: Queue.Flags, - execute work: @escaping () -> Void) + execute work: @escaping @Sendable () -> Void) func asyncAfter(deadline: DispatchTime, - execute work: @escaping () -> Void) + execute work: @escaping @Sendable () -> Void) func sync(execute workItem: () -> Void) func sync(execute workItem: () throws -> Void) rethrows diff --git a/Source/UnSendable.swift b/Source/UnSendable.swift new file mode 100644 index 0000000..84bbc17 --- /dev/null +++ b/Source/UnSendable.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Workaround for passing non-dispatched objects to a closure marked as dispatched. Use this only when you are sure the object is not shared between threads +/// - Warning: Use at your own risk. +public struct UnSendable: @unchecked Sendable { + public let value: T + + public init(value: T) { + self.value = value + } + + public init(_ value: T) { + self.value = value + } +} diff --git a/TestHelpers/FakeMutexing.swift b/TestHelpers/FakeMutexing.swift deleted file mode 100644 index d002555..0000000 --- a/TestHelpers/FakeMutexing.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import SpryKit -import Threading - -public final class FakeMutexing: Mutexing, Spryable { - public enum ClassFunction: String, StringRepresentable { - case empty - } - - public enum Function: String, StringRepresentable { - case async = "sync(execute:)" - case trySync = "trySync(execute:)" - } - - public var shouldFireClosures: Bool = false - - public func sync(execute work: () throws -> R) rethrows -> R { - if shouldFireClosures { - return try spryify(fallbackValue: work()) - } - return spryify() - } - - public func trySync(execute work: () throws -> R) rethrows -> R { - if shouldFireClosures { - return try spryify(fallbackValue: work()) - } - return spryify() - } -} diff --git a/TestHelpers/FakeQueue.swift b/TestHelpers/FakeQueue.swift deleted file mode 100644 index cae0302..0000000 --- a/TestHelpers/FakeQueue.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import SpryKit -import Threading - -public final class FakeQueueable: Queueable, Spryable { - public enum ClassFunction: String, StringRepresentable { - case empty - } - - public enum Function: String, StringRepresentable { - case async = "async(execute:)" - case asyncAfter = "asyncAfter(deadline:execute:)" - case asyncAfterWithFlags = "asyncAfter(deadline:flags:execute:)" - - case sync = "sync(execute:)" - case syncWithFlags = "sync(flags:execute:)" - } - - public init() {} - - public var shouldFireSyncClosures: Bool = false - public var asyncWorkItem: (() -> Void)? - - public func async(execute workItem: @escaping () -> Void) { - asyncWorkItem = workItem - return spryify(arguments: workItem) - } - - public func asyncAfter(deadline: DispatchTime, flags: Queue.Flags, execute work: @escaping () -> Void) { - asyncWorkItem = work - return spryify(arguments: deadline, flags, work) - } - - public func asyncAfter(deadline: DispatchTime, execute work: @escaping () -> Void) { - asyncWorkItem = work - return spryify(arguments: deadline, work) - } - - public func sync(execute workItem: () -> Void) { - if shouldFireSyncClosures { - workItem() - } - - return spryify() - } - - public func sync(execute workItem: () throws -> Void) rethrows { - if shouldFireSyncClosures { - try workItem() - } - - return spryify() - } - - public func sync(flags: Queue.Flags, execute work: () throws -> T) rethrows -> T { - if shouldFireSyncClosures { - return try spryify(arguments: flags, fallbackValue: work()) - } - - return spryify(arguments: flags) - } - - public func sync(execute work: () throws -> T) rethrows -> T { - if shouldFireSyncClosures { - return try spryify(fallbackValue: work()) - } - - return spryify() - } - - public func sync(flags: Queue.Flags, execute work: () -> T) -> T { - if shouldFireSyncClosures { - return spryify(arguments: flags, fallbackValue: work()) - } - - return spryify(arguments: flags) - } - - public func sync(execute work: () -> T) -> T { - if shouldFireSyncClosures { - return spryify(fallbackValue: work()) - } - - return spryify() - } -} diff --git a/Tests/DelayedQueueTests.swift b/Tests/DelayedQueueTests.swift index f1842c8..8073374 100644 --- a/Tests/DelayedQueueTests.swift +++ b/Tests/DelayedQueueTests.swift @@ -1,8 +1,8 @@ +#if canImport(SpryMacroAvailable) import Dispatch import Foundation import SpryKit import Threading -import ThreadingTestHelpers import XCTest final class DelayedQueueTests: XCTestCase { @@ -10,101 +10,95 @@ final class DelayedQueueTests: XCTestCase { func test_fake_queue_absent() { let subject: DelayedQueue = .absent - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertTrue(didCall) + wait(for: [didCall], timeout: 0) } func test_fake_queue_sync() { let queue: FakeQueueable = .init() queue.shouldFireSyncClosures = true - queue.stub(.sync).andReturn() + queue.stub(.syncWithExecute).andReturn() let subject: DelayedQueue = .sync(queue) - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertTrue(didCall) - XCTAssertHaveReceived(queue, .sync) + wait(for: [didCall], timeout: 0) + XCTAssertHaveReceived(queue, .syncWithExecute) } func test_fake_queue_async() { let queue: FakeQueueable = .init() - queue.shouldFireSyncClosures = true - queue.stub(.async).andReturn() + queue.stub(.asyncWithExecute).andReturn() let subject: DelayedQueue = .async(queue) - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertFalse(didCall) - XCTAssertHaveReceived(queue, .async) + XCTAssertHaveReceived(queue, .asyncWithExecute) queue.asyncWorkItem?() - XCTAssertTrue(didCall) + wait(for: [didCall], timeout: 1) } func test_fake_queue_async_after() { let dispatchTime = DispatchTime.delayInSeconds(1) let queue: FakeQueueable = .init() - queue.shouldFireSyncClosures = true - queue.stub(.asyncAfter).andReturn() + queue.stub(.asyncAfterWithDeadline_Execute).andReturn() let subject: DelayedQueue = .asyncAfter(deadline: dispatchTime, queue: queue) - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertFalse(didCall) - XCTAssertHaveReceived(queue, .asyncAfter, with: dispatchTime, Argument.anything) + XCTAssertHaveReceived(queue, .asyncAfterWithDeadline_Execute, with: dispatchTime, Argument.anything) queue.asyncWorkItem?() - XCTAssertTrue(didCall) + wait(for: [didCall], timeout: 0) } func test_fake_queue_async_after_with_flags() { let dispatchTime = DispatchTime.delayInSeconds(1) let queue: FakeQueueable = .init() - queue.shouldFireSyncClosures = true - queue.stub(.asyncAfterWithFlags).andReturn() + queue.stub(.asyncAfterWithDeadline_Flags_Execute).andReturn() let subject: DelayedQueue = .asyncAfterWithFlags(deadline: dispatchTime, flags: .barrier, queue: queue) - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertFalse(didCall) - XCTAssertHaveReceived(queue, .asyncAfterWithFlags, with: dispatchTime, Queue.Flags.barrier, Argument.anything) + XCTAssertHaveReceived(queue, .asyncAfterWithDeadline_Flags_Execute, with: dispatchTime, Queue.Flags.barrier, Argument.anything) queue.asyncWorkItem?() - XCTAssertTrue(didCall) + wait(for: [didCall], timeout: 0) } // MARK: - real func test_real_queue_absent() { let subject: DelayedQueue = .absent - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertTrue(didCall) + wait(for: [didCall], timeout: 0) } func test_real_queue_sync() { let queue = Queue.main let subject: DelayedQueue = .sync(queue) - var didCall = false + let didCall = expectation(description: "didCall") subject.fire { - didCall = true + didCall.fulfill() } - XCTAssertTrue(didCall) + wait(for: [didCall], timeout: 0) } func test_real_queue_async() { @@ -150,3 +144,4 @@ final class DelayedQueueTests: XCTestCase { wait(for: [didCall], timeout: 0.2) } } +#endif diff --git a/Tests/IsolatedMainTests.swift b/Tests/IsolatedMainTests.swift new file mode 100644 index 0000000..24ea1a2 --- /dev/null +++ b/Tests/IsolatedMainTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Threading +import XCTest + +@MainActor +private struct MainActorOnly { + var value: Int = 1 +} + +private enum MainActorError: Error { + case someError +} + +final class IsolatedMainTests: XCTestCase { + func test_void() async { + var notSendable: MainActorOnly = .init() + + await withCheckedContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertEqual(notSendable.value, 1) + notSendable.value += 1 + continuation.resume() + } + } + + Queue.isolatedMain.sync { + XCTAssertEqual(notSendable.value, 2) + } + } + + func test_value() async { + var notSendable: MainActorOnly = .init() + + let some = await withCheckedContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertEqual(notSendable.value, 1) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some, 2) + + let some2 = await withCheckedContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertEqual(notSendable.value, 2) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some2, 3) + + let some3 = Queue.isolatedMain.sync { + notSendable.value += 1 + return notSendable.value + } + XCTAssertEqual(some3, 4) + } + + func test_throws() async throws { + var notSendable: MainActorOnly = .init() + + let some = try await withCheckedThrowingContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertEqual(notSendable.value, 1) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some, 2) + + let some2 = try await withCheckedThrowingContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertEqual(notSendable.value, 2) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some2, 3) + + let some3 = try Queue.isolatedMain.sync { + if notSendable.value == -1 { // never happen + throw MainActorError.someError + } + notSendable.value += 1 + return notSendable.value + } + XCTAssertEqual(some3, 4) + + do { + let some: Int = try Queue.isolatedMain.sync { + if notSendable.value == 4 { + throw MainActorError.someError + } + return notSendable.value // never happen + } + XCTAssertTrue(some == -1, "should never happen: some3") + } catch { + XCTAssertEqual(error as? MainActorError, MainActorError.someError) + } + } +} diff --git a/TestHelpers/DelayedQueue+TestHelper.swift b/Tests/TestHelpers/DelayedQueue+TestHelper.swift similarity index 91% rename from TestHelpers/DelayedQueue+TestHelper.swift rename to Tests/TestHelpers/DelayedQueue+TestHelper.swift index 0fac36f..a2cc66c 100644 --- a/TestHelpers/DelayedQueue+TestHelper.swift +++ b/Tests/TestHelpers/DelayedQueue+TestHelper.swift @@ -2,9 +2,9 @@ import Foundation import SpryKit import Threading -// MARK: - DelayedQueue + Equatable, SpryEquatable +// MARK: - DelayedQueue + Equatable -extension DelayedQueue: Equatable, SpryEquatable { +extension DelayedQueue: Equatable { public static func ==(lhs: DelayedQueue, rhs: DelayedQueue) -> Bool { switch (lhs, rhs) { case (.absent, .absent): diff --git a/Tests/TestHelpers/FakeMutexing.swift b/Tests/TestHelpers/FakeMutexing.swift new file mode 100644 index 0000000..052bd63 --- /dev/null +++ b/Tests/TestHelpers/FakeMutexing.swift @@ -0,0 +1,24 @@ +#if canImport(SpryMacroAvailable) +import Foundation +import SpryKit +import Threading + +@Spryable +final class FakeMutexing: Mutexing, @unchecked Sendable { + var shouldFireClosures: Bool = false + + func sync(execute work: () throws -> R) rethrows -> R { + if shouldFireClosures { + return try spryify(fallbackValue: work()) + } + return spryify() + } + + func trySync(execute work: () throws -> R) rethrows -> R { + if shouldFireClosures { + return try spryify(fallbackValue: work()) + } + return spryify() + } +} +#endif diff --git a/Tests/TestHelpers/FakeQueueable.swift b/Tests/TestHelpers/FakeQueueable.swift new file mode 100644 index 0000000..e732f82 --- /dev/null +++ b/Tests/TestHelpers/FakeQueueable.swift @@ -0,0 +1,74 @@ +#if canImport(SpryMacroAvailable) +import Foundation +import SpryKit +import Threading + +@Spryable +final class FakeQueueable: Queueable, @unchecked Sendable { + var shouldFireSyncClosures: Bool = false + var asyncWorkItem: (() -> Void)? + + func async(execute workItem: @escaping () -> Void) { + asyncWorkItem = workItem + return spryify(arguments: workItem) + } + + func asyncAfter(deadline: DispatchTime, flags: Queue.Flags, execute work: @escaping () -> Void) { + asyncWorkItem = work + return spryify(arguments: deadline, flags, work) + } + + func asyncAfter(deadline: DispatchTime, execute work: @escaping () -> Void) { + asyncWorkItem = work + return spryify(arguments: deadline, work) + } + + func sync(execute workItem: () -> Void) { + if shouldFireSyncClosures { + workItem() + } + + return spryify() + } + + func sync(execute workItem: () throws -> Void) rethrows { + if shouldFireSyncClosures { + try workItem() + } + + return spryify() + } + + func sync(flags: Queue.Flags, execute work: () throws -> T) rethrows -> T { + if shouldFireSyncClosures { + return try spryify(arguments: flags, fallbackValue: work()) + } + + return spryify(arguments: flags) + } + + func sync(execute work: () throws -> T) rethrows -> T { + if shouldFireSyncClosures { + return try spryify(fallbackValue: work()) + } + + return spryify() + } + + func sync(flags: Queue.Flags, execute work: () -> T) -> T { + if shouldFireSyncClosures { + return spryify(arguments: flags, fallbackValue: work()) + } + + return spryify(arguments: flags) + } + + func sync(execute work: () -> T) -> T { + if shouldFireSyncClosures { + return spryify(fallbackValue: work()) + } + + return spryify() + } +} +#endif diff --git a/TestHelpers/Queue+TestHelper.swift b/Tests/TestHelpers/Queue+TestHelper.swift similarity index 100% rename from TestHelpers/Queue+TestHelper.swift rename to Tests/TestHelpers/Queue+TestHelper.swift