diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..e41d131 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,42 @@ +name: Pull request + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + license_header_check_project_name: "Swift.org" + 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..a289d85 --- /dev/null +++ b/.swift-format @@ -0,0 +1,73 @@ +{ + "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, + "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, + "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..943c8e3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.1 + +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..898693e 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ -# 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. + +# 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. diff --git a/Sources/DispatchAsync/AsyncSemaphore.swift b/Sources/DispatchAsync/AsyncSemaphore.swift new file mode 100644 index 0000000..cbf60b3 --- /dev/null +++ b/Sources/DispatchAsync/AsyncSemaphore.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 safer replacement +/// for DispatchSemaphore usage. +@available(macOS 10.15, *) +actor AsyncSemaphore { + private var value: Int + private var waiters: [CheckedContinuation] = [] + + init(value: Int = 1) { + self.value = value + } + + func wait() async { + value -= 1 + if value >= 0 { return } + await withCheckedContinuation { + waiters.append($0) + } + } + + func signal() { + self.value += 1 + + guard !waiters.isEmpty else { return } + let first = waiters.removeFirst() + first.resume() + } +} + +@available(macOS 10.15, *) +extension AsyncSemaphore { + func withLock(_ closure: () async throws -> T) async rethrows -> T { + await wait() + defer { signal() } + return try await closure() + } + + func withLockVoid(_ closure: () async throws -> Void) async rethrows { + await wait() + defer { signal() } + try await closure() + } +} diff --git a/Sources/DispatchAsync/DispatchGroup.swift b/Sources/DispatchAsync/DispatchGroup.swift new file mode 100644 index 0000000..2251558 --- /dev/null +++ b/Sources/DispatchAsync/DispatchGroup.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +// MARK: - Public Interface for Non-Async Usage - + +/// `DispatchGroup` is a drop-in replacement for the `DispatchGroup` implemented +/// in Grand Central Dispatch. However, this class uses Swift Concurrency, instead of low-level threading API's. +/// +/// 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, +@available(macOS 10.15, *) +public class DispatchGroup: @unchecked Sendable { + /// Used to ensure FIFO access to the enter and leave calls + @globalActor + private actor DispatchGroupEntryActor: GlobalActor { + static let shared = DispatchGroupEntryActor() + } + + private let group = AsyncGroup() + + public func enter() { + Task { @DispatchGroupEntryActor [] in + // ^--- Ensures serial FIFO entrance/exit into the group + await group.enter() + } + } + + public func leave() { + Task { @DispatchGroupEntryActor [] in + // ^--- Ensures serial FIFO entrance/exit into the group + await group.leave() + } + } + + public func notify(queue: DispatchQueue, execute work: @escaping @Sendable @convention(block) () -> Void) { + Task { @DispatchGroupEntryActor [] in + // ^--- Ensures serial FIFO entrance/exit into the group + await group.notify { + await withCheckedContinuation { continuation in + queue.async { + work() + continuation.resume() + } + } + } + } + } + + func wait() async { + await group.wait() + } + + public init() {} +} + +// MARK: - Private Interface for Async Usage - + +@available(macOS 10.15, *) +fileprivate actor AsyncGroup { + private var taskCount = 0 + private var continuation: CheckedContinuation? + private var isWaiting = false + 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 + } + + isWaiting = true + + await withCheckedContinuation { (continuation: CheckedContinuation) in + self.continuation = continuation + checkCompletion() + } + } + + private func checkCompletion() { + if taskCount <= 0 { + if isWaiting { + continuation?.resume() + continuation = nil + isWaiting = false + } + + if !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..3610c07 --- /dev/null +++ b/Sources/DispatchAsync/DispatchQueue.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +/// `DispatchQueue` is a drop-in replacement for the `DispatchQueue` implemented +/// in Grand Central Dispatch. However, this class uses Swift Concurrency, instead of low-level threading API's. +/// +/// 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, +@available(macOS 10.15, *) +public class DispatchQueue: @unchecked Sendable { + public static let main = DispatchQueue(isMain: true) + + private static let _global = DispatchQueue() + public static func global() -> DispatchQueue { + Self._global + } + + public enum Attributes { + case concurrent + } + + private let targetQueue: DispatchQueue? + + /// 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 + ) { + 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 { + Task { + work() + } + } + } + } +} diff --git a/Sources/DispatchAsync/DispatchSemaphore.swift b/Sources/DispatchAsync/DispatchSemaphore.swift new file mode 100644 index 0000000..050aa55 --- /dev/null +++ b/Sources/DispatchAsync/DispatchSemaphore.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +// 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. +// +// +#if os(WASI) + +/// DispatchSemaphore is not safe to use for most wasm executables. +/// +/// 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. +@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, *) +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 + } +} + +#else + +@available(macOS 10.15, *) +typealias DispatchSemaphore = AsyncSemaphore + +#endif // #if os(WASI) diff --git a/Sources/DispatchAsync/DispatchTime.swift b/Sources/DispatchAsync/DispatchTime.swift new file mode 100644 index 0000000..c9c1935 --- /dev/null +++ b/Sources/DispatchAsync/DispatchTime.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +@available(macOS 13, *) +public typealias DispatchTime = ContinuousClock.Instant + +/// The very first time someone tries to reference a `uptimeNanoseconds` or a similar +/// function that references a beginning point, this variable will be initialized as a beginning +/// reference point. This guarantees that all calls to `uptimeNanoseconds` or similar +/// will be 0 or greater. +/// +/// By design, it is not possible to related `ContinuousClock.Instant` to +/// `ProcessInfo.processInfo.systemUptime`, and even if one devised such +/// a mechanism, it would open the door for fingerprinting. It's best to let the concept +/// of uptime be relative to previous uptime calls. +@available(macOS 13, *) +private let uptimeBeginning: DispatchTime = DispatchTime.now() + +@available(macOS 13, *) +extension DispatchTime { + public static func now() -> DispatchTime { + now + } + + public var uptimeNanoseconds: UInt64 { + let beginning = uptimeBeginning + let rightNow = DispatchTime.now() + let uptimeDuration: Int64 = beginning.duration(to: rightNow).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 +// 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..469d691 --- /dev/null +++ b/Sources/DispatchAsync/DispatchTimeInterval.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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: 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 +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/Sources/DispatchAsync/PackageConstants.swift b/Sources/DispatchAsync/PackageConstants.swift new file mode 100644 index 0000000..762abf1 --- /dev/null +++ b/Sources/DispatchAsync/PackageConstants.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +package let kNanosecondsPerSecond: UInt64 = 1_000_000_000 +package let kNanosecondsPerMillisecond: UInt64 = 1_000_000 +package let kNanoSecondsPerMicrosecond: UInt64 = 1_000 diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift new file mode 100644 index 0000000..c1e622b --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +import Testing + +@testable import DispatchAsync + +@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..d4e8c77 --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchQueueTests.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +import Testing + +@testable import DispatchAsync + +#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..76ed242 --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +import Testing + +@testable import DispatchAsync + +nonisolated(unsafe) private var sharedPoolCompletionCount = 0 + +@Test func basicDispatchSemaphoreTest() 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..f0261cb --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchTimeTests.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// 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 +// +//===----------------------------------------------------------------------===// + +import Testing + +@testable import DispatchAsync + +@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..41690c4 --- /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 Apple Inc. and the Swift.org project authors +## 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..ee8e084 --- /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 Apple Inc. and the Swift.org project authors +## 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