diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6f9886f --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d8b2dcb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,71 @@ +name: Bug Report +description: Something isn't working as expected +labels: [bug] +body: +- type: markdown + attributes: + value: | + Thank you for contributing to swift-concurrency-extras! + + Before you submit your issue, please complete each text area below with the relevant details for your bug, and complete the steps in the checklist +- type: textarea + attributes: + label: Description + description: | + A short description of the incorrect behavior. + + If you think this issue has been recently introduced and did not occur in an earlier version, please note that. If possible, include the last version that the behavior was correct in addition to your current version. + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: If possible, I've reproduced the issue using the `main` branch of this package. + required: false + - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-concurrency-extras/issues) or [discussion](https://github.com/pointfreeco/swift-concurrency-extras/discussions). + required: true +- type: textarea + attributes: + label: Expected behavior + description: Describe what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Actual behavior + description: Describe or copy/paste the behavior you observe. + validations: + required: false +- type: textarea + attributes: + label: Steps to reproduce + description: | + Explanation of how to reproduce the incorrect behavior. + + This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. + placeholder: | + 1. ... + validations: + required: false +- type: input + attributes: + label: swift-concurrency-extras version information + description: The version of swift-concurrency-extras used to reproduce this issue. + placeholder: "'0.1.0' for example, or a commit hash" +- type: input + attributes: + label: Destination operating system + description: The OS running swift-concurrency-extras. + placeholder: "'iOS 16.4' for example" +- type: input + attributes: + label: Xcode version information + description: The version of Xcode used to reproduce this issue. + placeholder: "The version displayed from 'Xcode 〉About Xcode'" +- type: textarea + attributes: + label: Swift Compiler version information + description: The version of Swift used to reproduce this issue. + placeholder: Output from 'xcrun swiftc --version' + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3295041 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,15 @@ +blank_issues_enabled: false + +contact_links: + - name: Project Discussion + url: https://github.com/pointfreeco/swift-concurrency-extras/discussions + about: Library Q&A, ideas, and more + - name: Documentation + url: https://pointfreeco.github.io/swift-concurrency-extras/main/documentation/concurrencyextras/ + about: Read the documentation + - name: Videos + url: https://www.pointfree.co/collections/concurrency + about: Watch videos to get a behind-the-scenes look at how this library was motivated and built + - name: Slack + url: https://www.pointfree.co/slack-invite + about: Community chat diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..460bd9a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library: + runs-on: macos-13 + strategy: + matrix: + xcode: ['14.3.1'] + config: ['debug', 'release'] + steps: + - uses: actions/checkout@v3 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Run ${{ matrix.config }} tests + run: CONFIG=${{ matrix.config }} make test + + ubuntu-tests: + strategy: + matrix: + os: [ubuntu-20.04] + config: ['debug', 'release'] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build + - name: Run tests + run: swift test -c ${{ matrix.config }} diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..73e0674 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,74 @@ +# Build and deploy DocC to GitHub pages. Based off of @karwa's work here: +# https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml +name: Documentation + +on: + release: + types: + - published + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-13 + steps: + - name: Select Xcode 14.3 + run: sudo xcode-select -s /Applications/Xcode_14.3.app + + - name: Checkout Package + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Checkout gh-pages Branch + uses: actions/checkout@v3 + with: + ref: gh-pages + path: docs-out + + - name: Build documentation + run: > + rm -rf docs-out/.git; + rm -rf docs-out/main; + git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {}; + + for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6); + do + if [ -d "docs-out/$tag/data/documentation/concurrencyextras" ] + then + echo "✅ Documentation for "$tag" already exists."; + else + echo "⏳ Generating documentation for ConcurrencyExtras @ "$tag" release."; + rm -rf "docs-out/$tag"; + + git checkout .; + git checkout "$tag"; + + swift package \ + --allow-writing-to-directory docs-out/"$tag" \ + generate-documentation \ + --target ConcurrencyExtras \ + --output-path docs-out/"$tag" \ + --transform-for-static-hosting \ + --hosting-base-path /swift-concurrency-extras/"$tag" \ + && echo "✅ Documentation generated for ConcurrencyExtras @ "$tag" release." \ + || echo "⚠️ Documentation skipped for ConcurrencyExtras @ "$tag"."; + fi; + done + + - name: Fix permissions + run: 'sudo chown -R $USER docs-out' + + - name: Publish documentation to GitHub Pages + uses: JamesIves/github-pages-deploy-action@4.1.7 + with: + branch: gh-pages + folder: docs-out + single-commit: true diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..370a2e5 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,31 @@ +name: Format + +on: + push: + branches: + - main + +concurrency: + group: format-${{ github.ref }} + cancel-in-progress: true + +jobs: + swift_format: + name: swift-format + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_14.0.1.app + - name: Tap + run: brew tap pointfreeco/formulae + - name: Install + run: brew install Formulae/swift-format@5.7 + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Run swift-format + branch: 'main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f03aecc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/.build +/.swiftpm +/Packages +/*.xcodeproj +xcuserdata/ \ No newline at end of file diff --git a/ConcurrencyExtras.xcworkspace/contents.xcworkspacedata b/ConcurrencyExtras.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..ca3329e --- /dev/null +++ b/ConcurrencyExtras.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ConcurrencyExtras.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ConcurrencyExtras.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ConcurrencyExtras.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ConcurrencyExtras.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ConcurrencyExtras.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..528fbc1 --- /dev/null +++ b/ConcurrencyExtras.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + } + ], + "version" : 2 +} diff --git a/ConcurrencyExtras.xcworkspace/xcshareddata/xcschemes/ConcurrencyExtras.xcscheme b/ConcurrencyExtras.xcworkspace/xcshareddata/xcschemes/ConcurrencyExtras.xcscheme new file mode 100644 index 0000000..468cb43 --- /dev/null +++ b/ConcurrencyExtras.xcworkspace/xcshareddata/xcschemes/ConcurrencyExtras.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a27363a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Point-Free + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..302f079 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +CONFIG = debug +PLATFORM_IOS = iOS Simulator,name=iPhone 13 Pro +PLATFORM_MACOS = macOS +PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst +PLATFORM_TVOS = tvOS Simulator,name=Apple TV +PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 7 (45mm) + +test-all: test build + +test: + xcodebuild test \ + -configuration $(CONFIG) \ + -scheme ConcurrencyExtras \ + -destination platform="$(PLATFORM_IOS)" + +build: + for platform in "$(PLATFORM_IOS)" "$(PLATFORM_MACOS)" "$(PLATFORM_MAC_CATALYST)" "$(PLATFORM_TVOS)" "$(PLATFORM_WATCHOS)"; do \ + xcodebuild \ + -configuration $(CONFIG) \ + -workspace ConcurrencyExtras.xcworkspace \ + -scheme ConcurrencyExtras \ + -destination platform="$$platform" || exit 1; \ + done; + +test-linux: + docker run \ + --rm \ + -v "$(PWD):$(PWD)" \ + -w "$(PWD)" \ + swift:5.8 \ + bash -c 'swift test' + +test-swift: + swift test + +format: + swift format \ + --ignore-unparsable-files \ + --in-place \ + --recursive \ + ./Package.swift ./Sources ./Tests + +.PHONY: format test diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..528fbc1 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9e86ee4 --- /dev/null +++ b/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 5.6 + +import PackageDescription + +let package = Package( + name: "swift-concurrency-extras", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "ConcurrencyExtras", + targets: ["ConcurrencyExtras"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ], + targets: [ + .target( + name: "ConcurrencyExtras" + ), + .testTarget( + name: "ConcurrencyExtrasTests", + dependencies: [ + "ConcurrencyExtras" + ] + ), + ] +) + +//for target in package.targets { +// target.swiftSettings = target.swiftSettings ?? [] +// target.swiftSettings?.append( +// .unsafeFlags([ +// "-c", "release", +// "-emit-module-interface", "-enable-library-evolution", +// "-Xfrontend", "-warn-concurrency", +// "-Xfrontend", "-enable-actor-data-race-checks", +// ]) +// ) +//} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a2352a --- /dev/null +++ b/README.md @@ -0,0 +1,308 @@ +# swift-concurrency-extras + +[![CI](https://github.com/pointfreeco/swift-concurrency-extras/workflows/CI/badge.svg)](https://github.com/pointfreeco/swift-concurrency-extras/actions?query=workflow%3ACI) +[![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-concurrency-extras%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-concurrency-extras) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-concurrency-extras%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-concurrency-extras) + +Reliably testable Swift concurrency. + + * [Motivation](#motivation) + * [`ActorIsolated` and `LockIsolated`](#actorisolated-and-lockisolated) + * [Streams](#streams) + * [Tasks](#tasks) + * [`UncheckedSendable`](#uncheckedsendable) + * [Serial execution](#serial-execution) + * [Documentation](#documentation) + * [Other libraries](#other-libraries) + * [Learn more](#learn-more) + * [License](#License) + +## Learn more + +This library was designed to support libraries and episodes produced for [Point-Free][point-free], a +video series exploring the Swift programming language hosted by [Brandon Williams][mbrandonw] and +[Stephen Celis][stephencelis]. + +You can watch all of the episodes [here](https://www.pointfree.co/collections/concurrency). + + + video poster image + + +## Motivation + +This library comes with a number of tools that make working with Swift concurrency easier and more +testable. + + * [`ActorIsolated` and `LockIsolated`](#actorisolated-and-lockisolated) + * [Streams](#streams) + * [Tasks](#tasks) + * [`UncheckedSendable`](#uncheckedsendable) + * [Serial execution](#serial-execution) + +### `ActorIsolated` and `LockIsolated` + +The `ActorIsolated` and `LockIsolated` types help wrap other values in an isolated context. +`ActorIsolated` wraps the value in an actor so that the only way to access and mutate the value is +through an async/await interface. `LockIsolated` wraps the value in a class with a lock, which +allows you to read and write the value with a synchronous interface. You should prefer to use +`ActorIsolated` when you have access to an asynchronous context. + +### Streams + +The library comes with numerous helper APIs spread across the two Swift stream types: + + * There are helpers that erase any `AsyncSequence` conformance to either concrete stream type. + This allows you to treat the stream type as a kind of "type erased" `AsyncSequence`. + + For example, suppose you have a dependency client like this: + + ```swift + struct ScreenshotsClient { + var screenshots: () -> AsyncStream + } + ``` + + Then you can construct a live implementation that "erases" the + `NotificationCenter.Notifications` async sequence to a stream: + + ```swift + extension ScreenshotsClient { + static let live = Self( + screenshots: { + NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + .eraseToStream() // ⬅️ + } + ) + } + ``` + + Use `eraseToThrowingStream()` to propagate failures from throwing async sequences. + + * Swift 5.9's `makeStream(of:)` functions have been back-ported. It can handy in tests that need + to override a dependency endpoint that returns a stream: + + ```swift + let screenshots = AsyncStream.makeStream(of: Void.self) + + let model = FeatureModel(screenshots: { screenshots.stream }) + + XCTAssertEqual(model.screenshotCount, 0) + screenshots.continuation.yield() // Simulate a screenshot being taken. + XCTAssertEqual(model.screenshotCount, 1) + ``` + + * Static `AsyncStream.never` and `AsyncThrowingStream.never` helpers are provided that represent + streams that live forever and never emit. They can be handy in tests that need to override a + dependency endpoint with a stream that should suspend and never emit for the duration of the + test. + + ```swift + let model = FeatureModel(screenshots: { .never }) + ``` + + * Static `AsyncStream.finished` and `AsyncThrowingStream.finished(throwing:)` helpers are provided + that represents streams that complete immediately without emitting. They can be handy in tests + that need to override a dependency endpoint with a stream that completes/fails immediately. + +### Tasks + +The library enhances the `Task` type with new functionality. + + * The static function `Task.never()` can asynchronously return a value of any type, but does so by + suspending forever. This can be useful for satisfying a dependency requirement in a way that + does not require you to actually return data from that endpoint. + + For example, suppose you have a dependency client like this: + + ```swift + struct SettingsClient { + var fetchSettings: () async throws -> Settings + } + ``` + + You can override the client's `fetchSettings` endpoint in tests to suspend forever by awaiting + `Task.never()`: + + ```swift + SettingsClient( + fetchSettings: { try await Task.never() } + ) + ``` + + * `Task.cancellableValue` is a property that awaits the unstructured task's `value` property while + propagating cancellation from the current async context. + + * `Task.megaYield()` is a blunt tool that can make flakey async tests a little less flakey by + suspending the current task a number of times and improve the odds that other async work has + enough time to start. Prefer the reliability of [serial execution](#serial-execution) instead + where possible. + +### `UncheckedSendable` + +A wrapper type that can make any type `Sendable`, but in an unsafe and unchecked way. This type +should only be used as an alternative to `@preconcurrency import`, which turns off concurrency +checks for everything in the library. Whereas `UncheckedSendable` allows you to turn off concurrency +warnings for just one single usage of a particular type. + +While [SE-0302][se-0302] mentions future work of ["Adaptor Types for Legacy +Codebases"][se-0302-unsafetransfer], including an `UnsafeTransfer` type that serves the same +purpose, it has not landed in Swift. + +### Serial execution + +Some asynchronous code is [notoriously difficult][reliably-testing-swift-concurrency] to test in +Swift due to how suspension points are processed by the runtime. The library comes with a static +function, `withMainSerialExecutor`, that runs all tasks spawned in an operation serially and +deterministically. This function can be used to make asynchronous tests faster and less flakey. + +For example, consider the following seemingly simple model that makes a network request and manages +so `isLoading` state while the request is inflight: + +```swift +@Observable +class NumberFactModel { + var fact: String? + var isLoading = false + var number = 0 + + // Inject the request dependency explicitly to make it testable, but can also + // be provided via a dependency management library. + let getFact: (Int) async throws -> String + + func getFactButtonTapped() async { + self.isLoading = true + defer { self.isLoading = false } + do { + self.fact = try await self.getFact(self.number) + } catch { + // TODO: Handle error + } + } +} +``` + +We would love to be able to write a test that allows us to confirm that the `isLoading` state +flips to `true` and then `false`. You might hope that it is as easy as this: + +```swift +func testIsLoading() async { + let model = NumberFactModel(getFact: { "\($0) is a good number" }) + + let task = Task { await model.getFactButtonTapped() } + XCTAssertEqual(model.isLoading, true) + XCTAssertEqual(model.fact, nil) + + await task.value + XCTAssertEqual(model.isLoading, false) + XCTAssertEqual(model.fact, "0 is a good number.") +} +``` + +However this fails almost 100% of the time. The problem is that the line immediately after creating +the unstructured `Task` executes before the line _inside_ the unstructured task, and so we never +detect the moment the `isLoading` state flips to `true`. + +You might hope you can wiggle yourself in between the moment the `getFactButtonTapped` method is +called and the moment the request finishes by using a `Task.yield`: + +```diff + func testIsLoading() async { + let model = NumberFactModel(getFact: { "\($0) is a good number" }) + + let task = Task { await model.getFactButtonTapped() } ++ await Task.yield() + XCTAssertEqual(model.isLoading, true) + XCTAssertEqual(model.fact, nil) + + await task.value + XCTAssertEqual(model.isLoading, false) + XCTAssertEqual(model.fact, "0 is a good number.") + } +``` + +But that still fails the vast majority of times. + +These problems, and more, can be fixed by running this entire test on the main serial executor: + +```diff + func testIsLoading() async { ++ withMainSerialExecutor { + let model = NumberFactModel(getFact: { "\($0) is a good number" }) + + let task = Task { await model.getFactButtonTapped() } + await Task.yield() + XCTAssertEqual(model.isLoading, true) + XCTAssertEqual(model.fact, nil) + + await task.value + XCTAssertEqual(model.isLoading, false) + XCTAssertEqual(model.fact, "0 is a good number.") ++ } + } +``` + +That one change makes this test pass deterministically, 100% of the time. + + +## Documentation + +The latest documentation for this library is available [here][concurrency-extras-docs]. + +## Credits and thanks + +Thanks to Pat Brown and [Thomas Grapperon](https://twitter.com/tgrapperon) for providing feedback on +the library before its release. Special thanks to [Kabir Oberai](https://twitter.com/kabiroberai) +who helped us work around an Xcode bug and ship serial execution tools with the library. + +## Other libraries + +Concurrency Extras is just one library that makes it easier to write testable code in Swift. + + * [Case Paths][swift-case-paths]: Tools for working with and testing enums. + + * [Clocks][swift-clocks]: A few clocks that make working with Swift concurrency more testable and + more versatile. + + * [Combine Schedulers][combine-schedulers]: A few schedulers that make working with Combine more + testable and more versatile. + + * [Composable Architecture][swift-composable-architecture]: A library for building applications in + a consistent and understandable way, with composition, testing, and ergonomics in mind. + + * [Custom Dump][swift-custom-dump]: A collection of tools for debugging, diffing, and testing your + application's data structures. + + * [Dependencies][swift-dependencies]: A dependency management library inspired by SwiftUI's + "environment." + + * [Snapshot Testing][swift-snapshot-testing]: Assert on your application by recording and + and asserting against artifacts. + + * [XCTest Dynamic Overlay][xctest-dynamic-overlay]: Call `XCTFail` and other typically test-only + helpers from application code. + +## License + +This library is released under the MIT license. See [LICENSE](LICENSE) for details. + +[swift-async-algorithms]: http://github.com/apple/swift-async-algorithms +[point-free]: https://www.pointfree.co +[mbrandonw]: https://github.com/mbrandonw +[stephencelis]: https://github.com/stephencelis +[concurrency-testing-collection]: https://www.pointfree.co/collections/concurrency/testing-async-code +[concurrency-extras-docs]: http://pointfreeco.github.io/swift-concurrency-extras/main/documentation/concurrencyextras +[se-0302]: https://github.com/apple/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md +[se-0302-unsafetransfer]: https://github.com/apple/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md#adaptor-types-for-legacy-codebases +[reliably-testing-swift-concurrency]: https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 +[swift-case-paths]: http://github.com/pointfreeco/swift-case-paths +[swift-clocks]: http://github.com/pointfreeco/swift-clocks +[combine-schedulers]: http://github.com/pointfreeco/combine-schedulers +[swift-composable-architecture]: http://github.com/pointfreeco/swift-composable-architecture +[swift-custom-dump]: http://github.com/pointfreeco/swift-custom-dump +[swift-dependencies]: http://github.com/pointfreeco/swift-dependencies +[swift-snapshot-testing]: http://github.com/pointfreeco/swift-snapshot-testing +[xctest-dynamic-overlay]: http://github.com/pointfreeco/xctest-dynamic-overlay diff --git a/Sources/ConcurrencyExtras/ActorIsolated.swift b/Sources/ConcurrencyExtras/ActorIsolated.swift new file mode 100644 index 0000000..94aac7f --- /dev/null +++ b/Sources/ConcurrencyExtras/ActorIsolated.swift @@ -0,0 +1,112 @@ +/// A generic wrapper for isolating a mutable value to an actor. +/// +/// This type is most useful when writing tests for when you want to inspect what happens inside an +/// async operation. +/// +/// For example, suppose you have a feature such that when a button is tapped you track some +/// analytics: +/// +/// ```swift +/// struct AnalyticsClient { +/// var track: (String) async -> Void +/// } +/// +/// class FeatureModel: ObservableObject { +/// let analytics: AnalyticsClient +/// // ... +/// func buttonTapped() { +/// // ... +/// await self.analytics.track("Button tapped") +/// } +/// } +/// ``` +/// +/// Then, in tests we can construct an analytics client that appends events to a mutable array +/// rather than actually sending events to an analytics server. However, in order to do this in a +/// safe way we should use an actor, and `ActorIsolated` makes this easy: +/// +/// ```swift +/// func testAnalytics() async { +/// let events = ActorIsolated<[String]>([]) +/// let analytics = AnalyticsClient( +/// track: { event in await events.withValue { $0.append(event) } } +/// ) +/// let model = FeatureModel(analytics: analytics) +/// model.buttonTapped() +/// await events.withValue { +/// XCTAssertEqual($0, ["Button tapped"]) +/// } +/// } +/// ``` +/// +/// To synchronously isolate a value, see ``LockIsolated``. +public final actor ActorIsolated { + /// The actor-isolated value. + public var value: Value + + /// Initializes actor-isolated state around a value. + /// + /// - Parameter value: A value to isolate in an actor. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try value() + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// let count = ActorIsolated(0) + /// + /// func increment() async { + /// // Safely increment it: + /// await self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// > Tip: Because XCTest assertions don't play nicely with Swift concurrency, `withValue` also + /// > provides a handy interface to peek at an actor-isolated value and assert against it: + /// > + /// > ```swift + /// > let didOpenSettings = ActorIsolated(false) + /// > let model = withDependencies { + /// > $0.openSettings = { await didOpenSettings.setValue(true) } + /// > } operation: { + /// > FeatureModel() + /// > } + /// > await model.settingsButtonTapped() + /// > await didOpenSettings.withValue { XCTAssertTrue($0) } + /// > ``` + /// + /// - Parameters: operation: An operation to be performed on the actor with the underlying value. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + var value = self.value + defer { self.value = value } + return try operation(&value) + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// let count = ActorIsolated(0) + /// + /// func reset() async { + /// // Reset it: + /// await self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of `setValue` if the value being set is derived from the + /// > current value. This isolates the entire transaction and avoids data races between reading + /// > and writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try newValue() + } +} diff --git a/Sources/ConcurrencyExtras/AsyncStream.swift b/Sources/ConcurrencyExtras/AsyncStream.swift new file mode 100644 index 0000000..b8d4ad0 --- /dev/null +++ b/Sources/ConcurrencyExtras/AsyncStream.swift @@ -0,0 +1,140 @@ +extension AsyncStream { + /// Produces an `AsyncStream` from an `AsyncSequence` by consuming the sequence till it + /// terminates, ignoring any failure. + /// + /// Useful as a kind of type eraser for live `AsyncSequence`-based dependencies. + /// + /// For example, your feature may want to subscribe to screenshot notifications. You can model + /// this as a dependency client that returns an `AsyncStream`: + /// + /// ```swift + /// struct ScreenshotsClient { + /// var screenshots: () -> AsyncStream + /// func callAsFunction() -> AsyncStream { self.screenshots() } + /// } + /// ``` + /// + /// The "live" implementation of the dependency can supply a stream by erasing the appropriate + /// `NotificationCenter.Notifications` async sequence: + /// + /// ```swift + /// extension ScreenshotsClient { + /// static let live = Self( + /// screenshots: { + /// AsyncStream( + /// NotificationCenter.default + /// .notifications(named: UIApplication.userDidTakeScreenshotNotification) + /// .map { _ in } + /// ) + /// } + /// ) + /// } + /// ``` + /// + /// While your tests can use `AsyncStream.makeStream` to spin up a controllable stream for tests: + /// + /// ```swift + /// func testScreenshots() { + /// let screenshots = AsyncStream.makeStream(of: Void.self) + /// + /// let model = withDependencies { + /// $0.screenshots = { screenshots.stream } + /// } operation: { + /// FeatureModel() + /// } + /// + /// XCTAssertEqual(model.screenshotCount, 0) + /// screenshots.continuation.yield() // Simulate a screenshot being taken. + /// XCTAssertEqual(model.screenshotCount, 1) + /// } + /// ``` + /// + /// - Parameter sequence: An async sequence. + public init(_ sequence: S) where S.Element == Element { + var iterator: S.AsyncIterator? + self.init { + if iterator == nil { + iterator = sequence.makeAsyncIterator() + } + return try? await iterator?.next() + } + } + + #if swift(<5.9) + /// Constructs and returns a stream along with its backing continuation. + /// + /// A back-port of [SE-0388: Convenience Async[Throwing]Stream.makeStream methods][se-0388]. + /// + /// This is handy for immediately escaping the continuation from an async stream, which + /// typically requires multiple steps: + /// + /// ```swift + /// var _continuation: AsyncStream.Continuation! + /// let stream = AsyncStream { continuation = $0 } + /// let continuation = _continuation! + /// + /// // vs. + /// + /// let (stream, continuation) = AsyncStream.makeStream(of: Int.self) + /// ``` + /// + /// This tool is usually used for tests where we need to supply an async sequence to a + /// dependency endpoint and get access to its continuation so that we can emulate the dependency + /// emitting data. For example, suppose you have a dependency exposing an async sequence for + /// listening to notifications. To test this you can use `makeStream`: + /// + /// ```swift + /// func testScreenshots() { + /// let screenshots = AsyncStream.makeStream(of: Void.self) + /// + /// let model = withDependencies { + /// $0.screenshots = { screenshots.stream } + /// } operation: { + /// FeatureModel() + /// } + /// + /// XCTAssertEqual(model.screenshotCount, 0) + /// screenshots.continuation.yield() // Simulate a screenshot being taken. + /// XCTAssertEqual(model.screenshotCount, 1) + /// } + /// ``` + /// + /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only + /// > use this helper to test features that do not subscribe multiple times to the dependency + /// > endpoint. + /// + /// [se-0388]: https://github.com/apple/swift-evolution/blob/main/proposals/0388-async-stream-factory.md + /// + /// - Parameters: + /// - elementType: The type of element the `AsyncStream` produces. + /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By + /// default, the stream buffers an unlimited number of elements. You can also set the policy + /// to buffer a specified number of oldest or newest elements. + /// - Returns: An `AsyncStream`. + public static func makeStream( + of elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: Self, continuation: Continuation) { + var continuation: Continuation! + return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) + } + #endif + + /// An `AsyncStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + /// An `AsyncStream` that never emits and completes immediately. + public static var finished: Self { + Self { $0.finish() } + } +} + +extension AsyncSequence { + /// Erases this async sequence to an async stream that produces elements till this sequence + /// terminates (or fails). + public func eraseToStream() -> AsyncStream { + AsyncStream(self) + } +} diff --git a/Sources/ConcurrencyExtras/AsyncThrowingStream.swift b/Sources/ConcurrencyExtras/AsyncThrowingStream.swift new file mode 100644 index 0000000..14fd40c --- /dev/null +++ b/Sources/ConcurrencyExtras/AsyncThrowingStream.swift @@ -0,0 +1,97 @@ +extension AsyncThrowingStream where Failure == Error { + /// Produces an `AsyncThrowingStream` from an `AsyncSequence` by consuming the sequence till it + /// terminates, rethrowing any failure. + /// + /// - Parameter sequence: An async sequence. + public init(_ sequence: S) where S.Element == Element { + var iterator: S.AsyncIterator? + self.init { + if iterator == nil { + iterator = sequence.makeAsyncIterator() + } + return try await iterator?.next() + } + } + + #if swift(<5.9) + /// Constructs and returns a stream along with its backing continuation. + /// + /// A back-port of [SE-0388: Convenience Async[Throwing]Stream.makeStream methods][se-0388]. + /// + /// This is handy for immediately escaping the continuation from an async stream, which + /// typically requires multiple steps: + /// + /// ```swift + /// var _continuation: AsyncThrowingStream.Continuation! + /// let stream = AsyncThrowingStream { continuation = $0 } + /// let continuation = _continuation! + /// + /// // vs. + /// + /// let (stream, continuation) = AsyncThrowingStream.makeStream(of: Int.self) + /// ``` + /// + /// This tool is usually used for tests where we need to supply an async sequence to a + /// dependency endpoint and get access to its continuation so that we can emulate the dependency + /// emitting data. For example, suppose you have a dependency exposing an async sequence for + /// listening to notifications. To test this you can use `makeStream`: + /// + /// ```swift + /// func testScreenshots() { + /// let screenshots = AsyncThrowingStream.makeStream(of: Void.self) + /// + /// let model = withDependencies { + /// $0.screenshots = { screenshots.stream } + /// } operation: { + /// FeatureModel() + /// } + /// + /// XCTAssertEqual(model.screenshotCount, 0) + /// screenshots.continuation.yield() // Simulate a screenshot being taken. + /// XCTAssertEqual(model.screenshotCount, 1) + /// } + /// ``` + /// + /// > Warning: ⚠️ `AsyncThrowingStream` does not support multiple subscribers, therefore you can + /// > only use this helper to test features that do not subscribe multiple times to the + /// > dependency endpoint. + /// + /// [se-0388]: https://github.com/apple/swift-evolution/blob/main/proposals/0388-async-stream-factory.md + /// + /// - Parameters: + /// - elementType: The type of element the `AsyncThrowingStream` produces. + /// - failureType: The type of failure the `AsyncThrowingStream` throws. + /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By + /// default, the stream buffers an unlimited number of elements. You can also set the policy + /// to buffer a specified number of oldest or newest elements. + /// - Returns: An `AsyncThrowingStream`. + public static func makeStream( + of elementType: Element.Type = Element.self, + throwing failureType: Failure.Type = Failure.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: Self, continuation: Continuation) { + var continuation: Continuation! + return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) + } + #endif + + /// An `AsyncThrowingStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + /// An `AsyncThrowingStream` that completes immediately. + /// + /// - Parameter error: An optional error the stream completes with. + public static func finished(throwing error: Failure? = nil) -> Self { + Self { $0.finish(throwing: error) } + } +} + +extension AsyncSequence { + /// Erases this async sequence to an async throwing stream that produces elements till this + /// sequence terminates, rethrowing any error on failure. + public func eraseToThrowingStream() -> AsyncThrowingStream { + AsyncThrowingStream(self) + } +} diff --git a/Sources/ConcurrencyExtras/Documentation.docc/ConcurrencyExtras.md b/Sources/ConcurrencyExtras/Documentation.docc/ConcurrencyExtras.md new file mode 100644 index 0000000..c14f657 --- /dev/null +++ b/Sources/ConcurrencyExtras/Documentation.docc/ConcurrencyExtras.md @@ -0,0 +1,250 @@ +# ``ConcurrencyExtras`` + +Useful, testable Swift concurrency. + +## Overview + +This library comes with a number of tools that make working with Swift concurrency easier and more +testable. + +### ActorIsolated and LockIsolated + +The ``ActorIsolated`` and ``LockIsolated`` types help wrap other values in an isolated context. +`ActorIsolated` wraps the value in an actor so that the only way to access and mutate the value is +through an async/await interface. ``LockIsolated`` wraps the value in a class with a lock, which +allows you to read and write the value with a synchronous interface. You should prefer to use +`ActorIsolated` when you have access to an asynchronous context. + +### Streams + +The library comes with numerous helper APIs spread across the two Swift stream types: + + * There are helpers that erase any `AsyncSequence` conformance to either concrete stream type. + This allows you to treat the stream type as a kind of "type erased" `AsyncSequence`. + + For example, suppose you have a dependency client like this: + + ```swift + struct ScreenshotsClient { + var screenshots: () -> AsyncStream + } + ``` + + Then you can construct a live implementation that "erases" the + `NotificationCenter.Notifications` async sequence to a stream: + + ```swift + extension ScreenshotsClient { + static let live = Self( + screenshots: { + NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + .eraseToStream() // ⬅️ + } + ) + } + ``` + + Use `eraseToThrowingStream()` to propagate failures from throwing async sequences. + + * There is an API for simultaneously constructing a stream and its backing continuation. This can + be handy in tests when overriding a dependency endpoint that returns a stream: + + ```swift + let screenshots = AsyncStream.streamWithContinuation() + let model = FeatureModel(screenshots: screenshots.stream) + + XCTAssertEqual(model.screenshotCount, 0) + screenshots.continuation.yield() // Simulate a screenshot being taken. + XCTAssertEqual(model.screenshotCount, 1) + ``` + + * Static `AsyncStream.never` and `AsyncThrowingStream.never` helpers are provided that represent + streams that live forever and never emit. They can be handy in tests that need to override a + dependency endpoint with a stream that should suspend and never emit for the duration test. + + * Static `AsyncStream.finished` and `AsyncThrowingStream.finished(throwing:)` helpers are provided + that represents streams that complete immediately without emitting. They can be handy in tests + that need to override a dependency endpoint with a stream that completes/fails immediately. + +### Tasks + +The library comes with a static function, `Task.never()`, that can asynchronously return a value of +any type, but does so by suspending forever. This can be useful for satisfying a dependency +requirement in a way that does not require you to actually return data from that endpoint. + +### UncheckedSendable + +A wrapper type that can make any type `Sendable`, but in an unsafe and unchecked way. This type +should only be used as an alternative to `@preconcurrency import`, which turns off concurrency +checks for everything in the library. Whereas ``UncheckedSendable`` allows you to turn off +concurrency warnings for just one single usage of a particular type. + +While [SE-0302][se-0302] mentions future work of ["Adaptor Types for Legacy +Codebases"][se-0302-unsafetransfer], including an `UnsafeTransfer` type that serves the same +purpose, it has not landed in Swift. + +[se-0302]: https://github.com/apple/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md +[se-0302-unsafetransfer]: https://github.com/apple/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md#adaptor-types-for-legacy-codebases + +### Serial execution + +Some asynchronous code is [notoriously difficult][reliably-testing-swift-concurrency] to test in +Swift due to how suspension points are processed by the runtime. The library comes with a static +function, ``withMainSerialExecutor(operation:)-79jpc``, that runs all tasks spawned in an operation +serially and deterministically. This function can be used to make asynchronous tests faster and less +flakey. + +Note that running async tasks serially does not mean that multiple concurrent tasks are not able to +interleave. Suspension of async tasks still works just as you would expect, but all tasks are run on +the unique, main thread. + +For example, consider the following simple `ObservableObject` implementation for a feature that +wants to count the number of times a screenshot is taken of the screen: + +```swift +class FeatureModel: ObservableObject { + @Published var count = 0 + @MainActor + func onAppear() async { + let screenshots = NotificationCenter.default.notifications( + named: UIApplication.userDidTakeScreenshotNotification + ) + for await _ in screenshots { + self.count += 1 + } + } +} +``` + +This is quite a simple feature, but in the future it could start doing more complicated things, +such as performing a network request when it detects a screenshot being taken. + +So, it would be great if we could get some test coverage on this feature. To do this we can create +a model, and spin up a new task to invoke the `onAppear` method: + +```swift +func testBasics() async { + let model = ViewModel() + let task = Task { await model.onAppear() } +} +``` + +Then we can use `Task.yield()` to allow the subscription of the stream of notifications to start: + +```swift +func testBasics() async { + let model = ViewModel() + let task = Task { await model.onAppear() } + + // Give the task an opportunity to start executing its work. + await Task.yield() +} +``` + +Then we can simulate the user taking a screenshot by posting a notification: + +```swift +func testBasics() async { + let model = ViewModel() + let task = Task { await model.onAppear() } + + // Give the task an opportunity to start executing its work. + await Task.yield() + + // Simulate a screen shot being taken. + NotificationCenter.default.post( + name: UIApplication.userDidTakeScreenshotNotification, object: nil + ) +} +``` + +And then finally we can yield again to process the new notification and assert that the count +incremented by 1: + +```swift +func testBasics() async { + let model = ViewModel() + let task = Task { await model.onAppear() } + + // Give the task an opportunity to start executing its work. + await Task.yield() + + // Simulate a screen shot being taken. + NotificationCenter.default.post( + name: UIApplication.userDidTakeScreenshotNotification, object: nil + ) + + // Give the task an opportunity to update the view model. + await Task.yield() + + XCTAssertEqual(model.count, 1) +} +``` + +This seems like a perfectly reasonable test, and it does pass… sometimes. If you run it enough +times you will eventually get a failure (about 6% of the time). This is happening because sometimes +the single `Task.yield()` is not enough for the subscription to the stream of notifications to +actually start. In that case we will post the notification before we have actually subscribed, +causing a test failure. + +If we wrap the entire test in ``withMainSerialExecutor(operation:)-79jpc``, then it will pass +deterministically, 100% of the time: + +```swift +func testBasics() async { + await withMainSerialExecutor { + … + } +} +``` + +This is because now all tasks are enqueued on the serial, main executor, and so when we `Task.yield` +we can be sure that the `onAppear` method will execute until it reaches a suspension point. This +guarantees that the subscription to the stream of notifications will start when we expect it to. + +You can also use ``withMainSerialExecutor(operation:)-7fqt1`` to wrap an entire test case by +overriding the `invokeTest` method: + +```swift +final class FeatureModelTests: XCTestCase { + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + … +} +``` + +Now the entire `FeatureModelTests` test case will be run on the main, serial executor. + +Note that by using ``withMainSerialExecutor(operation:)-79jpc`` you are technically making your +tests behave in a manner that is different from how they would run in production. However, many +tests written on a day-to-day basis due not invoke the full-blown vagaries of concurrency. Instead +the tests want to assert that some user action happens, an async unit of work is executed, and +that causes some state to change. Such tests should be written in a way that is 100% deterministic. + +If your code has truly complex asynchronous and concurrent operations, then it may be handy to write +two sets of tests: one set that targets the main executor (using +``withMainSerialExecutor(operation:)-79jpc``) so that you can deterministically assert how the core +system behaves, and then another set that targets the default, global executor that will probably +need to make weaker assertions due to non-determinism, but can still assert on some things. + +[reliably-testing-swift-concurrency]: https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 + +## Topics + +### Data races + +- ``ActorIsolated`` +- ``LockIsolated`` + +### Serial execution + +- ``withMainSerialExecutor(operation:)-79jpc`` + +### Preconcurrency + +- ``UncheckedSendable`` diff --git a/Sources/ConcurrencyExtras/Documentation.docc/WithMainSerialExecutor.md b/Sources/ConcurrencyExtras/Documentation.docc/WithMainSerialExecutor.md new file mode 100644 index 0000000..52006df --- /dev/null +++ b/Sources/ConcurrencyExtras/Documentation.docc/WithMainSerialExecutor.md @@ -0,0 +1,11 @@ +# ``ConcurrencyExtras/withMainSerialExecutor(operation:)-79jpc`` + +## Topics + +### Overloads + +- ``withMainSerialExecutor(operation:)-7fqt1`` + +### Unchecked overrides + +- ``uncheckedUseMainSerialExecutor`` diff --git a/Sources/ConcurrencyExtras/LockIsolated.swift b/Sources/ConcurrencyExtras/LockIsolated.swift new file mode 100644 index 0000000..cce26be --- /dev/null +++ b/Sources/ConcurrencyExtras/LockIsolated.swift @@ -0,0 +1,115 @@ +import Foundation + +/// A generic wrapper for isolating a mutable value with a lock. +/// +/// To asynchronously isolate a value on an actor, see ``ActorIsolated``. If you trust the +/// sendability of the underlying value, consider using ``UncheckedSendable``, instead. +@dynamicMemberLookup +public final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + + /// Initializes lock-isolated state around a value. + /// + /// - Parameter value: A value to isolate with a lock. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.lock.sync { + self._value[keyPath: keyPath] + } + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func increment() { + /// // Safely increment it: + /// self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// - Parameters: operation: An operation to be performed on the the underlying value with a lock. + /// - Returns: The result of the operation. + public func withValue( + _ operation: (inout Value) throws -> T + ) rethrows -> T { + try self.lock.sync { + var value = self._value + defer { self._value = value } + return try operation(&value) + } + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func reset() { + /// // Reset it: + /// self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of ``setValue(_:)`` if the value being set is derived + /// > from the current value. That is, do this: + /// > + /// > ```swift + /// > self.count.withValue { $0 += 1 } + /// > ``` + /// > + /// > ...and not this: + /// > + /// > ```swift + /// > self.count.setValue(self.count + 1) + /// > ``` + /// > + /// > ``withValue(_:)`` isolates the entire transaction and avoids data races between reading and + /// > writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + try self.lock.sync { + self._value = try newValue() + } + } +} + +extension LockIsolated where Value: Sendable { + /// The lock-isolated value. + public var value: Value { + self.lock.sync { + self._value + } + } +} + +extension LockIsolated: Equatable where Value: Equatable { + public static func == (lhs: LockIsolated, rhs: LockIsolated) -> Bool { + lhs.withValue { lhsValue in rhs.withValue { rhsValue in lhsValue == rhsValue } } + } +} + +extension LockIsolated: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + self.withValue { hasher.combine($0) } + } +} + +extension NSRecursiveLock { + @inlinable @discardableResult + @_spi(Internals) public func sync(work: () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try work() + } +} diff --git a/Sources/ConcurrencyExtras/MainSerialExecutor.swift b/Sources/ConcurrencyExtras/MainSerialExecutor.swift new file mode 100644 index 0000000..2a4a918 --- /dev/null +++ b/Sources/ConcurrencyExtras/MainSerialExecutor.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Perform an operation on the main serial executor. +/// +/// Some asynchronous code is [notoriously +/// difficult](https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304) +/// to test in Swift due to how suspension points are processed by the runtime. This function runs +/// all tasks spawned in the given operation serially and deterministically. It makes asynchronous +/// tests faster and less flakey. +/// +/// ```swift +/// await withMainSerialExecutor { +/// // Everything performed in this scope is performed serially... +/// } +/// ``` +/// +/// - Parameter operation: An operation to be performed on the main serial executor. +@MainActor +public func withMainSerialExecutor( + @_implicitSelfCapture operation: @MainActor @Sendable () async throws -> Void +) async rethrows { + let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor + defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor } + uncheckedUseMainSerialExecutor = true + try await operation() +} + +/// Perform an operation on the main serial executor. +/// +/// A synchronous version of ``withMainSerialExecutor(operation:)-79jpc`` that can be used in +/// `XCTestCase.invokeTest` to ensure all async tests are performed serially: +/// +/// ```swift +/// class BaseTestCase: XCTestCase { +/// override func invokeTest() { +/// withMainSerialExecutor { +/// super.invokeTest() +/// } +/// } +/// } +/// ``` +/// +/// - Parameter operation: An operation to be performed on the main serial executor. +public func withMainSerialExecutor( + @_implicitSelfCapture operation: () throws -> Void +) rethrows { + let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor + defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor } + uncheckedUseMainSerialExecutor = true + try operation() +} + +/// Overrides Swift's global executor with the main serial executor in an unchecked fashion. +/// +/// > Warning: When set to `true`, all tasks will be enqueued on the main serial executor till set +/// > back to `false`. Consider using ``withMainSerialExecutor(operation:)-79jpc``, instead, which +/// > scopes this work to the duration of a given operation. +public var uncheckedUseMainSerialExecutor: Bool { + get { swift_task_enqueueGlobal_hook != nil } + set { + swift_task_enqueueGlobal_hook = + newValue + ? { job, _ in MainActor.shared.enqueue(job) } + : nil + } +} + +private typealias Original = @convention(thin) (UnownedJob) -> Void +private typealias Hook = @convention(thin) (UnownedJob, Original) -> Void + +private var swift_task_enqueueGlobal_hook: Hook? { + get { _swift_task_enqueueGlobal_hook.pointee } + set { _swift_task_enqueueGlobal_hook.pointee = newValue } +} +private let _swift_task_enqueueGlobal_hook: UnsafeMutablePointer = + dlsym(dlopen(nil, 0), "swift_task_enqueueGlobal_hook").assumingMemoryBound(to: Hook?.self) diff --git a/Sources/ConcurrencyExtras/Task.swift b/Sources/ConcurrencyExtras/Task.swift new file mode 100644 index 0000000..4be0a3f --- /dev/null +++ b/Sources/ConcurrencyExtras/Task.swift @@ -0,0 +1,84 @@ +import Foundation + +extension Task where Success == Never, Failure == Never { + /// Suspends the current task a number of times before resuming with the goal of allowing other + /// tasks to start their work. + /// + /// This function can be used to make flakey async tests less flakey, as described in + /// [this Swift Forums post](https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304). + /// You may, however, prefer to use ``withMainSerialExecutor(operation:)-79jpc`` to improve the + /// reliability of async tests, and to make their execution deterministic. + /// + /// > Note: When invoked from ``withMainSerialExecutor(operation:)-79jpc``, or when + /// > ``uncheckedUseMainSerialExecutor`` is set to `true`, `Task.megaYield()` is equivalent to + /// > a single `Task.yield()`. + public static func megaYield(count: Int = _defaultMegaYieldCount) async { + // TODO: Investigate why mega yields are still necessary in TCA's test suite. + // guard !uncheckedUseMainSerialExecutor else { + // await Task.yield() + // return + // } + for _ in 0...detached(priority: .background) { await Task.yield() }.value + } + } +} + +/// The number of yields `Task.megaYield()` invokes by default. +/// +/// Can be overridden by setting the `TASK_MEGA_YIELD_COUNT` environment variable. +public let _defaultMegaYieldCount = max( + 0, + min( + ProcessInfo.processInfo.environment["TASK_MEGA_YIELD_COUNT"].flatMap(Int.init) ?? 20, + 10_000 + ) +) + +extension Task where Failure == Never { + /// An async function that never returns. + public static func never() async throws -> Success { + for await element in AsyncStream.never { + return element + } + throw _Concurrency.CancellationError() + } +} + +extension Task where Success == Never, Failure == Never { + /// An async function that never returns. + public static func never() async throws { + for await _ in AsyncStream.never {} + throw _Concurrency.CancellationError() + } +} + +extension Task where Failure == Never { + /// Waits for the result of the task, propagating cancellation to the task. + /// + /// Equivalent to wrapping a call to `Task.value` in `withTaskCancellationHandler`. + public var cancellableValue: Success { + get async { + await withTaskCancellationHandler { + await self.value + } onCancel: { + self.cancel() + } + } + } +} + +extension Task where Failure == Error { + /// Waits for the result of the task, propagating cancellation to the task. + /// + /// Equivalent to wrapping a call to `Task.value` in `withTaskCancellationHandler`. + public var cancellableValue: Success { + get async throws { + try await withTaskCancellationHandler { + try await self.value + } onCancel: { + self.cancel() + } + } + } +} diff --git a/Sources/ConcurrencyExtras/UncheckedSendable.swift b/Sources/ConcurrencyExtras/UncheckedSendable.swift new file mode 100644 index 0000000..4a58b9d --- /dev/null +++ b/Sources/ConcurrencyExtras/UncheckedSendable.swift @@ -0,0 +1,75 @@ +/// A generic wrapper for turning any non-`Sendable` type into a `Sendable` one, in an unchecked +/// manner. +/// +/// Sometimes we need to use types that should be sendable but have not yet been audited for +/// sendability. If we feel confident that the type is truly sendable, and we don't want to blanket +/// disable concurrency warnings for a module via `@preconcurrency import`, then we can selectively +/// make that single type sendable by wrapping it in `UncheckedSendable`. +/// +/// > Note: By wrapping something in `UncheckedSendable` you are asking the compiler to trust you +/// > that the type is safe to use from multiple threads, and the compiler cannot help you find +/// > potential race conditions in your code. +/// +/// To synchronously isolate a value with a lock, see ``LockIsolated``. To asynchronously isolated a +/// value on an actor, see ``ActorIsolated``. +@dynamicMemberLookup +@propertyWrapper +public struct UncheckedSendable: @unchecked Sendable { + /// The unchecked value. + public var value: Value + + /// Initializes unchecked sendability around a value. + /// + /// - Parameter value: A value to make sendable in an unchecked way. + public init(_ value: Value) { + self.value = value + } + + public init(wrappedValue: Value) { + self.value = wrappedValue + } + + public var wrappedValue: Value { + _read { yield self.value } + _modify { yield &self.value } + } + + public var projectedValue: Self { + get { self } + set { self = newValue } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.value[keyPath: keyPath] + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { + _read { yield self.value[keyPath: keyPath] } + _modify { yield &self.value[keyPath: keyPath] } + } +} + +extension UncheckedSendable: Equatable where Value: Equatable {} +extension UncheckedSendable: Hashable where Value: Hashable {} + +extension UncheckedSendable: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(wrappedValue: try container.decode(Value.self)) + } catch { + self.init(wrappedValue: try Value(from: decoder)) + } + } +} + +extension UncheckedSendable: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } +} diff --git a/Tests/ConcurrencyExtrasTests/ActorIsolatedTests.swift b/Tests/ConcurrencyExtrasTests/ActorIsolatedTests.swift new file mode 100644 index 0000000..406b6c3 --- /dev/null +++ b/Tests/ConcurrencyExtrasTests/ActorIsolatedTests.swift @@ -0,0 +1,32 @@ +import ConcurrencyExtras +import XCTest + +final class ActorIsolatedTests: XCTestCase { + func testAsyncWithValue() async { + let numbers = ActorIsolated>([]) + + await numbers.setValue([0]) + + let task1 = Task { + await numbers.withValue { + _ = $0.insert(1) + } + } + let task2 = Task { + await numbers.withValue { + _ = $0.insert(2) + } + } + let task3 = Task { + await numbers.withValue { + _ = $0.insert(3) + } + } + + await task1.value + await task2.value + await task3.value + let value = await numbers.value + XCTAssertEqual(value, [0, 1, 2, 3]) + } +} diff --git a/Tests/ConcurrencyExtrasTests/AsyncStreamTests.swift b/Tests/ConcurrencyExtrasTests/AsyncStreamTests.swift new file mode 100644 index 0000000..d6a5ebb --- /dev/null +++ b/Tests/ConcurrencyExtrasTests/AsyncStreamTests.swift @@ -0,0 +1,36 @@ +#if os(iOS) + import ConcurrencyExtras + import SwiftUI + + @available(iOS 15, *) + private let sendable: @Sendable () async -> AsyncStream = { + await NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + .eraseToStream() + } + + @available(iOS 15, *) + private let mainActor: @MainActor () -> AsyncStream = { + NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + .eraseToStream() + } + + @available(iOS 15, *) + private let sendableThrowing: @Sendable () async -> AsyncThrowingStream = { + await NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + .eraseToThrowingStream() + } + + @available(iOS 15, *) + private let mainActorThrowing: @MainActor () -> AsyncThrowingStream = { + NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + .eraseToThrowingStream() + } +#endif diff --git a/Tests/ConcurrencyExtrasTests/MainSerialExecutorTests.swift b/Tests/ConcurrencyExtrasTests/MainSerialExecutorTests.swift new file mode 100644 index 0000000..5effc4d --- /dev/null +++ b/Tests/ConcurrencyExtrasTests/MainSerialExecutorTests.swift @@ -0,0 +1,140 @@ +import ConcurrencyExtras +import XCTest + +final class MainSerialExecutorTests: XCTestCase { + func testSerializedExecution() async { + let xs = LockIsolated<[Int]>([]) + await withMainSerialExecutor { + await withTaskGroup(of: Void.self) { group in + for x in 1...1000 { + group.addTask { + xs.withValue { $0.append(x) } + } + } + } + } + XCTAssertEqual(Array(1...1000), xs.value) + } + + func testSerializedExecution_WithActor() async { + let xs = ActorIsolated<[Int]>([]) + await withMainSerialExecutor { + await withTaskGroup(of: Void.self) { group in + for x in 1...1000 { + group.addTask { + await xs.withValue { $0.append(x) } + } + } + } + } + let value = await xs.value + XCTAssertEqual(Array(1...1000), value) + } + + func testSerializedExecution_YieldEveryOtherValue() async { + let xs = LockIsolated<[Int]>([]) + await withMainSerialExecutor { + await withTaskGroup(of: Void.self) { group in + for x in 1...1000 { + group.addTask { + if x.isMultiple(of: 2) { await Task.yield() } + xs.withValue { $0.append(x) } + } + } + } + } + XCTAssertEqual( + Array(0...499).map { $0 * 2 + 1 } + Array(1...500).map { $0 * 2 }, + xs.value + ) + } + + func testSerializedExecution_UnstructuredTasks() async { + await withMainSerialExecutor { + let xs = LockIsolated<[Int]>([]) + for x in 1...1000 { + Task { xs.withValue { $0.append(x) } } + } + while xs.count < 1_000 { await Task.yield() } + XCTAssertEqual(Array(1...1000), xs.value) + } + } + + func testUncheckedUseMainSerialExecutor() async { + uncheckedUseMainSerialExecutor = true + defer { uncheckedUseMainSerialExecutor = false } + + let xs = LockIsolated<[Int]>([]) + await withTaskGroup(of: Void.self) { group in + for x in 1...1000 { + group.addTask { + xs.withValue { $0.append(x) } + } + } + } + XCTAssertEqual(Array(1...1000), xs.value) + } + + func testOverlappingTaskOutsideOfScope() async throws { + guard #available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) else { return } + + let overlappingTask = Task { + try await Task.sleep(for: .milliseconds(500)) + Task { + XCTAssertEqual({ Thread.current.isMainThread }(), true) + } + } + + try await withMainSerialExecutor { + try await Task.sleep(for: .seconds(1)) + } + + try await overlappingTask.value + } + + func testDetachedTask() async { + await withMainSerialExecutor { + await Task.detached { + XCTAssertEqual({ Thread.current.isMainThread }(), true) + }.value + } + } + + func testUnstructuredTask() async { + await withMainSerialExecutor { + await Task { + XCTAssertTrue({ Thread.current.isMainThread }()) + }.value + } + } + +} + +final class MainSerialExecutorInvocationTests: XCTestCase { + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + + func testSerializedExecution() async { + let xs = LockIsolated<[Int]>([]) + await withTaskGroup(of: Void.self) { group in + for x in 1...1000 { + group.addTask { + xs.withValue { $0.append(x) } + } + } + } + XCTAssertEqual(Array(1...1000), xs.value) + } + + func testSerializedExecution_UnstructuredTasks() async { + let xs = LockIsolated<[Int]>([]) + for x in 1...1000 { + Task { xs.withValue { $0.append(x) } } + } + await Task.yield() + XCTAssertEqual(Array(1...1000), xs.value) + } +}