From a799843b7e7b1cca2c33c920a3c7aca6ed3bc454 Mon Sep 17 00:00:00 2001 From: Thibault Wittemberg Date: Sun, 4 Oct 2026 11:48:23 +0200 Subject: [PATCH 1/2] Add Linux support using Synchronization Mutex Preserve shared critical-state ownership with a reference-backed Mutex wrapper and compiler-checked Sendable conformance. Remove the unused Combine test import and make Dispatch explicit. Require Swift 6.1 and iOS/tvOS 18, macOS 15, and watchOS 11 while retaining Swift 5 language mode. Swift 6.0.3 crashes when compiling generic Mutex storage in Linux debug builds. Add Ubuntu CI for Swift 6.1.3 and 6.4.0 and verify throwing critical regions release the lock. All 200 tests and release builds passed on macOS and Linux arm64. --- .github/workflows/ci.yml | 24 +++++++++++++-- CHANGELOG.md | 3 +- Package.swift | 13 ++++---- README.md | 17 +++++++++-- .../Creators/AsyncBufferedTimerSequence.swift | 1 + Sources/Supporting/ManagedCriticalState.swift | 30 +++++-------------- Tests/AsyncSubjets/StreamedTests.swift | 1 - .../ManagedCriticalStateTests.swift | 24 +++++++++++++++ 8 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 Tests/Supporting/ManagedCriticalStateTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ac1d4c..b970cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: Build and test -on: [push] +on: [push, pull_request] jobs: Build: @@ -9,7 +9,7 @@ jobs: steps: - name: Checkout branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build run: swift build -Xswiftc -suppress-warnings @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Test run: swift test --enable-code-coverage -Xswiftc -suppress-warnings - name: Generate coverage @@ -29,3 +29,21 @@ jobs: uses: codecov/codecov-action@v3 with: files: ${{join(fromJSON(steps.coverage-files.outputs.files), ',')}} + + Linux: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + swift: ["6.1.3", "6.4.0"] + container: swift:${{ matrix.swift }}-jammy + steps: + - name: Checkout branch + uses: actions/checkout@v4 + - name: Show Swift version + run: swift --version + - name: Build release + run: swift build -c release + - name: Test + run: swift test diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f8705..abf0628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ - Breaking: use Swift Async Algorithms for `Sequence.async` and two- or three-input `zip`/`merge`, removing import ambiguities. Add the `AsyncAlgorithms` product dependency and import when migrating these APIs. Preserve variadic `zip`/`merge` and the explicit `AsyncLazySequence` constructor. - Breaking: rename the buffered Date timer from `AsyncTimerSequence` to `AsyncBufferedTimerSequence` to avoid ambiguity with Apple's clock-based timer. -- SwiftPM: require Swift 5.8 or later for the Swift Async Algorithms test dependency. +- Breaking: require a Swift 6.1 or later compiler and iOS/tvOS 18, macOS 15, or watchOS 11. Preserve Swift 5 language mode. Swift 6.0.3 on Linux crashes when compiling generic `Mutex` storage in debug builds. +- Linux: use `Synchronization.Mutex` for shared state, remove the unused Combine test import, and add Ubuntu CI with Swift 6.1.3 and 6.4.0. - SwitchToLatest: finish cancelled collection while the latest channel or outer sequence remains open, and discard late producer results (https://github.com/sideeffect-io/AsyncExtensions/issues/53). - Subjects: fix a deadlock when sending values or termination concurrently with consumer cancellation (https://github.com/sideeffect-io/AsyncExtensions/issues/52). diff --git a/Package.swift b/Package.swift index 5252a8b..3d37eee 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,10 +6,10 @@ import PackageDescription let package = Package( name: "AsyncExtensions", platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v6) + .iOS(.v18), + .macOS(.v15), + .tvOS(.v18), + .watchOS(.v11) ], products: [ .library( @@ -40,5 +40,6 @@ let package = Package( .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") ], path: "Tests"), - ] + ], + swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 4462cb9..082574b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ An async sequence produces values over time. You read those values with `for awa ## Installation -The package manifest requires Swift 5.8 or later and declares support for iOS 13, macOS 10.15, tvOS 13, and watchOS 6 or later. Resolved dependencies may require a newer Swift toolchain. +The package requires a Swift 6.1 or later compiler and uses Swift 5 language mode. It supports Linux and iOS 18, macOS 15, tvOS 18, and watchOS 11 or later. Its shared-state synchronization uses `Synchronization.Mutex` on every platform. ### In Xcode @@ -35,12 +35,12 @@ Add `import AsyncExtensions` to the Swift files that use the library. See Apple' Add the package to `dependencies`, then add its library product to the target that uses it. For example, a command-line app can use this `Package.swift`: ```swift -// swift-tools-version: 5.8 +// swift-tools-version: 6.1 import PackageDescription let package = Package( name: "MyApp", - platforms: [.macOS(.v10_15)], + platforms: [.macOS(.v15)], dependencies: [ .package( url: "https://github.com/sideeffect-io/AsyncExtensions.git", @@ -60,6 +60,17 @@ let package = Package( Put your app's code in `Sources/MyApp/main.swift`, import `AsyncExtensions`, and run it with `swift run`. For a library target, add the same product dependency to your existing `.target(...)`. SwiftPM's [package manifest reference](https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html) describes these dependency declarations. +### On Linux + +Install a [Swift toolchain for Linux](https://www.swift.org/install/linux/), then build and test this package with: + +```sh +swift build -c release +swift test +``` + +Linux CI runs the full test suite on Ubuntu 22.04 with Swift 6.1.3 and 6.4.0. The Apple deployment versions in `Package.swift` do not restrict Linux builds. + ## A first example This sequence produces three numbers. `scan` keeps a running total, and the loop prints each total as it arrives: diff --git a/Sources/Creators/AsyncBufferedTimerSequence.swift b/Sources/Creators/AsyncBufferedTimerSequence.swift index 2bef22b..32a1907 100644 --- a/Sources/Creators/AsyncBufferedTimerSequence.swift +++ b/Sources/Creators/AsyncBufferedTimerSequence.swift @@ -6,6 +6,7 @@ // @preconcurrency import Foundation +import Dispatch private extension DispatchTimeInterval { var nanoseconds: UInt64 { diff --git a/Sources/Supporting/ManagedCriticalState.swift b/Sources/Supporting/ManagedCriticalState.swift index 102b7d0..83bb36b 100644 --- a/Sources/Supporting/ManagedCriticalState.swift +++ b/Sources/Supporting/ManagedCriticalState.swift @@ -1,33 +1,19 @@ -import Darwin +import Synchronization -final class LockedBuffer: ManagedBuffer { - deinit { - _ = self.withUnsafeMutablePointerToElements { lock in - lock.deinitialize(count: 1) - } - } -} - -struct ManagedCriticalState { - let buffer: ManagedBuffer +// Copies of iterators and callbacks must keep sharing the same protected state. +final class ManagedCriticalState { + private let state: Mutex init(_ initial: State) { - buffer = LockedBuffer.create(minimumCapacity: 1) { buffer in - buffer.withUnsafeMutablePointerToElements { lock in - lock.initialize(to: os_unfair_lock()) - } - return initial - } + self.state = Mutex(initial) } @discardableResult func withCriticalRegion( _ critical: (inout State) throws -> R ) rethrows -> R { - try buffer.withUnsafeMutablePointers { header, lock in - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return try critical(&header.pointee) + try self.state.withLock { state in + try critical(&state) } } @@ -42,4 +28,4 @@ struct ManagedCriticalState { } } -extension ManagedCriticalState: @unchecked Sendable where State: Sendable { } +extension ManagedCriticalState: Sendable where State: Sendable { } diff --git a/Tests/AsyncSubjets/StreamedTests.swift b/Tests/AsyncSubjets/StreamedTests.swift index 02e6951..a995d8f 100644 --- a/Tests/AsyncSubjets/StreamedTests.swift +++ b/Tests/AsyncSubjets/StreamedTests.swift @@ -6,7 +6,6 @@ // import AsyncExtensions -import Combine import XCTest final class StreamedTests: XCTestCase { diff --git a/Tests/Supporting/ManagedCriticalStateTests.swift b/Tests/Supporting/ManagedCriticalStateTests.swift new file mode 100644 index 0000000..91cca35 --- /dev/null +++ b/Tests/Supporting/ManagedCriticalStateTests.swift @@ -0,0 +1,24 @@ +@testable import AsyncExtensions +import Dispatch +import XCTest + +final class ManagedCriticalStateTests: XCTestCase { + func test_throwing_critical_region_preserves_changes_and_releases_lock() async { + let state = ManagedCriticalState(0) + XCTAssertThrowsError(try state.withCriticalRegion { value in + value = 1 + throw MockError(code: 1701) + }) { error in + XCTAssertEqual(error as? MockError, MockError(code: 1701)) + } + + let reacquired = expectation(description: "Another thread can acquire the lock after a throw") + DispatchQueue.global().async { + XCTAssertEqual(state.criticalState, 1) + state.apply(criticalState: 2) + XCTAssertEqual(state.criticalState, 2) + reacquired.fulfill() + } + await fulfillment(of: [reacquired], timeout: 2) + } +} From 3b41b88c5603c59bf27e3707c4a1097b4a6d9cd9 Mon Sep 17 00:00:00 2001 From: Thibault Wittemberg Date: Sun, 4 Oct 2026 12:01:51 +0200 Subject: [PATCH 2/2] Await cancellation test task before completing XCTest --- Tests/Creators/AsyncLazySequenceTests.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Tests/Creators/AsyncLazySequenceTests.swift b/Tests/Creators/AsyncLazySequenceTests.swift index 0f4e941..da00eb1 100644 --- a/Tests/Creators/AsyncLazySequenceTests.swift +++ b/Tests/Creators/AsyncLazySequenceTests.swift @@ -23,9 +23,9 @@ final class AsyncLazySequenceTests: XCTestCase { XCTAssertEqual(receivedResult, sequence) } - func test_AsyncLazySequence_returns_an_asyncSequence_that_finishes_when_task_is_cancelled() { + func test_AsyncLazySequence_returns_an_asyncSequence_that_finishes_when_task_is_cancelled() async { let canCancelExpectation = expectation(description: "The first element has been emitted") - let hasCancelExceptation = expectation(description: "The task has been cancelled") + let hasCancelExpectation = expectation(description: "The task has been cancelled") let sequence = (0...1_000_000) @@ -36,15 +36,16 @@ final class AsyncLazySequenceTests: XCTestCase { for await element in sut { firstElement = element canCancelExpectation.fulfill() - await fulfillment(of: [hasCancelExceptation], timeout: 5) + await fulfillment(of: [hasCancelExpectation], timeout: 5) } - XCTAssertEqual(firstElement!, 0) // the AsyncSequence is cancelled having only emitted the first element + XCTAssertEqual(firstElement, 0) // the AsyncSequence is cancelled having only emitted the first element } - wait(for: [canCancelExpectation], timeout: 5) // one element has been emitted, we can cancel the task + await fulfillment(of: [canCancelExpectation], timeout: 5) // one element has been emitted, we can cancel the task task.cancel() - hasCancelExceptation.fulfill() // we can release the lock in the for loop + hasCancelExpectation.fulfill() // we can release the lock in the for loop + await task.value // finish checking cancellation before the test returns } }