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..b718e06 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 @@ -8,38 +8,25 @@ let package = Package( .iOS(.v13), .macOS(.v11), .macCatalyst(.v13), - .visionOS(.v1), .tvOS(.v13), - .watchOS(.v6) + .watchOS(.v6), + .visionOS(.v1) ], 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..0df4955 100644 --- a/Source/Atomic.swift +++ b/Source/Atomic.swift @@ -1,5 +1,14 @@ import Foundation +#if swift(>=6.0) +public protocol Mutexing: Sendable { + @discardableResult + func sync(execute work: () throws -> R) rethrows -> R + + @discardableResult + func trySync(execute work: () throws -> R) rethrows -> R +} +#else public protocol Mutexing { @discardableResult func sync(execute work: () throws -> R) rethrows -> R @@ -7,6 +16,7 @@ public protocol Mutexing { @discardableResult func trySync(execute work: () throws -> R) rethrows -> R } +#endif public enum Mutex { public enum Kind { @@ -136,11 +146,19 @@ public extension Atomic where Value: ExpressibleByNilLiteral { } } +#if swift(>=6.0) +private protocol Locking: Sendable { + func lock() + func tryLock() -> Bool + func unlock() +} +#else private protocol Locking { func lock() func tryLock() -> Bool func unlock() } +#endif private protocol SimpleMutexing: Mutexing, Locking {} @@ -301,3 +319,15 @@ private enum Impl { } } } + +#if swift(>=6.0) +extension Mutex: Sendable {} +extension Mutex.Kind: Sendable {} +extension AtomicOption: Sendable {} +extension Atomic: @unchecked Sendable {} +extension Impl.Barrier: @unchecked Sendable {} +extension Impl.NSLock: @unchecked Sendable {} +extension Impl.PThread: @unchecked Sendable {} +extension Impl.Semaphore: @unchecked Sendable {} +extension Impl.Unfair: @unchecked Sendable {} +#endif diff --git a/Source/DelayedQueue.swift b/Source/DelayedQueue.swift index 4829f95..92789ec 100644 --- a/Source/DelayedQueue.swift +++ b/Source/DelayedQueue.swift @@ -9,6 +9,27 @@ public enum DelayedQueue { case asyncAfterWithFlags(deadline: DispatchTime, flags: Queue.Flags, queue: Queueable) } +#if swift(>=6.0) +extension DelayedQueue: Sendable { + public func fire(_ workItem: @escaping @Sendable () -> Void) { + switch self { + case .absent: + workItem() + case .sync(let queue): + queue.sync(execute: workItem) + case .async(let queue): + queue.async(execute: workItem) + case .asyncAfter(let deadline, let queue): + queue.asyncAfter(deadline: deadline, + execute: workItem) + case .asyncAfterWithFlags(let deadline, let flags, let queue): + queue.asyncAfter(deadline: deadline, + flags: flags, + execute: workItem) + } + } +} +#else public extension DelayedQueue { func fire(_ workItem: @escaping () -> Void) { switch self { @@ -27,7 +48,12 @@ public extension DelayedQueue { execute: workItem) } } +} +#endif +// MARK: - DelayedQueue.n + +public extension DelayedQueue { /// namespace for shortcut /// /// interface: 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..21f7124 100644 --- a/Source/Queue+Queueable.swift +++ b/Source/Queue+Queueable.swift @@ -3,6 +3,26 @@ import Foundation // MARK: - Queue + Queueable extension Queue: Queueable { + #if swift(>=6.0) + public func async(execute workItem: @escaping @Sendable () -> Void) { + sdk.async(execute: workItem) + } + + public func asyncAfter(deadline: DispatchTime, + flags: Queue.Flags, + execute work: @escaping @Sendable () -> Void) { + sdk.asyncAfter(deadline: deadline, + flags: flags.toSDK(), + execute: work) + } + + public func asyncAfter(deadline: DispatchTime, + execute work: @escaping @Sendable () -> Void) { + asyncAfter(deadline: deadline, + flags: .absent, + execute: work) + } + #else public func async(execute workItem: @escaping () -> Void) { sdk.async(execute: workItem) } @@ -21,6 +41,7 @@ extension Queue: Queueable { flags: .absent, execute: work) } + #endif public func sync(execute workItem: () -> Void) { if isMainThread { diff --git a/Source/Queue.swift b/Source/Queue.swift index 58bc226..5f72c47 100644 --- a/Source/Queue.swift +++ b/Source/Queue.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation public struct Queue: Equatable { @@ -11,7 +12,7 @@ public struct Queue: Equatable { case barrier } - private enum Kind: Equatable { + fileprivate enum Kind: Equatable { case main case custom(label: String, qos: DispatchQoS = .default, @@ -24,39 +25,44 @@ public struct Queue: Equatable { case userInteractive } - public static var main: Self { + let sdk: DispatchQueue + private let kind: Kind +} + +public extension Queue { + static var main: Self { return Queue(kind: .main, sdk: .main) } - public static var background: Self { + static var background: Self { return Queue(kind: .background, sdk: .global(qos: .background)) } - public static var utility: Self { + static var utility: Self { return Queue(kind: .utility, sdk: .global(qos: .utility)) } - public static var `default`: Self { + static var `default`: Self { return Queue(kind: .default, sdk: .global(qos: .default)) } - public static var userInitiated: Self { + static var userInitiated: Self { return Queue(kind: .userInitiated, sdk: .global(qos: .userInitiated)) } - public static var userInteractive: Self { + static var userInteractive: Self { return Queue(kind: .userInteractive, sdk: .global(qos: .userInteractive)) } - public static func custom(label: String, - qos: DispatchQoS = .default, - attributes: Attributes = .concurrent) -> Self { + static func custom(label: String, + qos: DispatchQoS = .default, + attributes: Attributes = .concurrent) -> Self { return Queue(kind: .custom(label: label, qos: qos, attributes: attributes), @@ -65,9 +71,6 @@ public struct Queue: Equatable { attributes: attributes.toSDK())) } - public let sdk: DispatchQueue - private let kind: Kind - internal var isMain: Bool { return kind == .main } @@ -89,3 +92,10 @@ private extension Queue.Attributes { } } } + +#if swift(>=6.0) +extension Queue: @unchecked Sendable {} +extension Queue.Attributes: Sendable {} +extension Queue.Flags: Sendable {} +extension Queue.Kind: Sendable {} +#endif diff --git a/Source/Queueable.swift b/Source/Queueable.swift index be38f77..d25ab81 100644 --- a/Source/Queueable.swift +++ b/Source/Queueable.swift @@ -1,5 +1,25 @@ import Foundation +#if swift(>=6.0) +public protocol Queueable: Sendable { + func async(execute workItem: @escaping @Sendable () -> Void) + + func asyncAfter(deadline: DispatchTime, + flags: Queue.Flags, + execute work: @escaping @Sendable () -> Void) + func asyncAfter(deadline: DispatchTime, + execute work: @escaping @Sendable () -> Void) + + func sync(execute workItem: () -> Void) + func sync(execute workItem: () throws -> Void) rethrows + + func sync(flags: Queue.Flags, execute work: () throws -> T) rethrows -> T + func sync(execute work: () throws -> T) rethrows -> T + + func sync(flags: Queue.Flags, execute work: () -> T) -> T + func sync(execute work: () -> T) -> T +} +#else public protocol Queueable { func async(execute workItem: @escaping () -> Void) @@ -18,3 +38,4 @@ public protocol Queueable { func sync(flags: Queue.Flags, execute work: () -> T) -> T func sync(execute work: () -> T) -> T } +#endif diff --git a/Source/UnSendable.swift b/Source/UnSendable.swift new file mode 100644 index 0000000..b422cc1 --- /dev/null +++ b/Source/UnSendable.swift @@ -0,0 +1,31 @@ +import Foundation + +#if swift(>=6.0) +/// 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 + } +} +#else +/// 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 { + public let value: T + + public init(value: T) { + self.value = value + } + + public init(_ value: T) { + self.value = value + } +} +#endif 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/DispatchTime_QueueTests.swift b/Tests/DispatchTime_QueueTests.swift index 1152a1d..228ffb1 100644 --- a/Tests/DispatchTime_QueueTests.swift +++ b/Tests/DispatchTime_QueueTests.swift @@ -1,3 +1,4 @@ +#if arch(arm64) import Dispatch import Foundation import SpryKit @@ -35,7 +36,8 @@ private func XCTAssertEqual(_ expression1: @autoclosure () -> DispatchTime, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { - let lhs = Float(expression1().uptimeNanoseconds) / 1000 - let rhs = Float(expression2().uptimeNanoseconds) / 1000 + let lhs = Float(expression1().uptimeNanoseconds) / 1E+8 // github very slow... + let rhs = Float(expression2().uptimeNanoseconds) / 1E+8 XCTAssertEqual(lhs, rhs, accuracy: 0.1, message(), file: file, line: line) } +#endif diff --git a/Tests/IsolatedMainTests.swift b/Tests/IsolatedMainTests.swift new file mode 100644 index 0000000..057b9aa --- /dev/null +++ b/Tests/IsolatedMainTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Threading +import XCTest + +#if swift(>=6.0) +private struct MainActorOnly: Sendable { + var value: Int = 1 +} +#else +private struct MainActorOnly { + var value: Int = 1 +} +#endif + +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 { + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(notSendable.value, 1) + notSendable.value += 1 + continuation.resume() + } + } + + Queue.isolatedMain.sync { + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(notSendable.value, 2) + } + } + + func test_value() async { + var notSendable: MainActorOnly = .init() + + let some = await withCheckedContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(notSendable.value, 1) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some, 2) + + let some2 = await withCheckedContinuation { continuation in + Queue.isolatedMain.sync { + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(notSendable.value, 2) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some2, 3) + + let some3 = Queue.isolatedMain.sync { + XCTAssertTrue(Thread.isMainThread) + 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 { + XCTAssertTrue(Thread.isMainThread) + 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 { + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(notSendable.value, 2) + notSendable.value += 1 + continuation.resume(returning: notSendable.value) + } + } + XCTAssertEqual(some2, 3) + + let some3 = try Queue.isolatedMain.sync { + XCTAssertTrue(Thread.isMainThread) + 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 { + XCTAssertTrue(Thread.isMainThread) + 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/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