diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c32b77b..9ba6338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,28 +9,18 @@ on: - '*' jobs: - library-swift-latest: - name: Library (swift-latest) - runs-on: macOS-13 + library: + name: Library + runs-on: macos-14 steps: - - uses: actions/checkout@v3 - - name: Select Xcode 14.3 - run: sudo xcode-select -s /Applications/Xcode_14.3.app + - uses: actions/checkout@v4 + - name: Select Xcode 15.4 + run: sudo xcode-select -s /Applications/Xcode_15.4.app - name: Run tests run: make test - name: Build for library evolution run: make build-for-library-evolution - library-swift-5-6: - name: Library (swift-5.6) - runs-on: macOS-12 - steps: - - uses: actions/checkout@v3 - - name: Select Xcode 13.4.1 - run: sudo xcode-select -s /Applications/Xcode_13.4.1.app - - name: Run tests - run: make test - windows: name: Windows strategy: @@ -41,9 +31,9 @@ jobs: steps: - uses: compnerd/gha-setup-swift@main with: - branch: swift-5.8-release - tag: 5.8-RELEASE + branch: swift-5.10-release + tag: 5.10-RELEASE - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run tests run: swift test -c ${{ matrix.config }} diff --git a/Package.swift b/Package.swift index 42cd18e..b3adf98 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version: 5.9 import PackageDescription @@ -36,3 +36,10 @@ let package = Package( ), ] ) + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings!.append(contentsOf: [ + .enableExperimentalFeature("StrictConcurrency") + ]) +} diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 0000000..ff4512d --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "combine-schedulers", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "CombineSchedulers", + targets: ["CombineSchedulers"] + ) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), + ], + targets: [ + .target( + name: "CombineSchedulers", + dependencies: [ + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + .testTarget( + name: "CombineSchedulersTests", + dependencies: [ + "CombineSchedulers" + ] + ), + ], + swiftLanguageVersions: [.v6] +) diff --git a/Sources/CombineSchedulers/AnyScheduler.swift b/Sources/CombineSchedulers/AnyScheduler.swift index fcd339b..3d93f57 100644 --- a/Sources/CombineSchedulers/AnyScheduler.swift +++ b/Sources/CombineSchedulers/AnyScheduler.swift @@ -131,12 +131,10 @@ /// in classes, functions, etc. without needing to introduce a generic, which can help simplify /// the code and reduce implementation details from leaking out. /// - public struct AnyScheduler: Scheduler, @unchecked Sendable - where - SchedulerTimeType: Strideable, - SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible - { - + public struct AnyScheduler< + SchedulerTimeType: Strideable, SchedulerOptions + >: Scheduler, @unchecked Sendable + where SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { private let _minimumTolerance: () -> SchedulerTimeType.Stride private let _now: () -> SchedulerTimeType private let _scheduleAfterIntervalToleranceOptionsAction: @@ -193,12 +191,9 @@ /// /// - Parameters: /// - scheduler: A scheduler to wrap with a type-eraser. - public init( + public init>( _ scheduler: S - ) - where - S: Scheduler, S.SchedulerTimeType == SchedulerTimeType, S.SchedulerOptions == SchedulerOptions - { + ) where S.SchedulerOptions == SchedulerOptions { self._now = { scheduler.now } self._minimumTolerance = { scheduler.minimumTolerance } self._scheduleAfterToleranceOptionsAction = scheduler.schedule @@ -251,11 +246,7 @@ } } - extension AnyScheduler - where - SchedulerTimeType == DispatchQueue.SchedulerTimeType, - SchedulerOptions == DispatchQueue.SchedulerOptions - { + extension AnySchedulerOf { /// A type-erased main dispatch queue. public static var main: Self { DispatchQueue.main.eraseToAnyScheduler() diff --git a/Sources/CombineSchedulers/Concurrency.swift b/Sources/CombineSchedulers/Concurrency.swift index cbbe532..2ebf9f1 100644 --- a/Sources/CombineSchedulers/Concurrency.swift +++ b/Sources/CombineSchedulers/Concurrency.swift @@ -1,5 +1,5 @@ #if canImport(Combine) - import Combine + @preconcurrency import Combine extension Scheduler { /// Suspends the current task for at least the given duration. @@ -8,7 +8,7 @@ /// /// This function doesn't block the scheduler. /// - /// ``` + /// ```swift /// try await in scheduler.sleep(for: .seconds(1)) /// ``` /// @@ -35,7 +35,7 @@ /// /// This function doesn't block the scheduler. /// - /// ``` + /// ```swift /// try await in scheduler.sleep(until: scheduler.now + .seconds(1)) /// ``` @@ -59,7 +59,7 @@ /// /// If the task is cancelled, the sequence will terminate. /// - /// ``` + /// ```swift /// for await instant in scheduler.timer(interval: .seconds(1)) { /// print("now:", instant) /// } @@ -76,7 +76,7 @@ tolerance: SchedulerTimeType.Stride = .zero, options: SchedulerOptions? = nil ) -> AsyncStream { - .init { continuation in + AsyncStream { continuation in let cancellable = self.schedule( after: self.now.advanced(by: interval), interval: interval, @@ -96,7 +96,7 @@ /// Measure the elapsed time to execute a closure. /// - /// ``` + /// ```swift /// let elapsed = scheduler.measure { /// someWork() /// } diff --git a/Sources/CombineSchedulers/ImmediateScheduler.swift b/Sources/CombineSchedulers/ImmediateScheduler.swift index 26eb8b3..5d66817 100644 --- a/Sources/CombineSchedulers/ImmediateScheduler.swift +++ b/Sources/CombineSchedulers/ImmediateScheduler.swift @@ -98,12 +98,8 @@ /// > `ImmediateScheduler` will not schedule this work in a defined way. Use a `TestScheduler` /// > instead to capture your publisher's timing behavior. /// - public struct ImmediateScheduler: Scheduler - where - SchedulerTimeType: Strideable, - SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible - { - + public struct ImmediateScheduler: Scheduler + where SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { public let minimumTolerance: SchedulerTimeType.Stride = .zero public let now: SchedulerTimeType @@ -146,7 +142,7 @@ /// An immediate scheduler that can substitute itself for a dispatch queue. public static var immediate: ImmediateSchedulerOf { // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. - .init(now: .init(.init(uptimeNanoseconds: 1))) + ImmediateScheduler(now: DispatchQueue.SchedulerTimeType(DispatchTime(uptimeNanoseconds: 1))) } } @@ -154,51 +150,39 @@ /// An immediate scheduler that can substitute itself for a UI scheduler. public static var immediate: ImmediateSchedulerOf { // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. - .init(now: .init(.init(uptimeNanoseconds: 1))) + ImmediateScheduler(now: UIScheduler.SchedulerTimeType(DispatchTime(uptimeNanoseconds: 1))) } } extension OperationQueue { /// An immediate scheduler that can substitute itself for an operation queue. public static var immediate: ImmediateSchedulerOf { - .init(now: .init(.init(timeIntervalSince1970: 0))) + ImmediateScheduler(now: OperationQueue.SchedulerTimeType(Date(timeIntervalSince1970: 0))) } } extension RunLoop { /// An immediate scheduler that can substitute itself for a run loop. public static var immediate: ImmediateSchedulerOf { - .init(now: .init(.init(timeIntervalSince1970: 0))) + ImmediateScheduler(now: RunLoop.SchedulerTimeType(Date(timeIntervalSince1970: 0))) } } - extension AnyScheduler - where - SchedulerTimeType == DispatchQueue.SchedulerTimeType, - SchedulerOptions == DispatchQueue.SchedulerOptions - { + extension AnySchedulerOf { /// An immediate scheduler that can substitute itself for a dispatch queue. public static var immediate: Self { DispatchQueue.immediate.eraseToAnyScheduler() } } - extension AnyScheduler - where - SchedulerTimeType == OperationQueue.SchedulerTimeType, - SchedulerOptions == OperationQueue.SchedulerOptions - { + extension AnySchedulerOf { /// An immediate scheduler that can substitute itself for an operation queue. public static var immediate: Self { OperationQueue.immediate.eraseToAnyScheduler() } } - extension AnyScheduler - where - SchedulerTimeType == RunLoop.SchedulerTimeType, - SchedulerOptions == RunLoop.SchedulerOptions - { + extension AnySchedulerOf { /// An immediate scheduler that can substitute itself for a run loop. public static var immediate: Self { RunLoop.immediate.eraseToAnyScheduler() diff --git a/Sources/CombineSchedulers/TestScheduler.swift b/Sources/CombineSchedulers/TestScheduler.swift index be752ae..ff87c78 100644 --- a/Sources/CombineSchedulers/TestScheduler.swift +++ b/Sources/CombineSchedulers/TestScheduler.swift @@ -67,9 +67,9 @@ /// but this technique can be used to test any publisher that involves Combine's asynchronous /// operations. /// - public final class TestScheduler: + public final class TestScheduler: Scheduler, @unchecked Sendable - where SchedulerTimeType: Strideable, SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { + where SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { private var lastSequence: UInt = 0 private let lock = NSRecursiveLock() @@ -98,7 +98,6 @@ /// - Parameter duration: A stride. By default this argument is `.zero`, which does not advance /// the scheduler's time but does cause the scheduler to execute any units of work that are /// waiting to be performed for right now. - @MainActor public func advance(by duration: SchedulerTimeType.Stride = .zero) async { await self.advance(to: self.now.advanced(by: duration)) } @@ -130,7 +129,6 @@ /// Advances the scheduler to the given instant. /// /// - Parameter instant: An instant in time to advance to. - @MainActor public func advance(to instant: SchedulerTimeType) async { while self.lock.sync(operation: { self.now }) <= instant { await Task.megaYield() @@ -196,7 +194,6 @@ } } - @MainActor public func run() async { await Task.megaYield() while let date = self.lock.sync(operation: { self.scheduled.first?.date }) { @@ -253,7 +250,7 @@ /// A test scheduler of dispatch queues. public static var test: TestSchedulerOf { // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. - .init(now: .init(.init(uptimeNanoseconds: 1))) + TestScheduler(now: DispatchQueue.SchedulerTimeType(DispatchTime(uptimeNanoseconds: 1))) } } @@ -261,21 +258,21 @@ /// A test scheduler compatible with type erased UI schedulers. public static var test: TestSchedulerOf { // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. - .init(now: .init(.init(uptimeNanoseconds: 1))) + TestScheduler(now: UIScheduler.SchedulerTimeType(DispatchTime(uptimeNanoseconds: 1))) } } extension OperationQueue { /// A test scheduler of operation queues. public static var test: TestSchedulerOf { - .init(now: .init(.init(timeIntervalSince1970: 0))) + TestScheduler(now: OperationQueue.SchedulerTimeType(Date(timeIntervalSince1970: 0))) } } extension RunLoop { /// A test scheduler of run loops. public static var test: TestSchedulerOf { - .init(now: .init(.init(timeIntervalSince1970: 0))) + TestScheduler(now: RunLoop.SchedulerTimeType(Date(timeIntervalSince1970: 0))) } } diff --git a/Sources/CombineSchedulers/UIScheduler.swift b/Sources/CombineSchedulers/UIScheduler.swift index 056fc19..cca249c 100644 --- a/Sources/CombineSchedulers/UIScheduler.swift +++ b/Sources/CombineSchedulers/UIScheduler.swift @@ -1,6 +1,11 @@ #if canImport(Combine) import Combine - import Dispatch + + #if swift(>=6) + @preconcurrency import Dispatch + #else + import Dispatch + #endif /// A scheduler that executes its work on the main queue as soon as possible. /// diff --git a/Sources/CombineSchedulers/UnimplementedScheduler.swift b/Sources/CombineSchedulers/UnimplementedScheduler.swift index f85367a..df05328 100644 --- a/Sources/CombineSchedulers/UnimplementedScheduler.swift +++ b/Sources/CombineSchedulers/UnimplementedScheduler.swift @@ -170,7 +170,10 @@ /// - Returns: An unimplemented scheduler. public static func unimplemented(_ prefix: String) -> UnimplementedSchedulerOf { // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. - .init(prefix, now: .init(.init(uptimeNanoseconds: 1))) + UnimplementedScheduler( + prefix, + now: DispatchQueue.SchedulerTimeType(DispatchTime(uptimeNanoseconds: 1)) + ) } } @@ -186,7 +189,10 @@ /// messages. /// - Returns: An unimplemented scheduler. public static func unimplemented(_ prefix: String) -> UnimplementedSchedulerOf { - .init(prefix, now: .init(.init(timeIntervalSince1970: 0))) + UnimplementedScheduler( + prefix, + now: OperationQueue.SchedulerTimeType(Date(timeIntervalSince1970: 0)) + ) } } @@ -202,15 +208,14 @@ /// messages. /// - Returns: An unimplemented scheduler. public static func unimplemented(_ prefix: String) -> UnimplementedSchedulerOf { - .init(prefix, now: .init(.init(timeIntervalSince1970: 0))) + UnimplementedScheduler( + prefix, + now: RunLoop.SchedulerTimeType(Date(timeIntervalSince1970: 0)) + ) } } - extension AnyScheduler - where - SchedulerTimeType == DispatchQueue.SchedulerTimeType, - SchedulerOptions == DispatchQueue.SchedulerOptions - { + extension AnySchedulerOf { /// An unimplemented scheduler that can substitute itself for a dispatch queue. public static var unimplemented: Self { DispatchQueue.unimplemented.eraseToAnyScheduler() @@ -226,11 +231,7 @@ } } - extension AnyScheduler - where - SchedulerTimeType == OperationQueue.SchedulerTimeType, - SchedulerOptions == OperationQueue.SchedulerOptions - { + extension AnySchedulerOf { /// An unimplemented scheduler that can substitute itself for an operation queue. public static var unimplemented: Self { OperationQueue.unimplemented.eraseToAnyScheduler() @@ -246,11 +247,7 @@ } } - extension AnyScheduler - where - SchedulerTimeType == RunLoop.SchedulerTimeType, - SchedulerOptions == RunLoop.SchedulerOptions - { + extension AnySchedulerOf { /// An unimplemented scheduler that can substitute itself for a run loop. public static var unimplemented: Self { RunLoop.unimplemented.eraseToAnyScheduler() diff --git a/Tests/CombineSchedulersTests/TestSchedulerTests.swift b/Tests/CombineSchedulersTests/TestSchedulerTests.swift index 80f4d23..3f644a8 100644 --- a/Tests/CombineSchedulersTests/TestSchedulerTests.swift +++ b/Tests/CombineSchedulersTests/TestSchedulerTests.swift @@ -3,7 +3,6 @@ import CombineSchedulers import XCTest - @MainActor final class CombineSchedulerTests: XCTestCase { var cancellables: Set = [] diff --git a/Tests/CombineSchedulersTests/UISchedulerTests.swift b/Tests/CombineSchedulersTests/UISchedulerTests.swift index 619ddb0..202bad9 100644 --- a/Tests/CombineSchedulersTests/UISchedulerTests.swift +++ b/Tests/CombineSchedulersTests/UISchedulerTests.swift @@ -1,6 +1,7 @@ #if canImport(Combine) import Combine import CombineSchedulers + import ConcurrencyExtras import XCTest final class UISchedulerTests: XCTestCase { @@ -14,20 +15,20 @@ let queue = DispatchQueue.init(label: "queue") let exp = self.expectation(description: "wait") - var worked = false + let worked = LockIsolated(false) queue.async { XCTAssert(!Thread.isMainThread) UIScheduler.shared.schedule { XCTAssert(Thread.isMainThread) - worked = true + worked.setValue(true) exp.fulfill() } - XCTAssertFalse(worked) + XCTAssertFalse(worked.value) } self.wait(for: [exp], timeout: 1) - XCTAssertTrue(worked) + XCTAssertTrue(worked.value) } } #endif