diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..4c5197c --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,43 @@ +name: Pull request + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + format_check_container_image: swift:6.1.0-noble + license_header_check_enabled: false + api_breakage_check_enabled: false + + tests: + name: tests + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + with: + enable_macos_checks: false + linux_exclude_swift_versions: "[{\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"5.10.1\"}, {\"swift_version\": \"6.0\"}]" + enable_windows_checks: false + + wasm-sdk: + name: WebAssembly SDK + runs-on: ubuntu-latest + container: + image: "swift:6.1.0-noble" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Swift version + run: swift --version + - name: WasmBuild + # TODO: Update this to use swift-nio once https://github.com/apple/swift-nio/pull/3159/ is merged + run: | + apt-get update -y -q + apt-get install -y -q curl + apt-get install -y -q jq + version="$(swift --version | head -n1)" + tag="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$version" '.[$v] | .[-1]')" + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$tag.json" | jq -r '.["swift-sdks"]["wasm32-unknown-wasi"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x + swift build --swift-sdk wasm32-unknown-wasi diff --git a/.gitignore b/.gitignore index 52fe2f7..0023a53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,8 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User settings +.DS_Store +/.build +/Packages xcuserdata/ - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.licenseignore b/.licenseignore new file mode 100644 index 0000000..c73cf0c --- /dev/null +++ b/.licenseignore @@ -0,0 +1 @@ +Package.swift \ No newline at end of file diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..b66e4e1 --- /dev/null +++ b/.swift-format @@ -0,0 +1,79 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : false, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 140, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : { + "never" : { + + } + }, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : true, + "spacesBeforeEndOfLineComments" : 1, + "tabWidth" : 8, + "version" : 1 +} diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..369fd85 --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "dispatch-async", + products: [ + .library( + name: "DispatchAsync", + targets: ["DispatchAsync"] + ) + ], + targets: [ + .target(name: "DispatchAsync"), + .testTarget( + name: "DispatchAsyncTests", + dependencies: [ + "DispatchAsync" + ] + ), + ] +) diff --git a/README.md b/README.md index d4d71d3..4c02ddf 100644 --- a/README.md +++ b/README.md @@ -1 +1,141 @@ -# dispatch-async \ No newline at end of file +# dispatch-async + +## ⚠️ WARNING - This is an πŸ§ͺexperimentalπŸ§ͺ repository and should not be adopted at large. + +DispatchAsync is a temporary experimental repository aimed at implementing missing Dispatch support in the SwiftWasm toolchain. +Currently, [SwiftWasm doesn't include Dispatch](https://book.swiftwasm.org/getting-started/porting.html#swift-foundation-and-dispatch). +But, SwiftWasm does support Swift Concurrency. DispatchAsync implements a number of common Dispatch API's using Swift Concurrency +under the hood. + +Dispatch Async does not provide blocking API's such as `DispatchQueue.sync`, primarily due to the intentional lack of blocking +API's in Swift Concurrency. + +# Toolchain Adoption Plans + +DispatchAsync is not meant for consumption abroad directly as a new Swift Module. Rather, the intention is to provide eventual integration +as a drop-in replacement for Dispatch when compiling to Wasm. + +There are a few paths to adoption into the Swift toolchain + +- DispatchAsync can be emplaced inside the [libDispatch repository](https://github.com/swiftlang/swift-corelibs-libdispatch), and compiled +into the toolchain only for wasm targets. +- DispatchAsync can be consumed in place of libDispatch when building the Swift toolchain. + +Ideally, with either approach, this repository would transfer ownership to the swiftlang organization. + +In the interim, to move wasm support forward, portions of DispatchAsync may be inlined (copy-pasted) +into various libraries to enable wasm support. DispatchAsync is designed for this purpose, and has +special `#if` handling to ensure that existing temporary usages will be elided without breakage +the moment SwiftWasm adds support for `Dispatch` into the toolchain. + +# DispatchSemaphore Limitations + +The current implementation of `DispatchSemaphore` has some limitations. Blocking threads goes against the design goals of Swift Concurrency. +The `wait` function on `DispatchSemaphore` goes against this goal. Furthermore, most wasm targets run on a single thread from the web +browser, so any time the `wait` function ends up blocking the calling thread, it would almost certainly freeze the single-threaded wasm +executable. + +To navigate these issues, there are some limitations: + +- For wasm compilation targets, `DispatchSemaphore` assumes single-threaded execution, and lacks various safeguards that would otherwise +be needed for multi-threaded execution. This makes the implementation much easier. +- For wasm targets, calls to `signal` and `wait` must be balanced. An assertion triggers if `wait` is called more times than `signal`. +- DispatchSemaphore is deprecated for wasm targets, and AsyncSemaphore is encouraged as the replacement. +- For non-wasm targets, DispatchSemaphore is simply a typealias for `AsyncSemaphore`, and provides only a non-blocking async `wait` +function. This reduces potential issues that can arise from wait being a thread-blocking function. + +# Usage + +If you've scrolled this far, you probably saw the warning. But just to make sure… + +> ⚠️ WARNING - This is an πŸ§ͺexperimentalπŸ§ͺ repository and should not be adopted at large. + +PassiveLogic is [actively working](https://github.com/PassiveLogic/swift-web-examples/issues/1) to mainstream this into the SwiftWasm +toolchain. But if you can't wait, here are some tips. + +## 1. Only use this for WASI platforms, and only if Dispatch cannot be imported. + +Use `#if os(WASI) && !canImport(Dispatch)` to elide usages outside of WASI platforms: + +```swift +#if os(WASI) && !canImport(Dispatch) +import DispatchAsync +#else +import Dispatch +#endif + +// Use Dispatch API's the same way you normal would. +``` + +## 2. If you really want to use DispatchAsync as a pure swift Dispatch alternative for non-wasm targets + +Stop. Are you sure? If you do this, you'll need to be ' + +1. Add the dependency to your package: + +```swift +let package = Package( + name: "MyPackage", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MyPackage", + targets: [ + "MyPackage" + ] + ), + ], + dependencies: [ + .package( + url: "https://github.com/PassiveLogic/dispatch-async.git", + from: "0.0.1" + ), + ], + targets: [ + .target( + name: "MyPackage" + dependencies: [ + "DispatchAsync" + ] + ), + ] +) +``` + +2. Import and use DispatchAsync in place of Dispatch like this: + +```swift +#if os(WASI) && !canImport(Dispatch) +import DispatchAsync +#else +// Non-WASI platforms have to explicitly bring in DispatchAsync +// by using `@_spi`. +@_spi(DispatchAsync) import DispatchAsync +#endif + +// Not allowed: +// import Dispatch + +// Also Not allowed: +// import Foundation + +// You'll need to use scoped Foundation imports: +import struct Foundation.URL // Ok. Doesn't bring in Dispatch + +// If you ignore the above notes, but do the following, be prepared for namespace +// collisions between the toolchain's Dispatch and DispatchAsync: + +private typealias DispatchQueue = DispatchAsync.DispatchQueue + +// Ok. If you followed everything above, you can now do the following, using pure swift +// under the hood! πŸŽ‰ +DispatchQueue.main.async { + // Run your code here… +} +``` + +# LICENSE + +This project is distributed by PassiveLogic under the Apache-2.0 license. See +[LICENSE](https://github.com/PassiveLogic/dispatch-async/blob/main/LICENSE) for full terms of use. + diff --git a/Sources/DispatchAsync/AsyncSemaphore.swift b/Sources/DispatchAsync/AsyncSemaphore.swift new file mode 100644 index 0000000..349696f --- /dev/null +++ b/Sources/DispatchAsync/AsyncSemaphore.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Provides a semaphore implantation in `async` context, with a safe wait method. Provides easy safe replacement +/// for DispatchSemaphore usage. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public actor AsyncSemaphore { + private var value: Int + private var waiters: [CheckedContinuation] = [] + + public init(value: Int = 1) { + self.value = value + } + + public func wait() async { + value -= 1 + + if value >= 0 { return } + await withCheckedContinuation { + waiters.append($0) + } + } + + public func signal() { + self.value += 1 + + guard !waiters.isEmpty else { return } + let first = waiters.removeFirst() + first.resume() + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension AsyncSemaphore { + public func withLock(_ closure: () async throws -> T) async rethrows -> T { + await wait() + defer { signal() } + return try await closure() + } + + public func withLockVoid(_ closure: () async throws -> Void) async rethrows { + await wait() + defer { signal() } + try await closure() + } +} diff --git a/Sources/DispatchAsync/DispatchAsync.swift b/Sources/DispatchAsync/DispatchAsync.swift new file mode 100644 index 0000000..271b33c --- /dev/null +++ b/Sources/DispatchAsync/DispatchAsync.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Top level namespace for functionality provided in DispatchAsync. +/// +/// Used to avoid namespacing conflicts with `Dispatch` and `Foundation` +/// +/// Platforms other than WASI shouldn't consume this library for now +/// except for testing and development purposes. +/// +/// TODO: SM: Add github permalink to this, after it is merged. +#if !os(WASI) +@_spi(DispatchAsync) +#endif +public enum DispatchAsync {} diff --git a/Sources/DispatchAsync/DispatchGroup.swift b/Sources/DispatchAsync/DispatchGroup.swift new file mode 100644 index 0000000..c7cab54 --- /dev/null +++ b/Sources/DispatchAsync/DispatchGroup.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: The following typealias mirrors Dispatch API's, but only for +// specific compilation conditions where Dispatch is not available. +// It is designed to safely elide away if and when Dispatch is introduced +// in the required Dispatch support becomes available. +#if os(WASI) && !canImport(Dispatch) +/// Drop-in replacement for ``Dispatch.DispatchGroup``, implemented using pure swift. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public typealias DispatchGroup = DispatchAsync.DispatchGroup +#endif + +extension DispatchAsync { + // MARK: - Public Interface for Non-Async Usage - + + /// Drop-in replacement for ``Dispatch.DispatchGroup``, implemented using pure swift. + /// + /// The primary goal of this implementation is to enable WASM support for Dispatch. + /// + /// Refer to documentation for the original [DispatchGroup](https://developer.apple.com/documentation/dispatch/dispatchgroup) + /// for more details, + #if !os(WASI) + @_spi(DispatchAsync) + #endif + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public class DispatchGroup: @unchecked Sendable { + private let group = _AsyncGroup() + private let queue = DispatchAsync.FIFOQueue() + + public func enter() { + queue.enqueue { [weak self] in + guard let self else { return } + await group.enter() + } + } + + public func leave() { + queue.enqueue { [weak self] in + guard let self else { return } + await group.leave() + } + } + + public func notify( + queue notificationQueue: DispatchAsync.DispatchQueue, + execute work: @escaping @Sendable @convention(block) () -> Void + ) { + queue.enqueue { [weak self] in + guard let self else { return } + await group.notify { + await withCheckedContinuation { continuation in + notificationQueue.async { + work() + continuation.resume() + } + } + } + } + } + + func wait() async { + await withCheckedContinuation { continuation in + queue.enqueue { [weak self] in + guard let self else { return } + // NOTE: We use a task for the wait, because + // otherwise the queue won't execute any more + // tasks until the wait finishes, which is not the + // behavior we want here. We want to enqueue the wait + // in FIFO call order, but then we want to allow the wait + // to be non-blocking for the queue until the last leave + // is called on the group. + Task { + await group.wait() + continuation.resume() + } + } + } + } + + public init() {} + } + + // MARK: - Private Interface for Async Usage - + + #if !os(WASI) + @_spi(DispatchAsync) + #endif + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + fileprivate actor _AsyncGroup { + private var taskCount = 0 + private var notifyHandlers: [@Sendable () async -> Void] = [] + + func enter() { + taskCount += 1 + } + + func leave() { + defer { + checkCompletion() + } + guard taskCount > 0 else { + assertionFailure("leave() called more times than enter()") + return + } + taskCount -= 1 + } + + func notify(handler: @escaping @Sendable () async -> Void) { + notifyHandlers.append(handler) + checkCompletion() + } + + func wait() async { + if taskCount <= 0 { + return + } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + notify { + continuation.resume() + } + checkCompletion() + } + } + + private func checkCompletion() { + if taskCount <= 0, !notifyHandlers.isEmpty { + let handlers = notifyHandlers + notifyHandlers.removeAll() + + for handler in handlers { + Task { + await handler() + } + } + } + } + } +} diff --git a/Sources/DispatchAsync/DispatchQueue.swift b/Sources/DispatchAsync/DispatchQueue.swift new file mode 100644 index 0000000..74c74ab --- /dev/null +++ b/Sources/DispatchAsync/DispatchQueue.swift @@ -0,0 +1,162 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: The following typealias mirrors Dispatch API's, but only for +// specific compilation conditions where Dispatch is not available. +// It is designed to safely elide away if and when Dispatch is introduced +// in the required Dispatch support becomes available. +#if os(WASI) && !canImport(Dispatch) +/// Drop-in replacement for ``Dispatch.DispatchQueue``, implemented using pure swift. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public typealias DispatchQueue = DispatchAsync.DispatchQueue +#endif + +extension DispatchAsync { + /// Drop-in replacement for ``Dispatch.DispatchQueue``, implemented using pure swift. + /// + /// The primary goal of this implementation is to enable WASM support for Dispatch. + /// + /// Refer to documentation for the original [DispatchQueue](https://developer.apple.com/documentation/dispatch/dispatchqueue) + /// for more details, + #if !os(WASI) + @_spi(DispatchAsync) + #endif + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public class DispatchQueue: @unchecked Sendable { + public static let main = DispatchQueue(isMain: true) + + private static let _global = DispatchQueue(attributes: .concurrent) + public static func global() -> DispatchQueue { + Self._global + } + + public enum Attributes { + case concurrent + + fileprivate var isConcurrent: Bool { + switch self { + case .concurrent: + return true + } + } + } + + private let targetQueue: DispatchQueue? + + private let serialQueue = FIFOQueue() + + /// Indicates whether calling context is running from the main DispatchQueue instance, or some other DispatchQueue instance. + @TaskLocal public static var isMain = false + + /// This is set during the initialization of the DispatchQueue, and controls whether `async` calls run on MainActor or not + private let isMain: Bool + private let label: String? + private let attributes: DispatchQueue.Attributes? + + public convenience init( + label: String? = nil, + attributes: DispatchQueue.Attributes? = nil, + target: DispatchQueue? = nil + ) { + self.init(isMain: false, label: label, attributes: attributes, target: target) + } + + private init( + isMain: Bool, + label: String? = nil, + attributes: DispatchQueue.Attributes? = nil, + target: DispatchQueue? = nil + ) { + if isMain, attributes == .concurrent { + assertionFailure("Should never create a concurrent main queue. Main queue should always be serial.") + } + + self.isMain = isMain + self.label = label + self.attributes = attributes + self.targetQueue = target + } + + public func async( + execute work: @escaping @Sendable @convention(block) () -> Void + ) { + if let targetQueue, targetQueue !== self { + // Recursively call this function on the target queue + // until we reach a nil queue, or this queue. + targetQueue.async(execute: work) + } else { + if isMain { + Task { @MainActor [work] in + DispatchQueue.$isMain.withValue(true) { @MainActor [work] in + work() + } + } + } else { + if attributes?.isConcurrent == true { + Task { // FIFO is not important for concurrent queues, using global task executor here + work() + } + } else { + // We don't need to use a task for enqueing work to a non-main serial queue + // because the enqueue process is very light-weight, and it is important to + // preserve FIFO entry into the queue as much as possible. + serialQueue.enqueue(work) + } + } + } + } + } + + /// A tiny FIFO job runner that executes each submitted async closure + /// strictly in the order it was enqueued. + #if !os(WASI) + @_spi(DispatchAsync) + #endif + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public actor FIFOQueue { + /// A single item in the stream, which is a block of work that can be completed. + public typealias WorkItem = @Sendable () async -> Void + + /// The stream’s continuation; lives inside the actor so nobody + /// else can yield into it. + private let continuation: AsyncStream.Continuation + + /// Spin up the stream and the single draining task. + public init(bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded) { + let stream: AsyncStream + (stream, self.continuation) = AsyncStream.makeStream(of: WorkItem.self, bufferingPolicy: bufferingPolicy) + + // Dedicated worker that processes work items one-by-one. + Task { + for await work in stream { + // Run each job in order, allowing suspension, and awaiting full + // completion, before running the next work item + await work() + } + } + } + + /// Enqueue a new unit of work. + @discardableResult + nonisolated func enqueue(_ workItem: @escaping WorkItem) -> AsyncStream.Continuation.YieldResult { + // Never suspends, preserves order + continuation.yield(workItem) + } + + deinit { + // Clean shutdown on deinit + continuation.finish() + } + } +} diff --git a/Sources/DispatchAsync/DispatchSemaphore.swift b/Sources/DispatchAsync/DispatchSemaphore.swift new file mode 100644 index 0000000..82eae92 --- /dev/null +++ b/Sources/DispatchAsync/DispatchSemaphore.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: The following typealias mirrors Dispatch API's, but only for +// specific compilation conditions where Dispatch is not available. +// It is designed to safely elide away if and when Dispatch is introduced +// in the required Dispatch support becomes available. +#if os(WASI) && !canImport(Dispatch) +/// Drop-in replacement for ``Dispatch.DispatchSemaphore``, implemented using pure swift. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public typealias DispatchSemaphore = DispatchAsync.DispatchSemaphore +#endif // os(WASI) && !canImport(Dispatch) + +extension DispatchAsync { + /// DispatchSemaphore is not safe to use for most wasm executables. + /// + /// This implementation assumes the single-threaded + /// environment that swift wasm executables typically run in. + /// + /// It is not appropriate for true multi-threaded environments. + /// + /// For safety, this class is only defined for WASI platforms. + /// + /// Most wasm executables are single-threaded. Calling DispatchSemaphore.wait + /// when it's value is 0 or lower would be likely cause a frozen main thread, + /// because that would block the calling thread. And there is usually + /// only one thread in the wasm world (right now). + /// + /// For now, we guard against that case with both compile-time deprecation + /// pointing to the much safer ``AsyncSemaphore``, and also at run-time with + /// assertions. + /// + /// ``AsyncSemaphore`` provides full functionality, but only exposes + /// Swift Concurrency api's with a safe async wait function. + #if !os(WASI) + @_spi(DispatchAsyncSingleThreadedSemaphore) + #endif + @available( + *, + deprecated, + renamed: "AsyncSemaphore", + message: + "DispatchSemaphore.wait is dangerous because of it's thread-blocking nature. Use AsyncSemaphore and Swift Concurrency instead." + ) + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public class DispatchSemaphore: @unchecked Sendable { + public var value: Int + + public init(value: Int) { + self.value = value + } + + @discardableResult + public func signal() -> Int { + MainActor.assertIsolated() + value += 1 + return value + } + + public func wait() { + // NOTE: wasm is currently mostly single threaded. + // And we don't have a Thread.sleep API yet. + // So assert that we're on the main actor here. Usage from other + // actors is not currently supported. + MainActor.assertIsolated() + assert(value > 0, "DispatchSemaphore is currently only designed for single-threaded use.") + value -= 1 + } + } +} diff --git a/Sources/DispatchAsync/DispatchTime.swift b/Sources/DispatchAsync/DispatchTime.swift new file mode 100644 index 0000000..d044e36 --- /dev/null +++ b/Sources/DispatchAsync/DispatchTime.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: The following typealias mirrors Dispatch API's, but only for +// specific compilation conditions where Dispatch is not available. +// It is designed to safely elide away if and when Dispatch is introduced +// in the required Dispatch support becomes available. +#if os(WASI) && !canImport(Dispatch) +/// Drop-in replacement for ``Dispatch.DispatchTime``, implemented using pure swift. +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +public typealias DispatchTime = DispatchAsync.DispatchTime +#endif + +extension DispatchAsync { + /// Drop-in replacement for ``Dispatch.DispatchTime``, implemented using pure swift. + #if !os(WASI) + @_spi(DispatchAsync) + #endif + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + public struct DispatchTime { + private let instant: ContinuousClock.Instant + + /// The very first time someone intializes a DispatchTime instance, we + /// reference this static let, causing it to be initialized. + /// + /// This is the closest we can get to snapshotting the start time of the running + /// executable, without using OS-specific calls. We want + /// to avoid OS-specific calls to maximize portability. + /// + /// To keep this robust, we initialize `self.durationSinceBeginning` + /// to this value using a default value, which is guaranteed to run before any + /// initializers run. This guarantees that uptimeBeginning will be the very + /// first + @available(macOS 13, *) + private static let uptimeBeginning: ContinuousClock.Instant = ContinuousClock.Instant.now + + /// See documentation for ``uptimeBeginning``. We intentionally + /// use this to guarantee a capture of `now` to uptimeBeginnin BEFORE + /// any DispatchTime instances are initialized. + private let durationSinceUptime = uptimeBeginning.duration(to: ContinuousClock.Instant.now) + + public init() { + self.instant = ContinuousClock.Instant.now + } + + public static func now() -> Self { + DispatchTime() + } + + public var uptimeNanoseconds: UInt64 { + let beginning = DispatchTime.uptimeBeginning + let uptimeDuration: Int64 = beginning.duration(to: self.instant).nanosecondsClamped + guard uptimeDuration >= 0 else { + assertionFailure("It shouldn't be possible to get a negative duration since uptimeBeginning.") + return 0 + } + return UInt64(uptimeDuration) + } + } +} + +// NOTE: The following was copied from swift-nio/Source/NIOCore/TimeAmount+Duration on June 27, 2025. +// +// See https://github.com/apple/swift-nio/blob/83bc5b58440373a7678b56fa0d9cc22ca55297ee/Sources/NIOCore/TimeAmount%2BDuration.swift +// +// It was copied rather than brought via dependencies to avoid introducing +// a dependency on swift-nio for such a small piece of code. +// +// This library will need to have no depedendencies to be able to be integrated into GCD. +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +extension Swift.Duration { + /// The duration represented as nanoseconds, clamped to maximum expressible value. + fileprivate var nanosecondsClamped: Int64 { + let components = self.components + + let secondsComponentNanos = components.seconds.multipliedReportingOverflow(by: 1_000_000_000) + let attosCompononentNanos = components.attoseconds / 1_000_000_000 + let combinedNanos = secondsComponentNanos.partialValue.addingReportingOverflow(attosCompononentNanos) + + guard + !secondsComponentNanos.overflow, + !combinedNanos.overflow + else { + return .max + } + + return combinedNanos.partialValue + } +} diff --git a/Sources/DispatchAsync/DispatchTimeInterval.swift b/Sources/DispatchAsync/DispatchTimeInterval.swift new file mode 100644 index 0000000..30bd49a --- /dev/null +++ b/Sources/DispatchAsync/DispatchTimeInterval.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// NOTE: The following typealias mirrors Dispatch API's, but only for +// specific compilation conditions where Dispatch is not available. +// It is designed to safely elide away if and when Dispatch is introduced +// in the required Dispatch support becomes available. +#if os(WASI) && !canImport(Dispatch) +/// Drop-in replacement for ``Dispatch.DispatchQueue``, implemented using pure swift. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public typealias DispatchTimeInterval = DispatchAsync.DispatchTimeInterval +#endif + +extension DispatchAsync { + private static let kNanosecondsPerSecond: UInt64 = 1_000_000_000 + private static let kNanosecondsPerMillisecond: UInt64 = 1_000_000 + private static let kNanoSecondsPerMicrosecond: UInt64 = 1_000 + + /// NOTE: This is an excerpt from libDispatch, see + /// https://github.com/swiftlang/swift-corelibs-libdispatch/blob/main/src/swift/Time.swift#L168 + /// + /// Represents a time interval that can be used as an offset from a `DispatchTime` + /// or `DispatchWallTime`. + /// + /// For example: + /// let inOneSecond = DispatchTime.now() + DispatchTimeInterval.seconds(1) + /// + /// If the requested time interval is larger then the internal representation + /// permits, the result of adding it to a `DispatchTime` or `DispatchWallTime` + /// is `DispatchTime.distantFuture` and `DispatchWallTime.distantFuture` + /// respectively. Such time intervals compare as equal: + /// + /// let t1 = DispatchTimeInterval.seconds(Int.max) + /// let t2 = DispatchTimeInterval.milliseconds(Int.max) + /// let result = t1 == t2 // true + #if !os(WASI) + @_spi(DispatchAsync) + #endif + public enum DispatchTimeInterval: Equatable, Sendable { + case seconds(Int) + case milliseconds(Int) + case microseconds(Int) + case nanoseconds(Int) + case never + + internal var rawValue: Int64 { + switch self { + case .seconds(let s): return clampedInt64Product(Int64(s), Int64(kNanosecondsPerSecond)) + case .milliseconds(let ms): return clampedInt64Product(Int64(ms), Int64(kNanosecondsPerMillisecond)) + case .microseconds(let us): return clampedInt64Product(Int64(us), Int64(kNanoSecondsPerMicrosecond)) + case .nanoseconds(let ns): return Int64(ns) + case .never: return Int64.max + } + } + + public static func == (lhs: DispatchTimeInterval, rhs: DispatchTimeInterval) -> Bool { + switch (lhs, rhs) { + case (.never, .never): return true + case (.never, _): return false + case (_, .never): return false + default: return lhs.rawValue == rhs.rawValue + } + } + + // Returns m1 * m2, clamped to the range [Int64.min, Int64.max]. + // Because of the way this function is used, we can always assume + // that m2 > 0. + private func clampedInt64Product(_ m1: Int64, _ m2: Int64) -> Int64 { + assert(m2 > 0, "multiplier must be positive") + let (result, overflow) = m1.multipliedReportingOverflow(by: m2) + if overflow { + return m1 > 0 ? Int64.max : Int64.min + } + return result + } + } +} diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift new file mode 100644 index 0000000..eb1b4c3 --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(DispatchAsync) import DispatchAsync +import Testing + +private typealias DispatchGroup = DispatchAsync.DispatchGroup + +@Test +func dispatchGroupOrderCleanliness() async throws { + // Repeating this 100 times to help rule out + // edge cases that only show up some of the time + for index in 0 ..< 100 { + Task { + actor Result { + private(set) var value = "" + + func append(value: String) { + self.value.append(value) + } + } + + let result = Result() + + let group = DispatchGroup() + await result.append(value: "|πŸ”΅\(index)") + + group.enter() + Task { + await result.append(value: "🟣/") + group.leave() + } + + group.enter() + Task { + await result.append(value: "🟣^") + group.leave() + } + + group.enter() + Task { + await result.append(value: "🟣\\") + group.leave() + } + + await withCheckedContinuation { continuation in + group.notify(queue: .main) { + Task { + await result.append(value: "🟒\(index)=") + continuation.resume() + } + } + } + + let finalValue = await result.value + + /// NOTE: If you need to visually debug issues, you can uncomment + /// the following to watch a visual representation of the group ordering. + /// + /// In general, you'll see something like the following printed over and over + /// to the console: + /// + /// ``` + /// |πŸ”΅42🟣/🟣^🟣\🟒42= + /// ``` + /// + /// What you should observe: + /// + /// - The index number be the same at the beginning and end of each line, and it + /// should always increment by one. + /// - The πŸ”΅ should always be first, and the 🟒 should always be last for each line. + /// - There should always be 3 🟣's in between the πŸ”΅ and 🟒. + /// - The ordering of the 🟣 can be random, and that is fine. + /// + /// For example, for of the following are valid outputs: + /// + /// ``` + /// // GOOD + /// |πŸ”΅42🟣/🟣^🟣\🟒42= + /// ``` + /// + /// ``` + /// // GOOD + /// |πŸ”΅42🟣/🟣\🟣^🟒42= + /// ``` + /// + /// But the following would not be valid: + /// + /// ``` + /// // BAD! + /// |πŸ”΅43🟣/🟣^🟣\🟒43= + /// |πŸ”΅42🟣/🟣^🟣\🟒42= + /// |πŸ”΅44🟣/🟣^🟣\🟒44= + /// ``` + /// + /// ``` + /// // BAD! + /// |πŸ”΅42🟣/🟣^🟒42🟣\= + /// ``` + /// + + // Uncomment to use troubleshooting method above: + // print(finalValue) + + #expect(finalValue.prefix(1) == "|") + #expect(finalValue.count { $0 == "🟣" } == 3) + #expect(finalValue.count { $0 == "🟒" } == 1) + #expect(finalValue.lastIndex(of: "🟣")! < finalValue.firstIndex(of: "🟒")!) + #expect(finalValue.suffix(1) == "=") + } + } +} diff --git a/Tests/DispatchAsyncTests/DispatchQueueTests.swift b/Tests/DispatchAsyncTests/DispatchQueueTests.swift new file mode 100644 index 0000000..721567e --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchQueueTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(DispatchAsync) import DispatchAsync +import Testing + +private typealias DispatchQueue = DispatchAsync.DispatchQueue + +#if !os(WASI) +import class Foundation.Thread +#endif + +@Test +func testBasicDispatchQueueMain() async throws { + let asyncValue = await withCheckedContinuation { continuation in + DispatchQueue.main.async { + // Main queue should be on main thread on apple platforms. + // On linux platforms, there is no guarantee that the main queue is on the main thread, + // only that it is on the main actor. + + #if os(Linux) + #expect(DispatchQueue.isMain) + #elseif !os(WASI) + // NOTE: Thread API's aren't currently available on OS(WASI), as of June 2025 + #expect(Thread.isMainThread) + #endif + continuation.resume(returning: true) + } + } + #expect(asyncValue == true) +} + +@Test +func testBasicDispatchQueueGlobal() async throws { + let asyncValue = await withCheckedContinuation { continuation in + DispatchQueue.global().async { + // Global queue should NOT be on main thread. + #if !os(WASI) + #expect(!Thread.isMainThread) + #endif + continuation.resume(returning: true) + } + } + #expect(asyncValue == true) +} diff --git a/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift b/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift new file mode 100644 index 0000000..4c35698 --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// TODO: SM: Rename this file to AsyncSemaphoreTests (coming in next PR that adds tests) + +import Testing + +@testable import DispatchAsync + +// NOTE: AsyncSempahore is nearly API-compatible with DispatchSemaphore, +// This typealias helps demonstrate that fact. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +private typealias DispatchSemaphore = AsyncSemaphore + +nonisolated(unsafe) private var sharedPoolCompletionCount = 0 + +@Test func basicAsyncSemaphoreTest() async throws { + let totalConcurrentPools = 10 + + let semaphore = DispatchSemaphore(value: 1) + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< totalConcurrentPools { + group.addTask { + // Wait for any other pools currently holding the semaphore + await semaphore.wait() + + // Only one task should mutate counter at a time + // + // If there are issues with the semaphore, then + // we would expect to grab incorrect values here occasionally, + // which would result in an incorrect final completion count. + // + let existingPoolCompletionCount = sharedPoolCompletionCount + + // Add artificial delay to amplify race conditions + // Pools started shortly after this "semaphore-locked" + // pool starts will run before this line, unless + // this pool contains a valid lock. + try? await Task.sleep(nanoseconds: 100) + + sharedPoolCompletionCount = existingPoolCompletionCount + 1 + + // When we exit this flow, release our hold on the semaphore + await semaphore.signal() + } + } + } + + // After all tasks are done, counter should be 10 + #expect(sharedPoolCompletionCount == totalConcurrentPools) +} diff --git a/Tests/DispatchAsyncTests/DispatchTimeTests.swift b/Tests/DispatchAsyncTests/DispatchTimeTests.swift new file mode 100644 index 0000000..517b73f --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchTimeTests.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(DispatchAsync) import DispatchAsync +import Testing + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +private typealias DispatchTime = DispatchAsync.DispatchTime + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +@Test +func testDispatchTimeContinousClockBasics() async throws { + let a = DispatchTime.now().uptimeNanoseconds + let b = DispatchTime.now().uptimeNanoseconds + #expect(a <= b) +} diff --git a/format.sh b/format.sh new file mode 100755 index 0000000..bf7af48 --- /dev/null +++ b/format.sh @@ -0,0 +1,20 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2025 PassiveLogic, Inc. +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of Swift.org project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place diff --git a/lint.sh b/lint.sh new file mode 100755 index 0000000..bc33c5f --- /dev/null +++ b/lint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2025 PassiveLogic, Inc. +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of Swift.org project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel