From ec2d4dc6de629252eaa557bb40cb19d70f7bb19b 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 | 25 +- 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 | 304 ++++++++++++++++++ Source/DelayedQueue.swift | 37 ++- Source/IsolatedMain.swift | 42 +++ .../PrivacyInfo.xcprivacy | 0 Source/Queue+Queueable.swift | 21 ++ Source/Queue.swift | 57 +++- Source/Queueable.swift | 21 ++ Source/UnSendable.swift | 17 + TestHelpers/FakeMutexing.swift | 30 -- TestHelpers/FakeQueue.swift | 86 ----- Tests/DelayedQueueTests.swift | 67 ++-- Tests/DispatchTime_QueueTests.swift | 4 +- Tests/IsolatedMainTests.swift | 103 ++++++ .../DelayedQueue+TestHelper.swift | 4 +- Tests/TestHelpers/FakeQueueable.swift | 74 +++++ .../TestHelpers}/Queue+TestHelper.swift | 0 26 files changed, 808 insertions(+), 346 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/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..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..4b7b019 100644 --- a/Source/Atomic.swift +++ b/Source/Atomic.swift @@ -1,5 +1,308 @@ 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 +} + +public enum Mutex: Sendable { + public enum Kind: Sendable { + case normal + case recursive + + public static let `default`: Self = .normal + } + + public static var unfair: Mutexing { + return Impl.Unfair() + } + + public static func nslock(_ kind: Kind = .normal) -> Mutexing { + return Impl.NSLock(kind: kind) + } + + public static func pthread(_ kind: Kind = .normal) -> Mutexing { + return Impl.PThread(kind: kind) + } + + public static var semaphore: Mutexing { + return Impl.Semaphore() + } + + public static func barrier(_ queue: Queueable = Queue.utility) -> Mutexing { + return Impl.Barrier(queue) + } + + public static var `default`: Mutexing { + return pthread(.recursive) + } +} + +public enum AtomicOption: Equatable, Sendable { + case async + case sync + case trySync +} + +@propertyWrapper +public final class Atomic: @unchecked Sendable { + private let mutex: Mutexing + private var value: Value + private let read: AtomicOption + private let write: AtomicOption + + public var projectedValue: Atomic { + return self + } + + public var wrappedValue: Value { + get { + switch read { + case .sync: + return mutex.sync { + return value + } + case .trySync: + return mutex.trySync { + return value + } + case .async: + return value + } + } + + set { + switch write { + case .sync: + mutex.sync { + value = newValue + } + case .trySync: + mutex.trySync { + value = newValue + } + case .async: + value = newValue + } + } + } + + public init(wrappedValue initialValue: Value, + mutex: Mutexing = Mutex.default, + read: AtomicOption = .sync, + write: AtomicOption = .sync) { + self.value = initialValue + self.mutex = mutex + self.read = read + self.write = write + } + + public func mutate(_ mutation: (inout Value) -> Void) { + mutex.sync { + mutation(&value) + } + } + + public func tryMutate(_ mutation: (inout Value) -> Void) { + mutex.trySync { + mutation(&value) + } + } + + public func mutate(_ mutation: (inout Value) -> T) -> T { + return mutex.sync { + return mutation(&value) + } + } + + public func tryMutate(_ mutation: (inout Value) -> T) -> T { + return mutex.trySync { + return mutation(&value) + } + } +} + +public extension Atomic where Value: ExpressibleByNilLiteral { + convenience init(mutex: Mutexing = Mutex.default, + read: AtomicOption = .sync, + write: AtomicOption = .sync) { + self.init(wrappedValue: nil, + mutex: mutex, + read: read, + write: write) + } +} + +private protocol Locking: Sendable { + func lock() + func tryLock() -> Bool + func unlock() +} + +private protocol SimpleMutexing: Mutexing, Locking {} + +private extension SimpleMutexing { + @discardableResult + func sync(execute work: () throws -> R) rethrows -> R { + lock() + defer { + unlock() + } + return try work() + } + + @discardableResult + func trySync(execute work: () throws -> R) rethrows -> R { + let locked = tryLock() + defer { + if locked { + unlock() + } + } + return try work() + } +} + +// MARK: - NSLock + Locking + +extension NSLock: Locking { + func tryLock() -> Bool { + return self.try() + } +} + +// MARK: - NSRecursiveLock + Locking + +extension NSRecursiveLock: Locking { + func tryLock() -> Bool { + return self.try() + } +} + +private enum Impl { + final class Unfair: SimpleMutexing, @unchecked Sendable { + private var _lock = os_unfair_lock() + + func lock() { + os_unfair_lock_lock(&_lock) + } + + func tryLock() -> Bool { + return os_unfair_lock_trylock(&_lock) + } + + func unlock() { + os_unfair_lock_unlock(&_lock) + } + } + + struct NSLock: SimpleMutexing, @unchecked Sendable { + private let _lock: Locking + + public init(kind: Mutex.Kind) { + switch kind { + case .normal: + self._lock = Foundation.NSLock() + case .recursive: + self._lock = Foundation.NSRecursiveLock() + } + } + + func lock() { + _lock.lock() + } + + func tryLock() -> Bool { + return _lock.tryLock() + } + + func unlock() { + _lock.unlock() + } + } + + final class PThread: SimpleMutexing, @unchecked Sendable { + private var _lock: pthread_mutex_t = .init() + + public init(kind: Mutex.Kind) { + var attr = pthread_mutexattr_t() + + guard pthread_mutexattr_init(&attr) == 0 else { + preconditionFailure() + } + + switch kind { + case .normal: + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL) + case .recursive: + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE) + } + + guard pthread_mutex_init(&_lock, &attr) == 0 else { + preconditionFailure() + } + + pthread_mutexattr_destroy(&attr) + } + + deinit { + pthread_mutex_destroy(&_lock) + } + + public func lock() { + pthread_mutex_lock(&_lock) + } + + public func tryLock() -> Bool { + return pthread_mutex_trylock(&_lock) == 0 + } + + public func unlock() { + pthread_mutex_unlock(&_lock) + } + } + + struct Semaphore: Mutexing, @unchecked Sendable { + private var _lock = DispatchSemaphore(value: 1) + + func sync(execute work: () throws -> R) rethrows -> R { + _lock.wait() + defer { + _lock.signal() + } + return try work() + } + + func trySync(execute work: () throws -> R) rethrows -> R { + _lock.wait() + defer { + _lock.signal() + } + return try work() + } + } + + struct Barrier: Mutexing, @unchecked Sendable { + private let queue: Queueable + + init(_ queue: Queueable) { + self.queue = queue + } + + func sync(execute work: () throws -> R) rethrows -> R { + return try queue.sync(flags: .barrier, execute: work) + } + + func trySync(execute work: () throws -> R) rethrows -> R { + return try queue.sync(flags: .barrier, execute: work) + } + } +} +#else public protocol Mutexing { @discardableResult func sync(execute work: () throws -> R) rethrows -> R @@ -301,3 +604,4 @@ private enum Impl { } } } +#endif diff --git a/Source/DelayedQueue.swift b/Source/DelayedQueue.swift index 4829f95..a6f9521 100644 --- a/Source/DelayedQueue.swift +++ b/Source/DelayedQueue.swift @@ -1,16 +1,42 @@ import Foundation -public enum DelayedQueue { +#if swift(>=6.0) +public enum DelayedQueue: Sendable { case absent case sync(Queueable) case async(Queueable) case asyncAfter(deadline: DispatchTime, queue: Queueable) case asyncAfterWithFlags(deadline: DispatchTime, flags: Queue.Flags, queue: Queueable) + + 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 enum DelayedQueue { + case absent + case sync(Queueable) -public extension DelayedQueue { - func fire(_ workItem: @escaping () -> Void) { + case async(Queueable) + case asyncAfter(deadline: DispatchTime, queue: Queueable) + case asyncAfterWithFlags(deadline: DispatchTime, flags: Queue.Flags, queue: Queueable) + + public func fire(_ workItem: @escaping () -> Void) { switch self { case .absent: workItem() @@ -27,7 +53,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..6bf8d75 --- /dev/null +++ b/Source/IsolatedMain.swift @@ -0,0 +1,42 @@ +#if swift(>=6.0) +import Foundation + +public extension Queue { + static var isolatedMain: IsolatedMain.Type { + return IsolatedMain.self + } +} + +public struct IsolatedMain { + // namespace +} + +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() + } + } + } +} +#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..4453191 100644 --- a/Source/Queue.swift +++ b/Source/Queue.swift @@ -1,5 +1,35 @@ +import Dispatch import Foundation +#if swift(>=6.0) +public struct Queue: Equatable, @unchecked Sendable { + public enum Attributes: Equatable, Sendable { + case concurrent + case serial + } + + public enum Flags: Equatable, Sendable { + case absent + case barrier + } + + private enum Kind: Equatable, Sendable { + case main + case custom(label: String, + qos: DispatchQoS = .default, + attributes: Attributes = .concurrent) + + case background + case utility + case `default` + case userInitiated + case userInteractive + } + + let sdk: DispatchQueue + private let kind: Kind +} +#else public struct Queue: Equatable { public enum Attributes: Equatable { case concurrent @@ -24,39 +54,45 @@ public struct Queue: Equatable { case userInteractive } - public static var main: Self { + let sdk: DispatchQueue + private let kind: Kind +} +#endif + +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 +101,6 @@ public struct Queue: Equatable { attributes: attributes.toSDK())) } - public let sdk: DispatchQueue - private let kind: Kind - internal var isMain: Bool { return kind == .main } 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..ba65196 --- /dev/null +++ b/Source/UnSendable.swift @@ -0,0 +1,17 @@ +#if swift(>=6.0) +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 + } +} +#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..23d84df 100644 --- a/Tests/DelayedQueueTests.swift +++ b/Tests/DelayedQueueTests.swift @@ -1,8 +1,8 @@ +#if canImport(SpryMacroAvailable) && swift(>=6.0) 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..1bfbd9a 100644 --- a/Tests/DispatchTime_QueueTests.swift +++ b/Tests/DispatchTime_QueueTests.swift @@ -35,7 +35,7 @@ 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) } diff --git a/Tests/IsolatedMainTests.swift b/Tests/IsolatedMainTests.swift new file mode 100644 index 0000000..01f7f89 --- /dev/null +++ b/Tests/IsolatedMainTests.swift @@ -0,0 +1,103 @@ +#if swift(>=6.0) +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) + } + } +} +#endif 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..82c621b --- /dev/null +++ b/Tests/TestHelpers/FakeQueueable.swift @@ -0,0 +1,74 @@ +#if canImport(SpryMacroAvailable) && swift(>=6.0) +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