From d3626656b1179de8fffe3c9a9212a7f319443624 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 12 Feb 2026 15:53:52 -0800 Subject: [PATCH 1/5] `NonsendingClock` --- Sources/Clocks/ImmediateClock.swift | 7 +++---- Sources/Clocks/NonsendingClock.swift | 31 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 Sources/Clocks/NonsendingClock.swift diff --git a/Sources/Clocks/ImmediateClock.swift b/Sources/Clocks/ImmediateClock.swift index ab76a0bb..f9e0515c 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/NonsendingClock.swift b/Sources/Clocks/NonsendingClock.swift new file mode 100644 index 00000000..f61be713 --- /dev/null +++ b/Sources/Clocks/NonsendingClock.swift @@ -0,0 +1,31 @@ +#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 {} +#endif From dd4cac5933e614d188b26c5c8c2197e4db52a207 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 13 Feb 2026 22:36:43 -0800 Subject: [PATCH 2/5] wip --- Sources/Clocks/NonsendingClock.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Clocks/NonsendingClock.swift b/Sources/Clocks/NonsendingClock.swift index f61be713..497f2302 100644 --- a/Sources/Clocks/NonsendingClock.swift +++ b/Sources/Clocks/NonsendingClock.swift @@ -28,4 +28,7 @@ @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 From 851f1fcdeb476ad65790439e6d658e2379f5d94b Mon Sep 17 00:00:00 2001 From: David Roman <2538074+davdroman@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:46:09 +0100 Subject: [PATCH 3/5] Fix documentation link (#56) Updates the issue template documentation contact link to Swift Package Index DocC. --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a4043b6b55d666865959cf186c6357461dea26be Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:33:49 -0500 Subject: [PATCH 4/5] Modernize package. (#54) --- .github/workflows/ci.yml | 32 ++++++++++------ Package.swift | 17 +++++++++ Package@swift-5.9.swift | 49 ------------------------- Sources/Clocks/Internal/Lock.swift | 2 +- Sources/Clocks/SwiftUI.swift | 2 +- Sources/Clocks/TestClock.swift | 6 +-- Tests/ClocksTests/TestClocksTests.swift | 26 ++++++++----- 7 files changed, 58 insertions(+), 76 deletions(-) delete mode 100644 Package@swift-5.9.swift 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..c91b82f4 100644 --- a/Package.swift +++ b/Package.swift @@ -41,3 +41,20 @@ 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"), + ]) + #if compiler(>=6.4) + target.swiftSettings?.append(contentsOf: [ + .treatAllWarnings(as: .error) + ]) + #endif +} 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/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/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 - ) } } From 94bd2487d25e0e0da24e3c2f551190b45b4ff9d2 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 8 Jun 2026 14:15:19 -0700 Subject: [PATCH 5/5] wip --- Package.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Package.swift b/Package.swift index c91b82f4..412e6f18 100644 --- a/Package.swift +++ b/Package.swift @@ -52,9 +52,4 @@ for target in package.targets { .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), ]) - #if compiler(>=6.4) - target.swiftSettings?.append(contentsOf: [ - .treatAllWarnings(as: .error) - ]) - #endif }