diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 03d4dfc2..f13ad5a8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -5,7 +5,7 @@ contact_links: url: https://github.com/pointfreeco/swift-clocks/discussions about: Library Q&A, ideas, and more - name: Documentation - url: https://pointfreeco.github.io/swift-clocks/main/documentation/clocks/ + url: https://swiftpackageindex.com/pointfreeco/swift-clocks/main/documentation/clocks about: Read the documentation - name: Videos url: https://www.pointfree.co/collections/concurrency/clocks diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d654f76..b7dca269 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - '*' + - "*" workflow_dispatch: concurrency: @@ -14,29 +14,37 @@ concurrency: cancel-in-progress: true jobs: - macos: - name: macOS - runs-on: macos-14 + macos-26: + runs-on: macos-26 strategy: matrix: - config: ['debug', 'release'] + xcode: + - "26.2" + - "26.4" steps: - - uses: actions/checkout@v4 - - name: Select Xcode 15.4 - run: sudo xcode-select -s /Applications/Xcode_15.4.app + - uses: actions/checkout@v5 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Print Swift version + run: swift --version + - name: Install Simulator Runtimes + if: matrix.xcode != '26.2' + uses: nick-fields/retry@v3 + with: + timeout_minutes: 15 + max_attempts: 3 + command: xcodebuild -downloadAllPlatforms - name: Run ${{ matrix.config }} tests run: CONFIG=${{ matrix.config }} make test-all - # - name: Build for library evolution - # run: CONFIG=${{ matrix.config }} make build-for-library-evolution linux: name: Linux runs-on: ubuntu-latest strategy: matrix: - config: ['debug', 'release'] + config: ["debug", "release"] swift: - - '5.10' + - "6.0" container: swift:${{ matrix.swift }} steps: - uses: actions/checkout@v4 diff --git a/Package.swift b/Package.swift index b8fc73cd..412e6f18 100644 --- a/Package.swift +++ b/Package.swift @@ -41,3 +41,15 @@ let package = Package( ], swiftLanguageModes: [.v6] ) + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings?.append(contentsOf: [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ImmutableWeakCaptures"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + ]) +} diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index f200d3eb..00000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 5.9 - -import PackageDescription - -let package = Package( - name: "swift-clocks", - // NB: While the `Clock` protocol is iOS 16+, etc., the package should support earlier platforms - // so that depending libraries and applications can conditionally use the library via - // availability checks. - platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v6), - ], - products: [ - .library( - name: "Clocks", - targets: ["Clocks"] - ) - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), - ], - targets: [ - .target( - name: "Clocks", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - ] - ), - .testTarget( - name: "ClocksTests", - dependencies: [ - "Clocks" - ] - ), - ] -) - -for target in package.targets { - target.swiftSettings = target.swiftSettings ?? [] - target.swiftSettings!.append(contentsOf: [ - .enableExperimentalFeature("StrictConcurrency") - ]) -} diff --git a/Sources/Clocks/ImmediateClock.swift b/Sources/Clocks/ImmediateClock.swift index f182e7fb..ac33425e 100644 --- a/Sources/Clocks/ImmediateClock.swift +++ b/Sources/Clocks/ImmediateClock.swift @@ -133,18 +133,17 @@ } } - public private(set) var now: Instant + public internal(set) var now: Instant public private(set) var minimumResolution: Duration = .zero - private let lock = NSLock() + let lock = NSLock() public init(now: Instant = .init()) { self.now = now } - public func sleep(until deadline: Instant, tolerance: Duration?) async throws { + public func sleep(until deadline: Instant, tolerance: Duration?) throws { try Task.checkCancellation() self.lock.sync { self.now = deadline } - await Task.megaYield() } } diff --git a/Sources/Clocks/Internal/Lock.swift b/Sources/Clocks/Internal/Lock.swift index de7c025b..ac8f0934 100644 --- a/Sources/Clocks/Internal/Lock.swift +++ b/Sources/Clocks/Internal/Lock.swift @@ -1,4 +1,4 @@ -import Foundation +public import Foundation extension NSLock { @inlinable diff --git a/Sources/Clocks/NonsendingClock.swift b/Sources/Clocks/NonsendingClock.swift new file mode 100644 index 00000000..497f2302 --- /dev/null +++ b/Sources/Clocks/NonsendingClock.swift @@ -0,0 +1,34 @@ +#if compiler(>=6.2) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public protocol NonsendingClock: Clock { + nonisolated(nonsending) + func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension NonsendingClock { + nonisolated(nonsending) + public func sleep(for duration: Duration, tolerance: Instant.Duration? = nil) async throws + { + try await sleep(until: now.advanced(by: duration), tolerance: tolerance) + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension AnyClock: NonsendingClock {} + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension ContinuousClock: NonsendingClock {} + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension ImmediateClock: NonsendingClock {} + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension SuspendingClock: NonsendingClock {} + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension TestClock: NonsendingClock {} + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension UnimplementedClock: NonsendingClock {} +#endif diff --git a/Sources/Clocks/SwiftUI.swift b/Sources/Clocks/SwiftUI.swift index 6c299dac..7681c097 100644 --- a/Sources/Clocks/SwiftUI.swift +++ b/Sources/Clocks/SwiftUI.swift @@ -1,5 +1,5 @@ #if (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst)) && canImport(SwiftUI) - import SwiftUI + public import SwiftUI @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) extension EnvironmentValues { diff --git a/Sources/Clocks/TestClock.swift b/Sources/Clocks/TestClock.swift index 750fda4c..5bc93304 100644 --- a/Sources/Clocks/TestClock.swift +++ b/Sources/Clocks/TestClock.swift @@ -93,7 +93,7 @@ [( id: UUID, deadline: Instant, - continuation: AsyncThrowingStream.Continuation + continuation: AsyncThrowingStream.Continuation )] = [] public init(now: Instant = .init()) { @@ -104,12 +104,12 @@ try Task.checkCancellation() let id = UUID() do { - let stream: AsyncThrowingStream? = self.lock.sync { + let stream: AsyncThrowingStream? = self.lock.sync { guard deadline >= self.now else { return nil } - return AsyncThrowingStream { continuation in + return AsyncThrowingStream { continuation in self.suspensions.append((id: id, deadline: deadline, continuation: continuation)) } } diff --git a/Tests/ClocksTests/TestClocksTests.swift b/Tests/ClocksTests/TestClocksTests.swift index 3c131289..c1e8daad 100644 --- a/Tests/ClocksTests/TestClocksTests.swift +++ b/Tests/ClocksTests/TestClocksTests.swift @@ -1,4 +1,5 @@ import Clocks +import ConcurrencyExtras import XCTest @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) @@ -46,12 +47,13 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(ticks, 5) } - func testRun() async { + func testRun() async throws { let isFinished = ActorIsolated(false) Task { try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) await isFinished.setValue(true) } + try await Task.sleep(for: .seconds(0.1)) var checkIsFinished = await isFinished.value XCTAssertEqual(checkIsFinished, false) @@ -95,7 +97,9 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { let timer = clock.timer(interval: .seconds(1)) .prefix(10) + let timerStarted = LockIsolated(false) let task = Task { + timerStarted.withValue { $0 = true } var ticks = 0 for await _ in timer { ticks += 1 @@ -103,13 +107,16 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { return ticks } + while !timerStarted.withValue(\.self) { await Task.yield() } await self.clock.run(timeout: .seconds(30)) let ticks = await task.value XCTAssertEqual(ticks, 10) } func testRunWithReentrantUnitsOfWork() async throws { + let taskStarted = LockIsolated(false) let task = Task { + taskStarted.withValue { $0 = true } var count = 0 try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) count += 1 @@ -124,6 +131,7 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { return count } + while !taskStarted.withValue(\.self) { await Task.yield() } await self.clock.run(timeout: .seconds(30)) let ticks = try await task.value XCTAssertEqual(ticks, 5) @@ -193,14 +201,18 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { } func testRunSorting() async throws { + let secondTaskStarted = LockIsolated(false) let task = Task { try await withThrowingTaskGroup(of: Int.self, returning: [Int].self) { group in + let firstTaskStarted = LockIsolated(false) group.addTask { + firstTaskStarted.withValue { $0 = true } try await self.clock.sleep(for: .seconds(2)) return 2 } + while !firstTaskStarted.withValue(\.self) { await Task.yield() } group.addTask { - try await Task.sleep(nanoseconds: 500_000_000) + secondTaskStarted.withValue { $0 = true } try await self.clock.sleep(for: .seconds(1)) return 1 } @@ -208,7 +220,7 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { } } - try await Task.sleep(nanoseconds: 1_000_000_000) + while !secondTaskStarted.withValue(\.self) { await Task.yield() } await self.clock.run(timeout: .seconds(5)) let values = try await task.value @@ -216,16 +228,10 @@ final class TestClockTests: XCTestCase, @unchecked Sendable { } func testSleepUntilExactlyNow() async throws { - let before = Date() Task { - try await Task.sleep(for: .seconds(30)) + try await Task.sleep(for: .seconds(1)) await clock.advance() } try await clock.sleep(until: clock.now) - XCTAssertEqual( - before.advanced(by: 30).timeIntervalSince1970, - Date().timeIntervalSince1970, - accuracy: 5 - ) } }