diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Tetra.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Tetra.xcscheme index 2302888..41a9216 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Tetra.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Tetra.xcscheme @@ -38,6 +38,15 @@ ReferencedContainer = "container:"> + + + + =x)` + // can tell the two apart. NOT in the default set → OFF by default, so release/standard + // toolchains build cleanly. Enable with `swift build --traits SchedulingExecutorSPI` on + // a toolchain that exposes the SPI; an enabled trait becomes the `#if SchedulingExecutorSPI` + // compilation condition. + traits: [ + .trait( + name: "SchedulingExecutorSPI", + description: "Conform StackBoundRunLoopExecutor to the SchedulingExecutor/RunLoopExecutor/MainExecutor _Concurrency SPI protocols (only compilable where the stdlib exposes them)." + ), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), + .package( + url: "https://github.com/apple/swift-collections.git", + .upToNextMajor(from: "1.3.0"), + traits: [ + .defaults, +// .trait(name: "UnstableContainersPreview") + ], + ), + .package( + url: "https://github.com/apple/swift-atomics.git", + .upToNextMajor(from: "1.3.0"), + traits: [.defaults], + ), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.5"), + ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "Namespace", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "NamespaceExtension", + dependencies: [ + "Namespace", + ], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "CriticalSection", + dependencies: [ + .product(name: "Atomics", package: "swift-atomics"), + + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableExperimentalFeature("StaticExclusiveOnly"), + .enableExperimentalFeature("RawLayout"), + .enableExperimentalFeature("BuiltinModule"), + .enableExperimentalFeature("Lifetimes"), + .enableExperimentalFeature("LifetimeDependence"), + ] + ), + .target( + name: "BackportDiscardingTaskGroup", + dependencies: [ + "Namespace", + "CriticalSection", + ], + swiftSettings: [ + .enableUpcomingFeature("FullTypedThrows"), + .enableExperimentalFeature("IsolatedAny"), + .swiftLanguageMode(.v6) + ] + ), .target( name: "Tetra", - dependencies: [], + dependencies: [ + .product(name: "DequeModule", package: "swift-collections"), + .product(name: "HeapModule", package: "swift-collections"), + + "BackPortAsyncSequence", + "CriticalSection", + "BackportDiscardingTaskGroup", + "Namespace", + "NamespaceExtension", "TetraRunLoopConcurrency", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ], + swiftSettings: [ + .enableUpcomingFeature("FullTypedThrows"), + .enableExperimentalFeature("IsolatedAny"), + .swiftLanguageMode(.v6) + ] + ), + + .target( + name: "TetraRunLoopConcurrency", + dependencies: [ + "CriticalSection", + .product(name: "Atomics", package: "swift-atomics"), + .product(name: "HeapModule", package: "swift-collections"), + .product(name: "BasicContainers", package: "swift-collections"), + .product(name: "ContainersPreview", package: "swift-collections"), + ], swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency=complete"), + .swiftLanguageMode(.v6), + .enableExperimentalFeature("BuiltinModule"), + ] + ), + .target( + name: "BackPortAsyncSequence", + dependencies: [ "Namespace"], + swiftSettings: [ + .swiftLanguageMode(.v6), ] ), .testTarget( @@ -39,7 +144,19 @@ let package = Package( dependencies: [ "Tetra" ], - resources: [.process("Resources")] - ) - ] + resources: [.process("Resources")], + swiftSettings: [ + .swiftLanguageMode(.v5) + ] + ), + .testTarget( + name: "TetraRunLoopConcurrencyTests", + dependencies: ["TetraRunLoopConcurrency"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .unsafeFlags(["-Xfrontend", "-disable-availability-checking"]), + + ] + ), + ], ) diff --git a/README.md b/README.md index c48a538..ba3e27a 100644 --- a/README.md +++ b/README.md @@ -71,13 +71,16 @@ import Combine let cancellable = (0..<20).publisher .setFailureType(to: URLError.self) - .multiMapTask(maxTasks: .unlimited) { _ in + .multiMapTask(maxTasks: .unlimited) { _ throws(URLError) in + // Underlying Task is cancelled if subscription is cancelled before task completes. do { let (data, response) = try await URLSession.shared.data(from: URL(string: "https://google.com")!) - return .success(data) as Result + // below unsafe cancel is no-op, throw appropriate error to interrupt combine pipeline + // withUnsafeCurrentTask { $0?.cancel() } + return data } catch { - return .failure(error as! URLError) as Result + throw (error as! URLError) } }.sink { completion in @@ -101,8 +104,7 @@ import Tetra continuation.yield(1) continuation.finish() // Underlying AsyncIterator and Task receive task cancellation if subscription is cancelled. - }.tetra.publisher - .catch{ _ in Empty().setFailureType(to: Never.self) } + }.tetra.bridge.tetra.publisher .sink { number in } @@ -188,3 +190,38 @@ struct ContentView: View { - more fine grained way to introduce extension methods (maybe something like `.af` in Alamofire?) - remove all `AsyncTypedSequence` and `WrappedAsyncSequence` dummy protocol when `FullTypedThrow` is implemented. + +## WIP + +[PriorityRunLoop](./Sources/Tetra/Concurrency/PriorityRunLoop.swift) + +TaskPriority aware NSRunLoop based `Concurrency Serial Executor`. + +Can be useful for old Apple Framework that does not support `libDispatch` and only support `CFRunLoop` like `CoreLocation`, `perform(_:on:with:waitUntilDone:)` + +Reorder the jobs in Priority Order, and increase QoS level, according to the TaskPriority. + + +[AsyncFlatMap](./Sources/Tetra/Combine/Publishers+AsyncFlatMap.swift) + +Combine operator which convert the Upstream to AsyncSequence and transfer the elements to the downstream. + +uses actor based cooperative control to minimize contention. + +Investingating for a way to backport rich asyncSequence features before Swift 6 platform. + + +[BackportDiscardingTaskGroup](./Sources/BackportDiscardingTaskGroup/ThrowingTaskGroup.swift) + +Backport the behavior of `Discarding(Throwing)TaskGroup`. This now possbile thanks to Swift 6 generalized AsyncSequnce. Because we can wrap the `TaskGroup` into isolation. + +Investingating for a way to merge it with existing `Discarding(Throwing)TaskGroup` using some kind of opaque types rather than erased types. + +Investigating a way to merge NonFailure type and Failing type using typedThrow, which is currently not possible due to Swift 6 compiler bug. + + +[MemorySafe Data/String](./Sources/Tetra/Foundation/PruneMemory.swift) + +`Data` and `String` which erase its memory footprints when deallocated. implemented by `CoreFoundation` API. + +Still can not erase memory footprints caused by Swift Briding copy and os level memory paging. diff --git a/Sources/BackPortAsyncSequence/AsyncCompactMapSequence.swift b/Sources/BackPortAsyncSequence/AsyncCompactMapSequence.swift new file mode 100644 index 0000000..7c8d327 --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncCompactMapSequence.swift @@ -0,0 +1,146 @@ +// +// AsyncCompactMapSequence.swift +// +// +// Created by 박병관 on 6/13/24. +// + + +extension BackPort { + + + public struct AsyncCompactMapSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + + @usableFromInline + let base: Base + + @usableFromInline + let transform: (Base.Element) async throws(Failure) -> ElementOfResult? + + @inlinable + package init( + _ base: Base, + transform: @escaping (Base.Element) async throws(Failure) -> ElementOfResult? + ) { + self.base = base + self.transform = transform + } + } + + +} + +extension BackPort.AsyncCompactMapSequence: AsyncSequence, TypedAsyncSequence { + + /// The type of element produced by this asynchronous sequence. + /// + /// The compact map sequence produces errors from either the base + /// sequence or the transforming closure. + public typealias Failure = AsyncIterator.Failure + /// The type of iterator that produces elements of the sequence. + public typealias AsyncIterator = Iterator + + /// The iterator that produces elements of the compact map sequence. + public struct Iterator { + + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + let transform: (Base.Element) async throws(Failure) -> ElementOfResult? + + @usableFromInline + var finished = false + + @usableFromInline + init( + _ baseIterator: Base.AsyncIterator, + transform: @escaping (Base.Element) async throws(Failure) -> ElementOfResult? + ) { + self.baseIterator = baseIterator + self.transform = transform + } + + + } + + @inlinable + public __consuming func makeAsyncIterator() -> Iterator { + return Iterator(base.makeAsyncIterator(), transform: transform) + } +} + + +extension BackPort.AsyncCompactMapSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = ElementOfResult + public typealias Failure = Base.AsyncIterator.Err + + /// Produces the next element in the compact map sequence. + /// + /// This iterator calls `next()` on its base iterator; if this call + /// returns `nil`, `next()` returns `nil`. Otherwise, `next()` + /// calls the transforming closure on the received element, returning it if + /// the transform returns a non-`nil` value. If the transform returns `nil`, + /// this method continues to wait for further elements until it gets one + /// that transforms to a non-`nil` value. If calling the closure throws an + /// error, the sequence ends and `next()` rethrows the error. + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + while !finished { + guard let element = try await baseIterator.next(isolation: actor) else { + finished = true + return nil + } + let wrapper: (Base.Element) async -> Result, Failure> = { [transform] in + do throws(Base.AsyncIterator.Err) { + let value = try await Suppress(base: transform($0)) + return .success(value) + } catch { + return .failure(error) + } + } + do throws(Failure) { + if let transformed = try await wrapper(Suppress(base: element).base).get().base { + return transformed + } + } catch { + finished = true + throw error + } + } + return nil + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension BackPort.AsyncCompactMapSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable { } + + + +extension BackPort.AsyncCompactMapSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } + + +extension BackPort.AsyncCompactMapSequence { + + @inlinable + package init ( + _ source: Source, + transform: @escaping (Base.Element) async throws(Failure) -> ElementOfResult? + ) where Source.AsyncIterator: TypedAsyncIteratorProtocol, Source.AsyncIterator.Err == Never, Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: Failure.self) + self.init(base, transform: transform) + } + +} diff --git a/Sources/BackPortAsyncSequence/AsyncDropFirstSequence.swift b/Sources/BackPortAsyncSequence/AsyncDropFirstSequence.swift new file mode 100644 index 0000000..48acb9f --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncDropFirstSequence.swift @@ -0,0 +1,87 @@ +// +// AsyncDropFirstSequence.swift +// +// +// Created by 박병관 on 6/14/24. +// + +extension BackPort { + + public struct AsyncDropFirstSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + @usableFromInline + let base: Base + + @usableFromInline + let count: Int + + @inlinable + package init(_ base: Base, dropping count: Int) { + precondition(count >= 0, "Can't drop a negative number of elements from an async sequence") + self.base = base + self.count = count + } + } + +} + +extension BackPort.AsyncDropFirstSequence: AsyncSequence, TypedAsyncSequence { + + public typealias AsyncIterator = Iterator + public typealias Failure = AsyncIterator.Failure + + public struct Iterator { + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + var count: Int + + @usableFromInline + init(_ baseIterator: Base.AsyncIterator, count: Int) { + self.baseIterator = baseIterator + self.count = count + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(base.makeAsyncIterator(), count: count) + } + +} + +extension BackPort.AsyncDropFirstSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + + public typealias Failure = Base.AsyncIterator.Err + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + var remainingToDrop = count + while remainingToDrop > 0 { + guard try await baseIterator.next(isolation: actor) != nil else { + count = 0 + return nil + } + remainingToDrop -= 1 + } + count = 0 + return try await baseIterator.next(isolation: actor) + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension BackPort.AsyncDropFirstSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncDropFirstSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } diff --git a/Sources/BackPortAsyncSequence/AsyncDropWhileSequence.swift b/Sources/BackPortAsyncSequence/AsyncDropWhileSequence.swift new file mode 100644 index 0000000..e5f28aa --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncDropWhileSequence.swift @@ -0,0 +1,142 @@ +// +// Untitled.swift +// +// +// Created by 박병관 on 6/13/24. +// + +extension BackPort { + + public struct AsyncDropWhileSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + + @usableFromInline + let base: Base + + @usableFromInline + let predicate: (Base.Element) async throws(Failure) -> Bool + + @inlinable + package init( + _ base: Base, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) { + self.base = base + self.predicate = predicate + } + } + + +} +extension BackPort.AsyncDropWhileSequence: AsyncSequence, TypedAsyncSequence { + + + /// The type of errors produced by this asynchronous sequence. + /// + /// The drop-while sequence produces whatever type of error its base + /// sequence produces. + public typealias Failure = Iterator.Failure + /// The type of iterator that produces elements of the sequence. + public typealias AsyncIterator = Iterator + + /// The iterator that produces elements of the drop-while sequence. + public struct Iterator { + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + let predicate: (( Base.Element) async throws(Failure) -> Bool) + + @usableFromInline + var finished = false + + @usableFromInline + var doneDropping = false + + @usableFromInline + init( + _ baseIterator: Base.AsyncIterator, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) { + self.baseIterator = baseIterator + self.predicate = predicate + } + + + } + + /// Creates an instance of the drop-while sequence iterator. + @inlinable + public __consuming func makeAsyncIterator() -> Iterator { + return Iterator(base.makeAsyncIterator(), predicate: predicate) + } +} + + +extension BackPort.AsyncDropWhileSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + public typealias Failure = Base.AsyncIterator.Err + + /// Produces the next element in the drop-while sequence. + /// + /// This iterator calls `next(isolation:)` on its base iterator and + /// evaluates the result with the `predicate` closure. As long as the + /// predicate returns `true`, this method returns `nil`. After the predicate + /// returns `false`, for a value received from the base iterator, this + /// method returns that value. After that, the iterator returns values + /// received from its base iterator as-is, and never executes the predicate + /// closure again. + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + while !finished && !doneDropping { + guard let element = try await baseIterator.next(isolation: actor) else { + return nil + } + do throws(Failure) { + + if try await predicate(Suppress(base: element).base) == false { + doneDropping = true + return element + } + } catch { + finished = true + throw error + } + } + guard !finished else { + return nil + } + return try await baseIterator.next(isolation: actor) + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension BackPort.AsyncDropWhileSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncDropWhileSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncDropWhileSequence { + + + internal init( + _ source: Source, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) where Source.AsyncIterator: TypedAsyncIteratorProtocol, Source.AsyncIterator.Err == Never, Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: Failure.self) + + self.init(base, predicate: predicate) + } + + +} diff --git a/Sources/BackPortAsyncSequence/AsyncFilterSequence.swift b/Sources/BackPortAsyncSequence/AsyncFilterSequence.swift new file mode 100644 index 0000000..522421d --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncFilterSequence.swift @@ -0,0 +1,123 @@ +// +// Untitled.swift +// +// +// Created by 박병관 on 6/13/24. +// + +extension BackPort { + + + public struct AsyncFilterSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + + @usableFromInline + let base: Base + + @usableFromInline + let isIncluded: (Element) async throws(Failure) -> Bool + + @inlinable + package init( + _ base: Base, + isIncluded: @escaping (Base.Element) async throws(Failure) -> Bool + ) { + self.base = base + self.isIncluded = isIncluded + } + } + + + +} + +extension BackPort.AsyncFilterSequence: AsyncSequence, TypedAsyncSequence { + + + public typealias Failure = AsyncIterator.Failure + public typealias AsyncIterator = Iterator + + public struct Iterator { + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + let isIncluded: (Base.Element) async throws(Failure) -> Bool + + @usableFromInline + var finished = false + + @usableFromInline + init( + _ baseIterator: Base.AsyncIterator, + isIncluded: @escaping (Base.Element) async throws(Failure) -> Bool + ) { + self.baseIterator = baseIterator + self.isIncluded = isIncluded + } + + + } + + @inlinable + public __consuming func makeAsyncIterator() -> Iterator { + return Iterator(base.makeAsyncIterator(), isIncluded: isIncluded) + } + +} + + +extension BackPort.AsyncFilterSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + public typealias Failure = Base.AsyncIterator.Err + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + while !finished { + guard let element = try await baseIterator.next(isolation: actor) else { + return nil + } + do throws(Failure) { + if try await isIncluded(Suppress(base: element).base) { + return element + } + } catch { + finished = true + throw error + } + } + + return nil + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension BackPort.AsyncFilterSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncFilterSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncFilterSequence { + + @inlinable + package init( + _ source: Source, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) where Source.AsyncIterator: TypedAsyncIteratorProtocol, Source.AsyncIterator.Err == Never, Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: Failure.self) + self.init(base, isIncluded: predicate) + } + + + +} diff --git a/Sources/BackPortAsyncSequence/AsyncFlatMapSequence.swift b/Sources/BackPortAsyncSequence/AsyncFlatMapSequence.swift new file mode 100644 index 0000000..6e50da1 --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncFlatMapSequence.swift @@ -0,0 +1,196 @@ +// +// AsyncFlatMapSequence.swift +// +// +// Created by 박병관 on 6/13/24. +// + +extension BackPort { + + + public struct AsyncFlatMapSequence where Base.AsyncIterator:TypedAsyncIteratorProtocol, SegmentOfResult.AsyncIterator:TypedAsyncIteratorProtocol, SegmentOfResult.AsyncIterator.Err == Base.AsyncIterator.Err { + + + @usableFromInline + let base: Base + + @usableFromInline + let transform: (Base.Element) async throws(Failure) -> SegmentOfResult + + @inlinable + package init( + _ base: Base, + transform: @escaping (Base.Element) async throws(Failure) -> SegmentOfResult + ) { + self.base = base + self.transform = transform + } + } + + +} + +extension BackPort.AsyncFlatMapSequence: AsyncSequence, TypedAsyncSequence { + + @inlinable + public func makeAsyncIterator() -> Iterator { + .init(baseIterator: base.makeAsyncIterator(), transform: transform) + } + + public typealias Failure = AsyncIterator.Failure + public typealias AsyncIterator = Iterator + + + public struct Iterator { + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + let transform: (Base.Element) async throws(Failure) -> SegmentOfResult + + @usableFromInline + var currentIterator: SegmentOfResult.AsyncIterator? + + @usableFromInline + var finished = false + + @usableFromInline + init( + baseIterator: Base.AsyncIterator, + transform: @escaping (Base.Element) async throws(Failure) -> SegmentOfResult + ) { + self.baseIterator = baseIterator + self.transform = transform + } + + } + + + +} + + +extension BackPort.AsyncFlatMapSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = SegmentOfResult.Element + public typealias Failure = Base.AsyncIterator.Err + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + while !finished { + if var iterator = currentIterator { + do { + guard let element = try await iterator.next(isolation: actor) else { + currentIterator = nil + continue + } + // restore the iterator since we just mutated it with next + currentIterator = iterator + return element + } catch { + finished = true + throw error + } + } else { + guard let item = try await baseIterator.next(isolation: actor) else { + return nil + } + let block = transform + let wrapper = { (input:Suppress) async throws(Failure) in + let a = try await block(input.base) + return Suppress(base: a) + } + do throws(Failure) { + let segment: SegmentOfResult = try await wrapper(.init(base: item)).base + var iterator = segment.makeAsyncIterator() + guard let element = try await iterator.next(isolation: actor) else { + currentIterator = nil + continue + } + currentIterator = iterator + return element + } catch { + finished = true + currentIterator = nil + throw error + } + } + } + return nil + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension BackPort.AsyncFlatMapSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable, + SegmentOfResult: Sendable, + SegmentOfResult.Element: Sendable { } + +extension BackPort.AsyncFlatMapSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable, + SegmentOfResult.AsyncIterator: Sendable, + SegmentOfResult.Element: Sendable { } + + + +extension BackPort.AsyncFlatMapSequence { + + @inlinable + package init ( + _ source: Source, + transform: @escaping (Source.Element) async throws(Failure) -> SegmentOfResult + ) where + Source.AsyncIterator: TypedAsyncIteratorProtocol, + Source.AsyncIterator.Err == Never, + Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: Failure.self) + self.init(base, transform: transform) + } + + @inlinable + package init( + _ base: Base, + transform: @escaping (Base.Element) async throws(Failure) -> SegmentOfResultSource + ) where + SegmentOfResultSource.AsyncIterator: TypedAsyncIteratorProtocol, + SegmentOfResultSource.AsyncIterator.Err == Never, + SegmentOfResult == AsyncMapErrorSequence { + self.init(base) { (value) throws(Failure) in + + let source = try await transform(value) + return AsyncMapErrorSequence(base: source, failure: Failure.self) + } + } + + /// A == B == C + /// A / B == C + /// A == B / C + + @inlinable + package init( + _ source: Source, + transform: @escaping (Source.Element) async throws(Failure) -> SegmentOfResultSource + ) where + Source.AsyncIterator: TypedAsyncIteratorProtocol, + Source.AsyncIterator.Err == Never, + SegmentOfResultSource.AsyncIterator : TypedAsyncIteratorProtocol, + SegmentOfResultSource.AsyncIterator.Err == Never, + SegmentOfResult == AsyncMapErrorSequence, + Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: Failure.self) + self.init(base, transform: { value throws(Failure) in + let source = try await transform(value) + return AsyncMapErrorSequence(base: source, failure: Failure.self) + }) + } + +} diff --git a/Sources/BackPortAsyncSequence/AsyncMapErrorSequence.swift b/Sources/BackPortAsyncSequence/AsyncMapErrorSequence.swift new file mode 100644 index 0000000..2240e1b --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncMapErrorSequence.swift @@ -0,0 +1,109 @@ +// +// AsyncMapErrorSequence.swift +// +// +// Created by 박병관 on 6/13/24. +// + +import Namespace + +public struct AsyncMapErrorSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + + @usableFromInline + let mapError: @Sendable (Base.AsyncIterator.Err) async throws(Failure) -> Void + @usableFromInline + let base:Base + + @inlinable + package init( + base: Base, + mapError: @escaping @Sendable (Base.AsyncIterator.Err) async throws(Failure) -> Void + ) { + self.mapError = mapError + self.base = base + } + + @inlinable + package init( + base: Base, + failure:Failure.Type = Failure.self + ) where Base.AsyncIterator.Err == Never { + self.mapError = { @Sendable _ throws(Failure) in + } + self.base = base + + } + +} + + + + +extension AsyncMapErrorSequence:AsyncSequence, TypedAsyncSequence { + + public typealias AsyncIterator = Iterator + public typealias Failure = Failure +// public typealias Failure = AsyncIterator.Failure + + + public struct Iterator { + + @usableFromInline + let mapError: @Sendable (Base.AsyncIterator.Err) async throws(Failure) -> Void + @usableFromInline + var base:Base.AsyncIterator? + + + @usableFromInline + init( + base: Base.AsyncIterator, + mapError: @Sendable @escaping (Base.AsyncIterator.Err) async throws(Failure) -> Void + ) { + self.mapError = mapError + self.base = base + } + + } + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(base: base.makeAsyncIterator(), mapError: mapError) + } + + +} + +extension AsyncMapErrorSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + public typealias Failure = Failure + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + do { + let value = try await base?.next(isolation: actor) + if value == nil { + base = nil + } + return value + } catch { + base = nil + try await mapError(error) + return nil + } + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension AsyncMapErrorSequence: Sendable +where Base: Sendable, + Base.Element: Sendable { } + +extension AsyncMapErrorSequence.Iterator: Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } diff --git a/Sources/BackPortAsyncSequence/AsyncMapSequence.swift b/Sources/BackPortAsyncSequence/AsyncMapSequence.swift new file mode 100644 index 0000000..d73f678 --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncMapSequence.swift @@ -0,0 +1,237 @@ +// +// AsyncMapSequence.swift +// +// +// Created by 박병관 on 6/13/24. +// + +extension BackPort { + + public struct AsyncMapSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol{ + @usableFromInline + let base: Base + + @usableFromInline + let transform: (Base.Element) async throws(Failure) -> Transformed + + @inlinable + package init( + _ base: Base, + transform: @escaping (Base.Element) async throws(Failure) -> Transformed + ) { + self.base = base + self.transform = transform + } + } + + +} + + + +extension BackPort.AsyncMapSequence: AsyncSequence, TypedAsyncSequence { + + /// The type of the error that can be produced by the sequence. + /// + /// The map sequence produces whatever type of error its + /// base sequence does. + public typealias Failure = AsyncIterator.Failure + /// The type of iterator that produces elements of the sequence. + public typealias AsyncIterator = Iterator + + /// The iterator that produces elements of the map sequence. + public struct Iterator { + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + var finished = false + + @usableFromInline + let transform: (Base.Element) async throws(Failure) -> Transformed + + @usableFromInline + init( + _ baseIterator: Base.AsyncIterator, + transform: @escaping (Base.Element) async throws(Failure) -> Transformed + ) { + self.baseIterator = baseIterator + self.transform = transform + } + + } + + @inlinable + public __consuming func makeAsyncIterator() -> Iterator { + return Iterator(base.makeAsyncIterator(), transform: transform) + } +} + +extension BackPort.AsyncMapSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Transformed + public typealias Failure = Base.AsyncIterator.Err + + /// Produces the next element in the map sequence. + /// + /// This iterator calls `next()` on its base iterator; if this call returns + /// `nil`, `next()` returns `nil`. Otherwise, `next()` returns the result of + /// calling the transforming closure on the received element. + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + + /// Produces the next element in the map sequence. + /// + /// This iterator calls `next(isolation:)` on its base iterator; if this + /// call returns `nil`, `next(isolation:)` returns `nil`. Otherwise, + /// `next(isolation:)` returns the result of calling the transforming + /// closure on the received element. + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + guard !finished, let element = try await baseIterator.next(isolation: actor) else { + return nil + } +// let block = transform + let wrapper: (Base.Element) async -> Result, Failure> = { [transform] in + do throws(Base.AsyncIterator.Err) { + let value = try await Suppress(base: transform($0)) + return .success(value) + } catch { + return .failure(error) + } + } + do throws(Failure) { + + return try await wrapper(Suppress(base: element).base).get().base + } catch { + finished = true + throw error + } + } + +} + +extension BackPort.AsyncMapSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable, + Transformed: Sendable { } + +extension BackPort.AsyncMapSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable, + Transformed: Sendable { } + +extension BackPort.AsyncMapSequence { + + + @inlinable + package init ( + _ source: Source, + _ failure: Failure.Type = Failure.self, + transform: @escaping (Base.Element) async throws(Failure) -> Transformed + ) where Source.AsyncIterator: TypedAsyncIteratorProtocol, Source.AsyncIterator.Err == Never, Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: failure) + + self.init(base, transform: transform) + } +// +// internal init( +// _ base: Base, +// block: @escaping (Base.Element) async throws(Never) -> Transformed +// ) { +// self.init(base, transform: { await block($0) }) +// } +// + + +} + +// +//protocol AsyncProducer:~Copyable { +// +// associatedtype Failure:Error +// // this is not sendable +// associatedtype Element +// +// // since Element is not sendable we needs `sending` +// mutating func produce(isolation actor: isolated (any Actor)) async throws(Failure) -> sending Element? +// +//} +// +//struct MappingProducer: AsyncProducer { +// +// var base:Base +// // can not omit isolation parameter, can not use isolated(any) either +// let compute:(isolated (any Actor), consuming Base.Element) async throws(Base.Failure) -> sending Element +// +// mutating func produce(isolation actor: isolated (any Actor)) async throws(Base.Failure) -> sending Element? { +// if let value = try await base.produce(isolation: actor) { +// let newValue = try await compute(actor, value) +// return newValue +// } +// +// return nil +// } +// +//} +// +//struct MutatingProducer:AsyncProducer,~Copyable { +// +// var failed = false +// var state:State +// var source:EventSource +// let mutation:(isolated (any Actor), inout State ,sending EventSource.Element) async throws(EventSource.Failure) -> sending Effect +// +// mutating func produce(isolation actor: isolated (any Actor)) async throws(EventSource.Failure) -> sending Effect? { +// if failed { +// return nil +// } +// if let event = try await source.produce(isolation: actor) { +// do { +// let effect = try await mutation(actor, &state, event) +// +// return effect +// } catch { +// failed = true +// throw error +// } +// } +// +// return nil +// } +// +// +//} + +//struct FilteringProducer: AsyncProducer { +// +// var failed = false +// var base:Base +// // can not omit isolation parameter, can not use isolated(any) either +// let predicate:(isolated (any Actor), borrowing Base.Element) async throws(Base.Failure) -> Bool +// +// mutating func produce(isolation actor: isolated (any Actor)) async throws(Base.Failure) -> sending Base.Element? { +// if failed { +// return nil +// } +// if let value = try await base.produce(isolation: actor) { +// do { +// // Sending 'value' risks causing data races +// // may be marking parameter as ~Escape would help? +// if try await predicate(actor, value) { +// return value +// } +// } catch { +// failed = true +// throw error +// } +// } +// +// return nil +// } +// +//} diff --git a/Sources/BackPortAsyncSequence/AsyncPrefixSequence.swift b/Sources/BackPortAsyncSequence/AsyncPrefixSequence.swift new file mode 100644 index 0000000..4c7deaf --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncPrefixSequence.swift @@ -0,0 +1,85 @@ +// +// AsyncPrefixSequence.swift +// +// +// Created by 박병관 on 6/14/24. +// + +extension BackPort { + + + public struct AsyncPrefixSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + @usableFromInline + let base: Base + + @usableFromInline + let count: Int + + @usableFromInline + init(_ base: Base, count: Int) { + precondition(count >= 0, "Can't prefix a negative number of elements from an async sequence") + self.base = base + self.count = count + } + } + +} + +extension BackPort.AsyncPrefixSequence: AsyncSequence, TypedAsyncSequence { + + public typealias Failure = AsyncIterator.Failure + public typealias AsyncIterator = Iterator + + public struct Iterator { + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + var remaining: Int + + @usableFromInline + init(_ baseIterator: Base.AsyncIterator, count: Int) { + self.baseIterator = baseIterator + self.remaining = count + } + + } + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(base.makeAsyncIterator(), count: count) + } + +} + +extension BackPort.AsyncPrefixSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + public typealias Failure = Base.AsyncIterator.Err + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + if remaining != 0 { + remaining &-= 1 + return try await baseIterator.next(isolation: actor) + } else { + return nil + } + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + +extension BackPort.AsyncPrefixSequence: Sendable +where Base: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncPrefixSequence.Iterator: Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } diff --git a/Sources/BackPortAsyncSequence/AsyncPrefixWhileSequence.swift b/Sources/BackPortAsyncSequence/AsyncPrefixWhileSequence.swift new file mode 100644 index 0000000..937dc4b --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncPrefixWhileSequence.swift @@ -0,0 +1,144 @@ +// +// AsyncPrefixWhileSequence.swift +// +// +// Created by 박병관 on 6/13/24. +// + +extension BackPort { + + public struct AsyncPrefixWhileSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + @usableFromInline + let base: Base + + @usableFromInline + let predicate: (Base.Element) async throws(Failure) -> Bool + + @inlinable + package init( + _ base: Base, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) { + self.base = base + self.predicate = predicate + } + } + + + +} + + +extension BackPort.AsyncPrefixWhileSequence: AsyncSequence, TypedAsyncSequence { + /// The type of element produced by this asynchronous sequence. + /// + /// The prefix-while sequence produces whatever type of element its base + /// iterator produces. + /// The type of error produced by this asynchronous sequence. + /// + /// The prefix-while sequence produces errors from either the base + /// sequence or the filtering closure. + public typealias Failure = AsyncIterator.Failure + /// The type of iterator that produces elements of the sequence. + public typealias AsyncIterator = Iterator + + /// The iterator that produces elements of the prefix-while sequence. + public struct Iterator { + + + + @usableFromInline + var predicateHasFailed = false + + @usableFromInline + var baseIterator: Base.AsyncIterator + + @usableFromInline + let predicate: (Base.Element) async throws(Failure) -> Bool + + @usableFromInline + init( + _ baseIterator: Base.AsyncIterator, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) { + self.baseIterator = baseIterator + self.predicate = predicate + } + + + + } + + @inlinable + public __consuming func makeAsyncIterator() -> Iterator { + return Iterator(base.makeAsyncIterator(), predicate: predicate) + } +} + +extension BackPort.AsyncPrefixWhileSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + public typealias Failure = Base.AsyncIterator.Err + + /// Produces the next element in the prefix-while sequence. + /// + /// If the predicate hasn't failed yet, this method gets the next element + /// from the base sequence and calls the predicate with it. If this call + /// succeeds, this method passes along the element. Otherwise, it returns + /// `nil`, ending the sequence. If calling the predicate closure throws an + /// error, the sequence ends and `next()` rethrows the error. + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + + /// Produces the next element in the prefix-while sequence. + /// + /// If the predicate hasn't failed yet, this method gets the next element + /// from the base sequence and calls the predicate with it. If this call + /// succeeds, this method passes along the element. Otherwise, it returns + /// `nil`, ending the sequence. If calling the predicate closure throws an + /// error, the sequence ends and `next(isolation:)` rethrows the error. + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Base.Element? { + if !predicateHasFailed, let nextElement = try await baseIterator.next(isolation: actor) { + do throws(Failure) { + if try await predicate(Suppress(base: nextElement).base) { + return nextElement + } else { + predicateHasFailed = true + } + } catch { + predicateHasFailed = true + throw error + } + } + return nil + } + + +} + +extension BackPort.AsyncPrefixWhileSequence: @unchecked Sendable +where Base: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncPrefixWhileSequence.Iterator: @unchecked Sendable +where Base.AsyncIterator: Sendable, + Base.Element: Sendable { } + +extension BackPort.AsyncPrefixWhileSequence { + + @inlinable + package init( + _ source: Source, + predicate: @escaping (Base.Element) async throws(Failure) -> Bool + ) where Source.AsyncIterator: TypedAsyncIteratorProtocol, Source.AsyncIterator.Err == Never, Base == AsyncMapErrorSequence { + let base = AsyncMapErrorSequence(base: source, failure: Failure.self) + + self.init(base, predicate: predicate) + } + + +} diff --git a/Sources/BackPortAsyncSequence/AsyncStream.swift b/Sources/BackPortAsyncSequence/AsyncStream.swift new file mode 100644 index 0000000..a77a912 --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncStream.swift @@ -0,0 +1,79 @@ +// +// AsyncStream.swift +// +// +// Created by 박병관 on 6/15/24. +// + +public struct AsyncTypedStream { + + @usableFromInline + let base:AsyncStream + + @inlinable + public init(base: AsyncStream) { + self.base = base + } + +} + +extension AsyncTypedStream: AsyncSequence, TypedAsyncSequence { + + public typealias Failure = Never + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(baseIterator: base.makeAsyncIterator()) + } + + public struct Iterator { + + @usableFromInline + var baseIterator:AsyncStream.AsyncIterator + + @inlinable + public init(baseIterator: AsyncStream.AsyncIterator) { + self.baseIterator = baseIterator + } + + } + +} + +extension AsyncTypedStream.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Failure = Never + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async -> Element? { + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + return await baseIterator.next(isolation: actor) + } else { + nonisolated(unsafe) + var iter = self + defer { + self = iter + } + let value = await iter.advanceNext()?.base + return value + } + } + + @_disfavoredOverload + @inlinable + public mutating func next() async -> Element? { + await baseIterator.next() + } + + @inline(__always) + @usableFromInline +// @preconcurrency + internal mutating func advanceNext() async -> sending Suppress? { + guard let value = await baseIterator.next() else { return nil } + return .init(base: value) + } + +} + +extension AsyncTypedStream: Sendable where Element: Sendable {} + diff --git a/Sources/BackPortAsyncSequence/AsyncThrowingStream.swift b/Sources/BackPortAsyncSequence/AsyncThrowingStream.swift new file mode 100644 index 0000000..f4bd486 --- /dev/null +++ b/Sources/BackPortAsyncSequence/AsyncThrowingStream.swift @@ -0,0 +1,84 @@ +// +// AsyncThrowingStream.swift +// +// +// Created by 박병관 on 6/15/24. +// + +public struct AsyncTypedThrowingStream { + + @usableFromInline + let base: AsyncThrowingStream + + + @inlinable + public init(base: AsyncThrowingStream) { + self.base = base + } + +} + +extension AsyncTypedThrowingStream: AsyncSequence, TypedAsyncSequence { + + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(baseIterator: base.makeAsyncIterator()) + } + + + public struct Iterator { + + @usableFromInline + var baseIterator:AsyncThrowingStream.AsyncIterator + + @inlinable + public init(baseIterator: AsyncThrowingStream.AsyncIterator) { + self.baseIterator = baseIterator + } + + + } + +} + +extension AsyncTypedThrowingStream.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + return try await baseIterator.next(isolation: actor) + } else { + nonisolated(unsafe) + var iter = self + defer { + self = iter + } + do { + let value = try await iter.nextValue()?.base + return value + } catch { + throw (error as! Failure) + } + } + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + + @inline(__always) + @usableFromInline + internal mutating func nextValue() async throws -> Suppress? { + guard let value = try await baseIterator.next() else { return nil } + return .init(base: value) + } + +} + +extension AsyncTypedThrowingStream: Sendable where Element: Sendable {} + + + diff --git a/Sources/BackPortAsyncSequence/BackPort.swift b/Sources/BackPortAsyncSequence/BackPort.swift new file mode 100644 index 0000000..b1c95d6 --- /dev/null +++ b/Sources/BackPortAsyncSequence/BackPort.swift @@ -0,0 +1,58 @@ +// +// Untitled.swift +// +// +// Created by 박병관 on 6/13/24. +// + +public enum BackPort { + +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncStream.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingStream.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncMapSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncFlatMapSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncFilterSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncDropFirstSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncDropWhileSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncCompactMapSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncPrefixWhileSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncPrefixSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingMapSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingFlatMapSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingFilterSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingDropWhileSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingCompactMapSequence.Iterator: TypedAsyncIteratorProtocol {} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension AsyncThrowingPrefixWhileSequence.Iterator: TypedAsyncIteratorProtocol {} diff --git a/Sources/BackPortAsyncSequence/ConvertTypeToAsyncSequence.swift b/Sources/BackPortAsyncSequence/ConvertTypeToAsyncSequence.swift new file mode 100644 index 0000000..7946d09 --- /dev/null +++ b/Sources/BackPortAsyncSequence/ConvertTypeToAsyncSequence.swift @@ -0,0 +1,65 @@ +// +// ConvertTypeToAsyncSequence.swift +// +// +// Created by 박병관 on 6/15/24. +// + +public struct ConvertTypeToAsyncSequence where Base.AsyncIterator: TypedAsyncIteratorProtocol { + + @usableFromInline + var base:Base + + @inlinable + public init(base: Base) { + self.base = base + } + +} + +extension ConvertTypeToAsyncSequence: AsyncSequence, TypedAsyncSequence { + + public typealias Failure = Iterator.Failure + + public struct Iterator { + + @usableFromInline + var baseIterator:Base.AsyncIterator + + @usableFromInline + init(baseIterator: Base.AsyncIterator) { + self.baseIterator = baseIterator + } + + } + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(baseIterator: base.makeAsyncIterator()) + } + +} + + +extension ConvertTypeToAsyncSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Failure = Base.AsyncIterator.Err + public typealias Element = Base.Element + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + try await baseIterator.next(isolation: actor) + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + + +} + +extension ConvertTypeToAsyncSequence: Sendable where Base: Sendable, Base.Element: Sendable {} +extension ConvertTypeToAsyncSequence.Iterator: Sendable where Base.AsyncIterator: Sendable, Base.Element: Sendable {} + diff --git a/Sources/BackPortAsyncSequence/LegacyTypedAsyncSequence.swift b/Sources/BackPortAsyncSequence/LegacyTypedAsyncSequence.swift new file mode 100644 index 0000000..a6d9cc1 --- /dev/null +++ b/Sources/BackPortAsyncSequence/LegacyTypedAsyncSequence.swift @@ -0,0 +1,91 @@ +// +// LegacyTypedAsyncSequence.swift +// +// +// Created by 박병관 on 6/15/24. +// + +public struct LegacyTypedAsyncSequence { + + @usableFromInline + let base:Base + + + @inlinable + public init(base: Base) { + self.base = base + } + + +} + +extension LegacyTypedAsyncSequence: AsyncSequence, TypedAsyncSequence { + + + public typealias Failure = any Error + public typealias Element = Base.Element + + @inlinable + public func makeAsyncIterator() -> Iterator { + return Iterator(baseIterator: base.makeAsyncIterator()) + } + + + public struct Iterator { + + @usableFromInline + package var baseIterator:Base.AsyncIterator + + + @inlinable + public init(baseIterator: Base.AsyncIterator) { + self.baseIterator = baseIterator + } + + } + +} + + +extension LegacyTypedAsyncSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Element = Base.Element + public typealias Failure = any Error + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + return try await baseIterator.next(isolation: actor) + } else { + nonisolated(unsafe) + var iter = self + do { + let value = try await iter.advance()?.base + self = iter + return value + } catch { + self = iter + throw error + } + } + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await baseIterator.next() + } + + @inline(__always) + @usableFromInline + internal mutating func advance() async throws(Failure) -> Suppress? { + guard let value = try await baseIterator.next() else { return nil } + return .init(base: value) + } + + +} + +extension LegacyTypedAsyncSequence: Sendable where Base: Sendable, Base.Element: Sendable {} + +extension LegacyTypedAsyncSequence.Iterator: Sendable where Base.AsyncIterator: Sendable, Base.Element: Sendable {} diff --git a/Sources/BackPortAsyncSequence/TaskGroup.swift b/Sources/BackPortAsyncSequence/TaskGroup.swift new file mode 100644 index 0000000..49a44d8 --- /dev/null +++ b/Sources/BackPortAsyncSequence/TaskGroup.swift @@ -0,0 +1,71 @@ +// +// TaskGroup.swift +// +// +// Created by 박병관 on 6/28/24. +// +import Namespace + + +public struct TypedTaskGroup { + + @usableFromInline + let base:TaskGroup + + @inlinable + public init(base: TaskGroup) { + self.base = base + } + +} + +extension TypedTaskGroup: AsyncSequence, TypedAsyncSequence { + + public typealias Failure = Never + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(parent: base) + } + + public struct Iterator { + + @usableFromInline + var parent:TaskGroup + + @inlinable + internal init( + parent: TaskGroup + ) { + self.parent = parent + } + + } + +} + +extension TypedTaskGroup.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Failure = Never + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async -> Element? { + return await parent.next(isolation: actor) + } + + @_disfavoredOverload + @inlinable + public mutating func next() async -> Element? { + await next(isolation: nil) + } + +} + +extension TetraExtension{ + + @inlinable + public func bridge() -> TypedTaskGroup where Base == TaskGroup { + return .init(base: base) + } + +} diff --git a/Sources/BackPortAsyncSequence/ThrowingTaskGroup.swift b/Sources/BackPortAsyncSequence/ThrowingTaskGroup.swift new file mode 100644 index 0000000..6526776 --- /dev/null +++ b/Sources/BackPortAsyncSequence/ThrowingTaskGroup.swift @@ -0,0 +1,72 @@ +// +// ThrowingTaskGroup.swift +// +// +// Created by 박병관 on 6/28/24. +// +import Namespace + +public struct TypedThrowingTaskGroup { + + @usableFromInline + let base:ThrowingTaskGroup + + @inlinable + public init(base: ThrowingTaskGroup) { + self.base = base + } + +} + +extension TypedThrowingTaskGroup: AsyncSequence, TypedAsyncSequence { + + public typealias Failure = Failure + + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(parent: base) + } + + public struct Iterator { + + @usableFromInline + var parent:ThrowingTaskGroup + + @inlinable + internal init( + parent: ThrowingTaskGroup + ) { + self.parent = parent + } + + } + +} + +extension TypedThrowingTaskGroup.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + public typealias Failure = Failure + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + return try await parent.nextResult(isolation: actor)?.get() + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + + +extension TetraExtension{ + + @inlinable + public func bridge() -> TypedThrowingTaskGroup where Base == ThrowingTaskGroup { + return .init(base: base) + } + +} diff --git a/Sources/BackPortAsyncSequence/TypedAsyncIteratorProtocol.swift b/Sources/BackPortAsyncSequence/TypedAsyncIteratorProtocol.swift new file mode 100644 index 0000000..1d93b55 --- /dev/null +++ b/Sources/BackPortAsyncSequence/TypedAsyncIteratorProtocol.swift @@ -0,0 +1,82 @@ +// +// TypedAsyncIteratorProtocol.swift +// +// +// Created by 박병관 on 6/13/24. +// +/*** + Entry point of TypeFailure for `AsyncFlatMap` and `AsyncSequencePublisher + + Since generalized `AsyncSequence` is not available until Swift 6, this `protocol` is a entry point for async typed throw. + +Even though it has isolation parameter, this parameter is rarely used since `AsyncFlatMap` and `AsyncSequencePublisher` runs in nonisolated Task space. + +`Use `WrappedAsyncSequence` or `LegacyTypedAsyncSequence` if possible which provides general implementation to adapt this protocol + + Use `TypedAsyncSequence` with Associated primarmy type generic to make use of AsyncSequence in abstract way. And later wrap the instance with `ConvertTypeToAsyncSequence` to use it as typed `AsyncSequence` +``` + + let source = AsyncStream(...) + let abstactSequence:some TypedAsyncSequence = AsyncTypedStream(base: source) + for await value in ConvertTypeToAsyncSequence(base: abstactSequence) { + // use value + // this sequence is fully typed throwing and actor isolation support + } + + +``` + + + - important: `Err` must be same as `AsyncIterator.Failure` + + + - adapting protocol to existing type, + + adapting protocols to exisiting type that user don't own is pretty easy too. By writing conformance like below, compiler now know the correct implemenation without recursive problem. +```` + extension AsyncStream.Iterator: TypedAsyncIteratorProtocol { + + @_implements(TypedAsyncIteratorProtocol, next(isolation:)) + mutating public func tetraNext(isolation actor: isolated (any Actor)?) async throws(Never) -> Self.Element? { + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + var c: some AsyncIteratorProtocol = self + defer { + self = c as! Self + } + return await c.next(isolation: actor) + } else { + return try? await next() + } + } + + } + + ```` + */ + +public protocol TypedAsyncIteratorProtocol: ~Copyable { + + associatedtype Element + associatedtype Err: Error +// typealias Failure = Err + + @inlinable + mutating func next(isolation actor: isolated (any Actor)?) async throws(Err) -> Element? + +} + + +public protocol TypedAsyncSequence:AsyncSequence where AsyncIterator: TypedAsyncIteratorProtocol{ + + + /// The type of errors produced when iteration over the sequence fails. + associatedtype Err = AsyncIterator.Err where Err == AsyncIterator.Err + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + @inlinable + func makeAsyncIterator() -> AsyncIterator +} diff --git a/Sources/BackPortAsyncSequence/WrappedAsyncSequence.swift b/Sources/BackPortAsyncSequence/WrappedAsyncSequence.swift new file mode 100644 index 0000000..0815a08 --- /dev/null +++ b/Sources/BackPortAsyncSequence/WrappedAsyncSequence.swift @@ -0,0 +1,69 @@ +// +// WrappedAsyncSequence.swift +// +// +// Created by 박병관 on 6/15/24. +// + +public struct WrappedAsyncSequence { + + @usableFromInline + let base:Base + + @inlinable + public init(base: Base) { + self.base = base + } + + public struct Iterator { + + @usableFromInline + var baseIterator:Base.AsyncIterator + + @inlinable + public init(baseIterator: Base.AsyncIterator) { + self.baseIterator = baseIterator + } + + } + +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension WrappedAsyncSequence: AsyncSequence, TypedAsyncSequence { + + public typealias AsyncIterator = Iterator + public typealias Element = Base.Element + public typealias Failure = Base.Failure + + @inlinable + public func makeAsyncIterator() -> Iterator { + Iterator(baseIterator: base.makeAsyncIterator()) + } + +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension WrappedAsyncSequence.Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { + + + public typealias Element = Base.Element + public typealias Failure = Base.Failure + + @inlinable + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Failure) -> Element? { + try await baseIterator.next(isolation: actor) + } + + @_disfavoredOverload + @inlinable + public mutating func next() async throws(Failure) -> Element? { + try await next(isolation: nil) + } + +} + + +extension WrappedAsyncSequence: Sendable where Base: Sendable, Base.Element: Sendable {} + +extension WrappedAsyncSequence.Iterator: Sendable where Base.AsyncIterator: Sendable, Base.Element: Sendable {} diff --git a/Sources/BackPortAsyncSequence/operators.swift b/Sources/BackPortAsyncSequence/operators.swift new file mode 100644 index 0000000..ba5da67 --- /dev/null +++ b/Sources/BackPortAsyncSequence/operators.swift @@ -0,0 +1,171 @@ +// +// operators.swift +// +// +// Created by 박병관 on 6/13/24. +// +import Namespace + +public extension AsyncSequence { + + @inlinable + var tetra:TetraExtension { + .init(self) + } + +} + + +public extension TetraExtension where Base:AsyncSequence, Base.AsyncIterator: TypedAsyncIteratorProtocol, Base.AsyncIterator.Err == Never { + + @inlinable + func mapError( + _ failureType: Failure.Type = Failure.self + ) -> some TypedAsyncSequence { + AsyncMapErrorSequence(base: base, failure: failureType) + } + + @inlinable func filter( + _ isIncluded: @escaping @Sendable (Element) async throws(Failure) -> Bool + ) -> some TypedAsyncSequence + where Element == Base.Element { + mapError(Failure.self).tetra.filter(isIncluded) + } + + @inlinable + @_disfavoredOverload + func map( + _ map: @escaping @Sendable (Base.Element) async throws(Failure) -> T + ) -> some TypedAsyncSequence { + mapError().tetra.map(map) + } + + @inlinable + func flatMap2( + _ map: @escaping @Sendable (Base.AsyncIterator.Element) async throws(Failure) -> SegmentOfResults + ) -> some TypedAsyncSequence where + SegmentOfResults.AsyncIterator.Err == Failure, + SegmentOfResults.AsyncIterator: TypedAsyncIteratorProtocol, + SegmentOfResults.Element == T { + mapError().tetra.flatMap(map) + } +// + @inlinable + func flatMap0( + _ map: @escaping @Sendable (Base.AsyncIterator.Element) async throws(Failure) -> SegmentOfResults + ) -> some TypedAsyncSequence where + SegmentOfResults.AsyncIterator.Err == Never, + SegmentOfResults.AsyncIterator: TypedAsyncIteratorProtocol, + SegmentOfResults.Element == T { + mapError().tetra.flatMap{ value throws(Failure) in + let segment = try await map(value) + return segment.tetra.mapError(Failure.self) + } + } +// + +} + +public extension TetraExtension where Base:AsyncSequence, Base.AsyncIterator: TypedAsyncIteratorProtocol { + + + @inlinable + func mapError( + _ mapError: @escaping @Sendable (Base.AsyncIterator.Err) async throws(Failure) -> Void + ) -> some TypedAsyncSequence { + AsyncMapErrorSequence(base: base, mapError: mapError) + } + + +// @preconcurrency @inlinable public func map(_ transform: @escaping @Sendable (Self.Element) async -> Transformed) -> AsyncMapSequence + @inlinable func filter( + _ isIncluded: @escaping @Sendable (Element) async throws(Failure) -> Bool + ) -> some TypedAsyncSequence + where Element == Base.Element, Failure == Base.AsyncIterator.Err { + BackPort.AsyncFilterSequence(base, isIncluded: isIncluded) + } + + @inlinable + func map( + _ map: @escaping @Sendable (Base.AsyncIterator.Element) async throws(Failure) -> T + ) -> some TypedAsyncSequence where Base.AsyncIterator.Err == Failure { + BackPort.AsyncMapSequence(base, transform: map) + } + + @inlinable + func flatMap( + _ map: @escaping @Sendable (Base.AsyncIterator.Element) async throws(Failure) -> SegmentOfResults + ) -> some TypedAsyncSequence where + Base.AsyncIterator.Err == Failure, + SegmentOfResults.AsyncIterator.Err == Failure, + SegmentOfResults.AsyncIterator: TypedAsyncIteratorProtocol, + SegmentOfResults.Element == T { + BackPort.AsyncFlatMapSequence(base, transform: map) + } + + + @inlinable + func drop( + while predicate: @escaping @Sendable (Base.Element) async throws(Failure) -> Bool + ) -> some TypedAsyncSequence where Failure == Base.AsyncIterator.Err { + BackPort.AsyncDropWhileSequence(base, predicate: predicate) + } + + @inlinable + func prefix(while predicate: @escaping @Sendable (Base.Element) async throws(Failure) -> Bool) -> some TypedAsyncSequence where Base.AsyncIterator.Err == Failure { + BackPort.AsyncPrefixWhileSequence(base, predicate: predicate) + } + + @inlinable + func compactMap(_ transform: @escaping @Sendable (Base.Element) async throws(Failure) -> ElementOfResult?) -> some TypedAsyncSequence where Base.AsyncIterator.Err == Failure { + BackPort.AsyncCompactMapSequence(base, transform: transform) + } + + @inlinable func dropFirst(_ count: Int = 1) -> some TypedAsyncSequence { + BackPort.AsyncDropFirstSequence(base, dropping: count) + } + + @inlinable func prefix(_ count: Int = 1) -> some TypedAsyncSequence { + BackPort.AsyncPrefixSequence(base, count: count) + } + +} + + +public extension TetraExtension { + + @inlinable func dropFirst(_ count: Int = 1) -> some TypedAsyncSequence where Base == BackPort.AsyncDropFirstSequence { + BackPort.AsyncDropFirstSequence(base, dropping: count + base.count) + } + + @inlinable func prefix(_ count: Int = 1) -> some TypedAsyncSequence where Base == BackPort.AsyncPrefixSequence { + BackPort.AsyncPrefixSequence(base, count: base.count + count) + } + + @inlinable + func bridge() -> AsyncTypedStream where Base == AsyncStream { + AsyncTypedStream(base: base) + } + + @inlinable + func bridge() -> AsyncTypedThrowingStream where Base == AsyncThrowingStream { + AsyncTypedThrowingStream(base: base) + } + +} + + +@preconcurrency +@usableFromInline +struct Suppress:@unchecked Sendable { + + @usableFromInline + nonisolated(unsafe) + var base:Base + + @usableFromInline + init(base: Base) { + self.base = base + } + +} diff --git a/Sources/BackportDiscardingTaskGroup/CompatDiscardingTaskGroup.swift b/Sources/BackportDiscardingTaskGroup/CompatDiscardingTaskGroup.swift new file mode 100644 index 0000000..0314fbf --- /dev/null +++ b/Sources/BackportDiscardingTaskGroup/CompatDiscardingTaskGroup.swift @@ -0,0 +1,50 @@ +// +// CompatThrowingDiscardingTaskGroup.swift +// +// +// Created by 박병관 on 6/20/24. +// +@usableFromInline +package protocol CompatDiscardingTaskGroup { + + associatedtype Err:Error = any Error + typealias Block = @isolated(any) @Sendable () async throws(Err) -> Void + + @inlinable + var isCancelled:Bool { get } + + @inlinable + var isEmpty:Bool { get } + + @inlinable + func cancelAll() + + @inlinable + mutating func addTaskUnlessCancelled( + priority: TaskPriority?, + operation: sending @escaping Block + ) -> Bool + + @inlinable + mutating func addTask( + priority: TaskPriority?, + operation: sending @escaping Block + ) + + @inlinable + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + mutating func addTask( + executorPreference taskExecutor: (any TaskExecutor)?, + priority: TaskPriority?, + operation: sending @escaping Block + ) + + @inlinable + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + mutating func addTaskUnlessCancelled( + executorPreference taskExecutor: (any TaskExecutor)?, + priority: TaskPriority?, + operation: sending @escaping Block + ) -> Bool + +} diff --git a/Sources/BackportDiscardingTaskGroup/SafetyRegion.swift b/Sources/BackportDiscardingTaskGroup/SafetyRegion.swift new file mode 100644 index 0000000..5b52f21 --- /dev/null +++ b/Sources/BackportDiscardingTaskGroup/SafetyRegion.swift @@ -0,0 +1,45 @@ +// +// SafetyRegion.swift +// +// +// Created by 박병관 on 6/20/24. +// + +/// Empty actor to isolate `ThrowingTaskGroup` to simulate DiscardingTaskGroup +@usableFromInline +package actor SafetyRegion { + + @usableFromInline + internal(set) package var isFinished = false + @usableFromInline + internal var continuation: UnsafeContinuation? = nil + + @inlinable + package init() { + + } + + @usableFromInline + package func markDone() { +// guard !isFinished else { return } + isFinished = true + continuation?.resume() + continuation = nil + } + + @usableFromInline + internal func hold() async { + return await withUnsafeContinuation { + if isFinished { + $0.resume() + } else { + if let old = self.continuation { + assertionFailure("received suspend more than once!") + old.resume() + } + self.continuation = $0 + } + } + } + +} diff --git a/Sources/BackportDiscardingTaskGroup/Suppress.swift b/Sources/BackportDiscardingTaskGroup/Suppress.swift new file mode 100644 index 0000000..d537dbc --- /dev/null +++ b/Sources/BackportDiscardingTaskGroup/Suppress.swift @@ -0,0 +1,21 @@ +// +// Suppress.swift +// +// +// Created by 박병관 on 6/20/24. +// + +@preconcurrency +@usableFromInline +struct Suppress:@unchecked Sendable { + + @usableFromInline + nonisolated(unsafe) + var base:Base + + @usableFromInline + init(base: Base) { + self.base = base + } + +} diff --git a/Sources/BackportDiscardingTaskGroup/TaskGroup.swift b/Sources/BackportDiscardingTaskGroup/TaskGroup.swift new file mode 100644 index 0000000..09d8777 --- /dev/null +++ b/Sources/BackportDiscardingTaskGroup/TaskGroup.swift @@ -0,0 +1,137 @@ +// +// TaskGroup.swift +// +// +// Created by 박병관 on 6/20/24. +// + +internal import CriticalSection + +@usableFromInline +package func simuateDiscardingTaskGroup( + isolation actor: isolated T = #isolation, + body: (isolated T, inout TaskGroup) async -> TaskResult +) async -> sending TaskResult { + let wrapped = await withTaskGroup(of: Void.self, returning: Suppress.self) { group in + let holder: SafetyRegion = actor as? SafetyRegion ?? .init() + if await holder.isFinished { + preconditionFailure("SafetyRegion is already used!") + } + group.addTask(priority: .background) { + /// keep at least one child task alive + /// so that subTask won't return + await holder.hold() + + } + let suppress = Suppress(base: group) + /// drain all the finished or failed Task + async let subTask:Void = { (act: isolated T) in +// nonisolated(unsafe) + var iter = suppress.base + while let _ = await iter.next(isolation: act) { + if await holder.isFinished { + break + } + } + }(actor) + nonisolated(unsafe) + let body2 = body + async let mainTask = { (act: isolated T) in + var iter = suppress.base + let v = await body2(act, &iter) + await holder.markDone() + return Suppress(base: v) + }(actor) + await subTask + let value = await mainTask.base + return Suppress(base: value) + } + return wrapped.base +} + + +/// simulate `DiscardingTaskGroup` and return the TaskResult +/// +/// This function is a good workaround for globalActor isolated version of `simuateDiscardingTaskGroup` +/// +/// +///``` +///await simuateDiscardingTaskGroup { @MainActor group in +/// group.addTask{ ... } +/// group.addTask{ ... } +/// group.addTask{ ... } +/// return 0 +///} +///``` +/// +/// +/// - precondition: body can not be nonisolated +/// - Parameter body: DiscardingTaskGroup body +/// - Returns: which is returned from body +/// - SeeAlso: withDiscardingTaskGroup(returning:body:) +@inlinable +package func simuateDiscardingTaskGroup2( + isolation actor: isolated (any Actor)? = #isolation, + body: (inout TaskGroup) async -> TaskResult +) async -> TaskResult { + guard actor != nil else { + preconditionFailure("actor should not be nil") + } + return await withoutActuallyEscaping(body) { escapingClosure in + await __simuateDiscardingTaskGroup2(isolation: actor) { group in + let value = await escapingClosure(&group) + return Suppress(base: value) + } + }.base +} + +@usableFromInline +internal func __simuateDiscardingTaskGroup2( + isolation actor: isolated (any Actor)?, + body: @escaping (inout TaskGroup) async -> sending TaskResult +) async -> TaskResult { + let wrapped:Suppress = await withTaskGroup(of: Void.self, returning: Suppress.self, isolation: actor) { group in + let holder: SafetyRegion = actor as? SafetyRegion ?? .init() + if await holder.isFinished { + preconditionFailure("SafetyRegion is already used!") + } + group.addTask(priority: .background) { + /// keep at least one child task alive + /// so that subTask won't return + await holder.hold() + } + let suppress = Suppress(base: group) + /// drain all the finished or failed Task + async let subTask:Void = { (barrier: isolated (any Actor)?) in + + var iter = suppress.base + while let _ = await iter.next(isolation: barrier) { + if await holder.isFinished { + break + } + } + }(actor) + nonisolated(unsafe) + let body2 = body + async let mainTask = { (barrier: isolated (any Actor)?) in + var iter = suppress.base + let v = await body2(&iter) + await holder.markDone() + return Suppress(base: v) + }(actor) + do { + // wait for subTask first to trigger priority elavation + // (release finished tasks as soon as possible) + await subTask + } + nonisolated(unsafe) + let value = await mainTask.base + + return .init(base: value) + } + return wrapped.base +} + +extension Suppress: BitwiseCopyable where Base: BitwiseCopyable { + +} diff --git a/Sources/BackportDiscardingTaskGroup/ThrowingTaskGroup.swift b/Sources/BackportDiscardingTaskGroup/ThrowingTaskGroup.swift new file mode 100644 index 0000000..703a87c --- /dev/null +++ b/Sources/BackportDiscardingTaskGroup/ThrowingTaskGroup.swift @@ -0,0 +1,142 @@ +// +// ThrowingTaskGroup.swift +// +// +// Created by 박병관 on 6/20/24. +// + +@inlinable +package func simuateThrowingDiscardingTaskGroup2( + isolation actor: isolated (any Actor)? = #isolation, + body: (inout ThrowingTaskGroup) async throws -> TaskResult +) async throws -> TaskResult { + guard actor != nil else { + preconditionFailure("actor should not be nil") + } + return try await withoutActuallyEscaping(body) { escapingClosure in + try await __simuateThrowingDiscardingTaskGroup2(isolation: actor) { group in + let value = try await escapingClosure(&group) + return Suppress(base: value) + } + }.base +} + +@inlinable +package func simuateThrowingDiscardingTaskGroup( + isolation actor: isolated T, + body: (isolated T, inout ThrowingTaskGroup) async throws -> TaskResult +) async throws -> sending TaskResult { + let wrapped:Suppress = try await withThrowingTaskGroup(of: Void.self, returning: Suppress.self, isolation: actor) { group in + let holder: SafetyRegion = actor as? SafetyRegion ?? .init() + if await holder.isFinished { + preconditionFailure("SafetyRegion is already used!") + } + group.addTask(priority: .background) { + /// keep at least one child task alive + /// so that subTask won't return + await holder.hold() + } + let suppress = Suppress(base: group) + /// drain all the finished or failed Task + async let subTask:Void = { (barrier: isolated T) in +// nonisolated(unsafe) + var iter = suppress.base + while let _ = try await iter.next(isolation: barrier) { + if await holder.isFinished { + break + } + } + }(actor) + nonisolated(unsafe) + let body2 = body + async let mainTask = { (isolation: isolated T) in + var iter = suppress.base + do { + let v = try await body2(isolation, &iter) + await holder.markDone() + return Suppress(base: v) + } catch { + await holder.markDone() + throw error + } + }(actor) + let errorRef:(any Error)? + do { + // wait for subTask first to trigger priority elavation + // (release finished tasks as soon as possible) + try await subTask + errorRef = nil + } catch { + group.cancelAll() + errorRef = error + } + nonisolated(unsafe) + let value = try await mainTask.base + if let errorRef { + throw errorRef + } + return Suppress(base: value) + } + return wrapped.base +} + +@usableFromInline +internal func __simuateThrowingDiscardingTaskGroup2( + isolation actor: isolated (any Actor)?, + body: @escaping (inout ThrowingTaskGroup) async throws -> sending TaskResult +) async throws -> TaskResult { + let wrapped:Suppress = try await withThrowingTaskGroup(of: Void.self, returning: Suppress.self, isolation: actor) { group in + let holder: SafetyRegion = actor as? SafetyRegion ?? .init() + if await holder.isFinished { + preconditionFailure("SafetyRegion is already used!") + } + group.addTask(priority: .background) { + /// keep at least one child task alive + /// so that subTask won't return + await holder.hold() + } + let suppress = Suppress(base: group) + /// drain all the finished or failed Task + async let subTask:Void = {(barrier: isolated (any Actor)?) in + nonisolated(unsafe) + var iter = suppress.base + while let _ = try await iter.next(isolation: barrier) { + if await holder.isFinished { + break + } + } + }(#isolation) + nonisolated(unsafe) + let body2 = body + async let mainTask = { (barrier: isolated (any Actor)?) in + do { + nonisolated(unsafe) + var iter = suppress.base + + let v = try await body2(&iter) + await holder.markDone() + return Suppress(base: v) + } catch { + await holder.markDone() + throw error + } + }(#isolation) + let errorRef:(any Error)? + do { + // wait for subTask first to trigger priority elavation + // (release finished tasks as soon as possible) + try await subTask + errorRef = nil + } catch { + group.cancelAll() + errorRef = error + } + nonisolated(unsafe) + let value = try await mainTask.base + if let errorRef { + throw errorRef + } + return .init(base: value) + } + return wrapped.base +} diff --git a/Sources/BackportDiscardingTaskGroup/conformance.swift b/Sources/BackportDiscardingTaskGroup/conformance.swift new file mode 100644 index 0000000..3802cf1 --- /dev/null +++ b/Sources/BackportDiscardingTaskGroup/conformance.swift @@ -0,0 +1,127 @@ +// +// File.swift +// +// +// Created by 박병관 on 5/16/24. +// + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, macCatalyst 17.0, visionOS 1.0, *) +extension DiscardingTaskGroup: CompatDiscardingTaskGroup { + + @usableFromInline + package typealias Err = NoThrow +// @usableFromInline +// package typealias Failure = NoThrow +// + @_disfavoredOverload + @inlinable + package mutating func addTaskUnlessCancelled(priority: TaskPriority?, operation: @escaping Block) -> Bool { + let block = { @Sendable () async throws(Never) -> Void in + try? await operation() + } + return addTaskUnlessCancelled(priority: priority, operation: block) + } + + @_disfavoredOverload + @inlinable + package mutating func addTask(priority: TaskPriority?, operation: @escaping Block) { + let block = { @Sendable () async throws(Never) -> Void in + try? await operation() + } + return addTask(priority: priority, operation: block) + } + + @_disfavoredOverload + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inlinable + package mutating func addTask(executorPreference taskExecutor: (any TaskExecutor)?, priority: TaskPriority?, operation: @escaping Block) { + let block = { @Sendable () async throws(Never) -> Void in + try? await operation() + } + addTask(executorPreference: taskExecutor, priority: priority, operation: block) + } + + @_disfavoredOverload + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inlinable + package mutating func addTaskUnlessCancelled(executorPreference taskExecutor: (any TaskExecutor)?, priority: TaskPriority?, operation: @escaping Block) -> Bool { + let block = { @Sendable () async throws(Never) -> Void in + try? await operation() + } + return addTaskUnlessCancelled(executorPreference: taskExecutor, priority: priority, operation: block) + + } + +} + +extension TaskGroup: CompatDiscardingTaskGroup where ChildTaskResult == Void { + + @_disfavoredOverload + @inlinable + package mutating func addTaskUnlessCancelled(priority: TaskPriority?, operation: @escaping Block) -> Bool { + let block = { @Sendable in + try? await operation() + return + } + return addTaskUnlessCancelled(priority: priority, operation: block) + } + + @_disfavoredOverload + @inlinable + package mutating func addTask(priority: TaskPriority?, operation: @escaping Block) { + let block = { @Sendable in + try? await operation() + return + } + return addTask(priority: priority, operation: block) + } + + @_disfavoredOverload + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inlinable + package mutating func addTask(executorPreference taskExecutor: (any TaskExecutor)?, priority: TaskPriority?, operation: @escaping Block) { + let block = { @Sendable in + try? await operation() + return + } + addTask(executorPreference: taskExecutor, priority: priority, operation: block) + } + + @_disfavoredOverload + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inlinable + package mutating func addTaskUnlessCancelled(executorPreference taskExecutor: (any TaskExecutor)?, priority: TaskPriority?, operation: @escaping Block) -> Bool { + let block = { @Sendable in + try? await operation() + return + } + return addTaskUnlessCancelled(executorPreference: taskExecutor, priority: priority, operation: block) + + } + + @usableFromInline + package typealias Err = NoThrow + + +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, macCatalyst 17.0, visionOS 1.0, *) +extension ThrowingDiscardingTaskGroup: CompatDiscardingTaskGroup { + + @usableFromInline + package typealias Err = any Error +} + + +extension ThrowingTaskGroup: CompatDiscardingTaskGroup where ChildTaskResult == Void { + @usableFromInline + package typealias Err = any Error +} + + +@usableFromInline +package enum NoThrow: Error { + + case failure(Never) + +} diff --git a/Sources/CriticalSection/AtomicStore.swift b/Sources/CriticalSection/AtomicStore.swift new file mode 100644 index 0000000..bf77a13 --- /dev/null +++ b/Sources/CriticalSection/AtomicStore.swift @@ -0,0 +1,295 @@ +// +// AtomicStore.swift +// Tetra +// +// Created by 박병관 on 2/14/26. +// +package import Atomics +package import Builtin + +@_staticExclusiveOnly +@_rawLayout(like: Value.AtomicRepresentation, movesAsLike) +package struct AtomicStore:~Copyable where Value.AtomicRepresentation.Value == Value { + + @_transparent + @usableFromInline + package var _address: UnsafeMutablePointer { + UnsafeMutablePointer(_rawAddress) + } + + @_transparent + @inline(__always) + @usableFromInline + internal var _rawAddress: Builtin.RawPointer { + Builtin.addressOfRawLayout(self) + } + + @_transparent + @usableFromInline + package init(_ initialValue: consuming Value) { + _address.initialize(to: Value.AtomicRepresentation(initialValue)) + } + + @inlinable + deinit { + let _ = _address.pointee.dispose() + } + + + @usableFromInline + @inline(__always) + @_transparent + var _ptr:UnsafeMutablePointer<_Storage> { + _address + } + + + @usableFromInline + @inline(__always) + @_transparent + var store:UnsafeAtomic { + .init(at: _address) + } + + @usableFromInline + typealias _Storage = Value.AtomicRepresentation + +} + + + + + + + + + extension AtomicStore { + + + +// typealias Atomic + + /// Atomically loads and returns the current value, applying the specified + /// memory ordering. + /// + /// - Parameter ordering: The memory ordering to apply on this operation. + /// - Returns: The current value. + @_semantics("atomics.requires_constant_orderings") + @inlinable + @_transparent +// @_alwaysEmitIntoClient + public func load( + ordering: AtomicLoadOrdering + ) -> Value { +// _Storage.atomicLoad(at: _ptr, ordering: ordering) + store.load(ordering: ordering) + } + + /// Atomically sets the current value to `desired`, applying the specified + /// memory ordering. + /// + /// - Parameter desired: The desired new value. + /// - Parameter ordering: The memory ordering to apply on this operation. + @_semantics("atomics.requires_constant_orderings") + @_transparent + //@_alwaysEmitIntoClient + public func store( + _ desired: consuming Value, + ordering: AtomicStoreOrdering + ) { + _Storage.atomicStore(desired, at: _ptr, ordering: ordering) + } + + /// Atomically sets the current value to `desired` and returns the original + /// value, applying the specified memory ordering. + /// + /// - Parameter desired: The desired new value. + /// - Parameter ordering: The memory ordering to apply on this operation. + /// - Returns: The original value. + @_semantics("atomics.requires_constant_orderings") + @_transparent + //@_alwaysEmitIntoClient + public func exchange( + _ desired: consuming Value, + ordering: AtomicUpdateOrdering + ) -> Value { + _Storage.atomicExchange(desired, at: _ptr, ordering: ordering) + } + + /// Perform an atomic compare and exchange operation on the current value, + /// applying the specified memory ordering. + /// + /// This operation performs the following algorithm as a single atomic + /// transaction: + /// + /// ``` + /// atomic(self) { currentValue in + /// let original = currentValue + /// guard original == expected else { return (false, original) } + /// currentValue = desired + /// return (true, original) + /// } + /// ``` + /// + /// This method implements a "strong" compare and exchange operation + /// that does not permit spurious failures. + /// + /// - Parameter expected: The expected current value. + /// - Parameter desired: The desired new value. + /// - Parameter ordering: The memory ordering to apply on this operation. + /// - Returns: A tuple `(exchanged, original)`, where `exchanged` is true if + /// the exchange was successful, and `original` is the original value. + @_semantics("atomics.requires_constant_orderings") + @_transparent + //@_alwaysEmitIntoClient + public func compareExchange( + expected:consuming Value, + desired: consuming Value, + ordering: AtomicUpdateOrdering + ) -> (exchanged: Bool, original: Value) { + _Storage.atomicCompareExchange( + expected: expected, + desired: desired, + at: _ptr, + ordering: ordering) + } + + /// Perform an atomic compare and exchange operation on the current value, + /// applying the specified success/failure memory orderings. + /// + /// This operation performs the following algorithm as a single atomic + /// transaction: + /// + /// ``` + /// atomic(self) { currentValue in + /// let original = currentValue + /// guard original == expected else { return (false, original) } + /// currentValue = desired + /// return (true, original) + /// } + /// ``` + /// + /// The `successOrdering` argument specifies the memory ordering to use when + /// the operation manages to update the current value, while `failureOrdering` + /// will be used when the operation leaves the value intact. + /// + /// This method implements a "strong" compare and exchange operation + /// that does not permit spurious failures. + /// + /// - Parameter expected: The expected current value. + /// - Parameter desired: The desired new value. + /// - Parameter successOrdering: The memory ordering to apply if this + /// operation performs the exchange. + /// - Parameter failureOrdering: The memory ordering to apply on this + /// operation does not perform the exchange. + /// - Returns: A tuple `(exchanged, original)`, where `exchanged` is true if + /// the exchange was successful, and `original` is the original value. + @_semantics("atomics.requires_constant_orderings") + @_transparent + //@_alwaysEmitIntoClient + public func compareExchange( + expected: consuming Value, + desired: consuming Value, + successOrdering: AtomicUpdateOrdering, + failureOrdering: AtomicLoadOrdering + ) -> (exchanged: Bool, original: Value) { + _Storage.atomicCompareExchange( + expected: expected, + desired: desired, + at: _ptr, + successOrdering: successOrdering, + failureOrdering: failureOrdering) + } + + /// Perform an atomic weak compare and exchange operation on the current + /// value, applying the memory ordering. This compare-exchange variant is + /// allowed to spuriously fail; it is designed to be called in a loop until + /// it indicates a successful exchange has happened. + /// + /// This operation performs the following algorithm as a single atomic + /// transaction: + /// + /// ``` + /// atomic(self) { currentValue in + /// let original = currentValue + /// guard original == expected else { return (false, original) } + /// currentValue = desired + /// return (true, original) + /// } + /// ``` + /// + /// (In this weak form, transient conditions may cause the `original == + /// expected` check to sometimes return false when the two values are in fact + /// the same.) + /// + /// - Parameter expected: The expected current value. + /// - Parameter desired: The desired new value. + /// - Parameter ordering: The memory ordering to apply on this operation. + /// - Returns: A tuple `(exchanged, original)`, where `exchanged` is true if + /// the exchange was successful, and `original` is the original value. + @_semantics("atomics.requires_constant_orderings") + @_transparent + //@_alwaysEmitIntoClient + public func weakCompareExchange( + expected: consuming Value, + desired: __owned Value, + ordering: AtomicUpdateOrdering + ) -> (exchanged: Bool, original: Value) { + _Storage.atomicWeakCompareExchange( + expected: expected, + desired: desired, + at: _ptr, + ordering: ordering) + } + + /// Perform an atomic weak compare and exchange operation on the current + /// value, applying the specified success/failure memory orderings. This + /// compare-exchange variant is allowed to spuriously fail; it is designed to + /// be called in a loop until it indicates a successful exchange has happened. + /// + /// This operation performs the following algorithm as a single atomic + /// transaction: + /// + /// ``` + /// atomic(self) { currentValue in + /// let original = currentValue + /// guard original == expected else { return (false, original) } + /// currentValue = desired + /// return (true, original) + /// } + /// ``` + /// + /// (In this weak form, transient conditions may cause the `original == + /// expected` check to sometimes return false when the two values are in fact + /// the same.) + /// + /// The `ordering` argument specifies the memory ordering to use when the + /// operation manages to update the current value, while `failureOrdering` + /// will be used when the operation leaves the value intact. + /// + /// - Parameter expected: The expected current value. + /// - Parameter desired: The desired new value. + /// - Parameter successOrdering: The memory ordering to apply if this + /// operation performs the exchange. + /// - Parameter failureOrdering: The memory ordering to apply on this + /// operation does not perform the exchange. + /// - Returns: A tuple `(exchanged, original)`, where `exchanged` is true if + /// the exchange was successful, and `original` is the original value. + @_semantics("atomics.requires_constant_orderings") + @_transparent + //@_alwaysEmitIntoClient + public func weakCompareExchange( + expected:consuming Value, + desired: consuming Value, + successOrdering: AtomicUpdateOrdering, + failureOrdering: AtomicLoadOrdering + ) -> (exchanged: Bool, original: Value) { + _Storage.atomicWeakCompareExchange( + expected: expected, + desired: desired, + at: _ptr, + successOrdering: successOrdering, + failureOrdering: failureOrdering) + } +} + diff --git a/Sources/CriticalSection/BackportedCell.swift b/Sources/CriticalSection/BackportedCell.swift new file mode 100644 index 0000000..0f64790 --- /dev/null +++ b/Sources/CriticalSection/BackportedCell.swift @@ -0,0 +1,143 @@ +// +// BackportedCell.swift +// +// +// Created by 박병관 on 6/26/24. +// +package import Builtin + +//@available(macOS 26.0.0, *) +@_rawLayout(likeArrayOf: T, count: 5, movesAsLike) +package struct FiveArray:~Copyable { + @_transparent + @usableFromInline + package var _rawAddress: Builtin.RawPointer { + Builtin.addressOfRawLayout(self) + } + @_transparent + @usableFromInline + package var _address: UnsafeMutableBufferPointer { + .init(start: .init(_rawAddress), count: 5) + } + + public init(initializingWith initializer: (inout OutputSpan) throws(E) -> Void) throws(E) where E : Error { + var span = unsafe OutputSpan(buffer: _address, initializedCount: 0) + try initializer(&span) + let count = span.finalize(for: _address) + + precondition(5 == count) + } + + deinit { + _address.deinitialize() + } + public + var span:Span { + @_lifetime(borrow self) + borrowing get { + _overrideLifetime(Span(_unsafeStart: UnsafePointer(_rawAddress), count: 5), borrowing: self) + } + } + public + var mutableSpan:MutableSpan { + @_lifetime(&self) + mutating get { + _overrideLifetime(MutableSpan(_unsafeStart: UnsafeMutablePointer(_rawAddress), count:5), mutating: &self) + } + } + + package subscript (index: Int) -> T { + borrowing _read { + yield _address[index] + } + mutating _modify { + yield &_address[index] + } + } + +} + +extension FiveArray: @unchecked Sendable where T:~Copyable, T:Sendable {} +extension ThreeArray: @unchecked Sendable where T:~Copyable, T:Sendable {} + + + +@_rawLayout(likeArrayOf: T, count: 3, movesAsLike) +package struct ThreeArray:~Copyable { + @_transparent + @usableFromInline + package var _rawAddress: Builtin.RawPointer { + Builtin.addressOfRawLayout(self) + } + @_transparent + @usableFromInline + package var _address: UnsafeMutableBufferPointer { + .init(start: .init(_rawAddress), count: 3) + } + + public init(initializingWith initializer: (inout OutputSpan) throws(E) -> Void) throws(E) where E : Error { + var span = unsafe OutputSpan(buffer: _address, initializedCount: 0) + try initializer(&span) + let count = span.finalize(for: _address) + + precondition(3 == count) + } + + deinit { + _address.deinitialize() + } + public + var span:Span { + @_lifetime(borrow self) + borrowing get { + _overrideLifetime(Span(_unsafeStart: UnsafePointer(_rawAddress), count: 3), borrowing: self) + } + } + public + var mutableSpan:MutableSpan { + @_lifetime(&self) + mutating get { + _overrideLifetime(MutableSpan(_unsafeStart: UnsafeMutablePointer(_rawAddress), count:3), mutating: &self) + } + } + + package subscript (index: Int) -> T { + borrowing _read { + yield _address[index] + } + mutating _modify { + yield &_address[index] + } + } + +} + +@frozen +@usableFromInline +@_rawLayout(like: Value, movesAsLike) +package struct BackportedCell: ~Copyable { + + @_transparent + @usableFromInline + package var _address: UnsafeMutablePointer { + UnsafeMutablePointer(_rawAddress) + } + + @_transparent +// @usableFromInline + public var _rawAddress: Builtin.RawPointer { + Builtin.addressOfRawLayout(self) + } + + @_transparent + @usableFromInline + package init(_ initialValue: consuming Value) { + _address.initialize(to: initialValue) + } + + @inlinable + deinit { + _address.deinitialize(count: 1) + } + +} diff --git a/Sources/CriticalSection/CompatMutex.swift b/Sources/CriticalSection/CompatMutex.swift new file mode 100644 index 0000000..953c54a --- /dev/null +++ b/Sources/CriticalSection/CompatMutex.swift @@ -0,0 +1,8 @@ +// +// File.swift +// Tetra +// +// Created by 박병관 on 1/4/25. +// + +import Foundation diff --git a/Sources/CriticalSection/DarwinImpl.swift b/Sources/CriticalSection/DarwinImpl.swift new file mode 100644 index 0000000..65c8457 --- /dev/null +++ b/Sources/CriticalSection/DarwinImpl.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Atomics open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if $BuiltinAddressOfRawLayout && canImport(Darwin) +import Darwin + +@frozen +@_staticExclusiveOnly +public struct _MutexHandle: ~Copyable { + @usableFromInline + let value: BackportedCell + + @_transparent + public init() { + value = BackportedCell(os_unfair_lock()) + } + + @_transparent + internal borrowing func _lock() { + os_unfair_lock_lock(value._address) + } + + @_transparent + internal borrowing func _tryLock() -> Bool { + os_unfair_lock_trylock(value._address) + } + + + @_transparent + internal borrowing func _unlock() { + os_unfair_lock_unlock(value._address) + } +} + +#endif diff --git a/Sources/Tetra/Foundation/ManagedUnfairLock.swift b/Sources/CriticalSection/ManagedUnfairLock.swift similarity index 64% rename from Sources/Tetra/Foundation/ManagedUnfairLock.swift rename to Sources/CriticalSection/ManagedUnfairLock.swift index b619e0b..6232457 100644 --- a/Sources/Tetra/Foundation/ManagedUnfairLock.swift +++ b/Sources/CriticalSection/ManagedUnfairLock.swift @@ -1,12 +1,25 @@ // // ManagedUnfairLock.swift -// +// // // Created by pbk on 2022/12/14. // import Foundation +#if canImport(os) && canImport(Darwin) import os +import Darwin + +@usableFromInline +internal final class LockBuffer: ManagedBuffer { + + @usableFromInline + deinit { + self.withUnsafeMutablePointerToElements{ + let _ = $0.deinitialize(count: 1) + } + } +} @available(iOS, deprecated: 16.0, renamed: "OSAllocatedUnfairLock") @available(tvOS, deprecated: 16.0, renamed: "OSAllocatedUnfairLock") @@ -15,7 +28,8 @@ import os @available(macOS, deprecated: 13.0, renamed: "OSAllocatedUnfairLock") public struct ManagedUnfairLock: @unchecked Sendable { - private let __lock:ManagedBuffer + @usableFromInline + internal let __lock:ManagedBuffer /// Initialize an SwiftUnfairLock with a non-sendable lock-protected /// `initialState`. @@ -28,8 +42,9 @@ public struct ManagedUnfairLock: @unchecked Sendable { /// - Parameter initialState: An initial value to store that will be /// protected under the lock. /// + @inlinable public init(uncheckedState initialState: State) { - __lock = .create(minimumCapacity: 1) { buffer in + __lock = LockBuffer.create(minimumCapacity: 1) { buffer in buffer.withUnsafeMutablePointerToElements{ $0.initialize(to: .init()) } return initialState } @@ -45,14 +60,25 @@ public struct ManagedUnfairLock: @unchecked Sendable { /// - Returns: The return value of `body`. /// - Throws: Anything thrown by `body`. /// - public func withLockUnchecked(_ body: (inout State) throws -> R) rethrows -> R { - try __lock.withUnsafeMutablePointers{ state, lock in + @inlinable + public func withLockUnchecked(_ body: (inout State) throws(Failure) -> R) throws(Failure) -> R { + try __lock.withUnsafeMutablePointers{ state, lock throws(Failure) in os_unfair_lock_lock(lock) defer { os_unfair_lock_unlock(lock) } return try body(&state.pointee) } } + @available(iOS 18.0, tvOS 18.0, macOS 15.0, macCatalyst 18.0, watchOS 11.0, visionOS 2.0, *) + @inlinable + func withLockUnchecked(flags: UnfairLockFlags, _ body: (inout State) throws(Failure) -> R) throws(Failure) -> R { + try __lock.withUnsafeMutablePointers { state, lock throws(Failure) in + os_unfair_lock_lock_with_flags(lock, flags.unwrapped) + defer { os_unfair_lock_unlock(lock) } + return try body(&state.pointee) + } + } + /// Perform a sendable closure while holding this lock. /// /// @@ -60,7 +86,14 @@ public struct ManagedUnfairLock: @unchecked Sendable { /// - Returns: The sendable return value of `body`. /// - Throws: Anything thrown by `body`. /// - public func withLock(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R : Sendable { + @inlinable + public func withLock(_ body: @Sendable (inout State) throws(Failure) -> R) throws(Failure) -> R where R : Sendable { + try withLockUnchecked(body) + } + + @available(iOS 18.0, tvOS 18.0, macOS 15.0, macCatalyst 18.0, watchOS 11.0, visionOS 2.0, *) + @inlinable + func withLock(flags:UnfairLockFlags ,_ body: @Sendable (inout State) throws(Failure) -> R) throws(Failure) -> R where R : Sendable { try withLockUnchecked(body) } @@ -76,8 +109,9 @@ public struct ManagedUnfairLock: @unchecked Sendable { /// If the lock is not acquired, nil. /// - Throws: Anything thrown by `body`. /// - public func withLockIfAvailableUnchecked(_ body: (inout State) throws -> R) rethrows -> R? { - try __lock.withUnsafeMutablePointers{ state, lock in + @inlinable + public func withLockIfAvailableUnchecked(_ body: (inout State) throws(Failure) -> R) throws(Failure) -> R? { + try __lock.withUnsafeMutablePointers{ state, lock throws(Failure) in guard os_unfair_lock_trylock(lock) else { return nil } defer { os_unfair_lock_unlock(lock) } return try body(&state.pointee) @@ -92,10 +126,11 @@ public struct ManagedUnfairLock: @unchecked Sendable { /// If the lock is not acquired, nil. /// - Throws: Anything thrown by `body`. /// - public func withLockIfAvailable(_ body: @Sendable (inout State) throws -> R) rethrows -> R? where R : Sendable { + @inlinable + public func withLockIfAvailable(_ body: @Sendable (inout State) throws(Failure) -> R) throws(Failure) -> R? where R : Sendable { try withLockIfAvailableUnchecked(body) } - + @frozen public enum Ownership: Sendable, Hashable { case owner @@ -113,6 +148,7 @@ public struct ManagedUnfairLock: @unchecked Sendable { /// - `.owner` - asserts and terminates the process /// - `.notOwner` - returns /// + @inlinable public func precondition(_ condition: Ownership) { __lock.withUnsafeMutablePointerToElements { switch condition { @@ -130,22 +166,34 @@ public struct ManagedUnfairLock: @unchecked Sendable { public extension ManagedUnfairLock where State == Void { /// Initialize an SwiftUnfairLock with no protected state. + @inlinable init() { - __lock = .create(minimumCapacity: 1) { buffer in + __lock = LockBuffer.create(minimumCapacity: 1) { buffer in buffer.withUnsafeMutablePointerToElements{ $0.initialize(to: .init()) } } } /// Acquire this lock. @_unavailableFromAsync(message: "Use async-safe scoped locking instead") + @inlinable func lock() { __lock.withUnsafeMutablePointerToElements { os_unfair_lock_lock($0) } } + @_unavailableFromAsync(message: "Use async-safe scoped locking instead") + @available(iOS 18.0, tvOS 18.0, macOS 15.0, macCatalyst 18.0, watchOS 11.0, visionOS 2.0, *) + @inlinable + func lock(flags: UnfairLockFlags) { + __lock.withUnsafeMutablePointerToElements { + os_unfair_lock_lock_with_flags($0, flags.unwrapped) + } + } + /// Unlock this lock. @_unavailableFromAsync(message: "Use async-safe scoped locking instead") + @inlinable func unlock() { __lock.withUnsafeMutablePointerToElements{ os_unfair_lock_unlock($0) } } @@ -156,10 +204,18 @@ public extension ManagedUnfairLock where State == Void { /// - Returns: The return value of `body`. /// - Throws: Anything thrown by `body`. /// - func withLock(_ body: @Sendable () throws -> R) rethrows -> R where R : Sendable { + @inlinable + func withLock(_ body: @Sendable () throws(Failure) -> R) throws(Failure) -> R where R : Sendable { try withLockUnchecked(body) } + @available(iOS 18.0, tvOS 18.0, macOS 15.0, macCatalyst 18.0, watchOS 11.0, visionOS 2.0, *) + @inlinable + func withLock(flags:UnfairLockFlags ,_ body: @Sendable () throws(Failure) -> R) throws(Failure) -> R where R : Sendable { + try withLockUnchecked(body) + } + + /// Perform a closure while holding this lock. /// This method does not enforce sendability requirement /// on closure body and its return type. @@ -170,20 +226,32 @@ public extension ManagedUnfairLock where State == Void { /// - Returns: The return value of `body`. /// - Throws: Anything thrown by `body`. /// - func withLockUnchecked(_ body: () throws -> R) rethrows -> R { - try __lock.withUnsafeMutablePointerToElements { lock in + @inlinable + func withLockUnchecked(_ body: () throws(Failure) -> R) throws(Failure) -> R { + try __lock.withUnsafeMutablePointerToElements { lock throws(Failure) in os_unfair_lock_lock(lock) defer { os_unfair_lock_unlock(lock) } return try body() } } + @available(iOS 18.0, tvOS 18.0, macOS 15.0, macCatalyst 18.0, watchOS 11.0, visionOS 2.0, *) + @inlinable + func withLockUnchecked(flags: UnfairLockFlags, _ body: () throws(Failure) -> R) throws(Failure) -> R { + try __lock.withUnsafeMutablePointerToElements { lock throws(Failure) in + os_unfair_lock_lock_with_flags(lock, flags.unwrapped) + defer { os_unfair_lock_unlock(lock) } + return try body() + } + } + /// Attempt to acquire the lock if it is not already locked. /// /// - Returns: `true` if the lock was succesfully locked, and /// `false` if the lock attempt failed. @available(*, noasync, message: "Use async-safe scoped locking instead") - func lockIfAvailable() -> Bool { + @inlinable + func lockIfAvailable() -> Bool { __lock.withUnsafeMutablePointerToElements { os_unfair_lock_trylock($0) } } @@ -195,7 +263,8 @@ public extension ManagedUnfairLock where State == Void { /// If the lock is not acquired, nil. /// - Throws: Anything thrown by `body`. /// - func withLockIfAvailable(_ body: @Sendable () throws -> R) rethrows -> R? where R : Sendable { + @inlinable + func withLockIfAvailable(_ body: @Sendable () throws(Failure) -> R) throws(Failure) -> R? where R : Sendable { try withLockIfAvailableUnchecked(body) } @@ -211,8 +280,9 @@ public extension ManagedUnfairLock where State == Void { /// If the lock is not acquired, nil. /// - Throws: Anything thrown by `body`. /// - func withLockIfAvailableUnchecked(_ body: () throws -> R) rethrows -> R? { - try __lock.withUnsafeMutablePointerToElements{ lock in + @inlinable + func withLockIfAvailableUnchecked(_ body: () throws(Failure) -> R) throws(Failure) -> R? { + try __lock.withUnsafeMutablePointerToElements{ lock throws(Failure) in guard os_unfair_lock_trylock(lock) else { return nil } defer { os_unfair_lock_unlock(lock) } return try body() @@ -221,12 +291,37 @@ public extension ManagedUnfairLock where State == Void { } + +public extension ManagedUnfairLock { + + struct UnfairLockFlags: OptionSet, BitwiseCopyable, Hashable { + + public var rawValue: __os_unfair_lock_flags_t.RawValue + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + @available(iOS 18.0, tvOS 18.0, macOS 15.0, macCatalyst 18.0, watchOS 11.0, visionOS 2.0, *) + static var adaptiveSpin:Self { .init(rawValue: os.OSAllocatedUnfairLockFlags.adaptiveSpin.rawValue) } + + @usableFromInline + internal var unwrapped: __os_unfair_lock_flags_t { + .init(rawValue: self.rawValue) + } + } + +} + + + public extension ManagedUnfairLock { /// Initialize an SwiftUnfairLock with a lock-protected sendable /// `initialState`. /// - Parameter initialState: An initial value to store that will be /// protected under the lock. + @inlinable init(initialState: State) where State:Sendable { __lock = .create(minimumCapacity: 1) { buffer in buffer.withUnsafeMutablePointerToElements{ $0.initialize(to: .init()) } @@ -237,35 +332,40 @@ public extension ManagedUnfairLock { } @usableFromInline -internal protocol UnfairStateLock: Sendable { +package protocol UnfairStateLock: Sendable { associatedtype State - + @inlinable func withLock(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R : Sendable - + @inlinable func withLockUnchecked(_ body: (inout State) throws -> R) rethrows -> R - + @inlinable func withLockIfAvailableUnchecked(_ body: (inout State) throws -> R) rethrows -> R? - + @inlinable init(uncheckedState initialState: State) - + @inlinable func withLockIfAvailable(_ body: @Sendable (inout State) throws -> R) rethrows -> R? where R: Sendable } @usableFromInline -internal protocol UnfairLockProtocol: Sendable { +package protocol UnfairLockProtocol: Sendable { + @inlinable init() + @inlinable @_unavailableFromAsync(message: "Use async-safe scoped locking instead") func lock() + @inlinable @_unavailableFromAsync(message: "Use async-safe scoped locking instead") func unlock() + @inlinable func withLockUnchecked(_ body: () throws -> R) rethrows -> R + @inlinable func withLock(_ body: @Sendable () throws -> R) rethrows -> R where R : Sendable } @@ -277,8 +377,8 @@ extension OSAllocatedUnfairLock: UnfairLockProtocol {} extension ManagedUnfairLock: UnfairStateLock {} extension ManagedUnfairLock: UnfairLockProtocol {} -@usableFromInline -internal func createUncheckedStateLock(uncheckedState initialState:State) -> some UnfairStateLock { +@inlinable +package func createUncheckedStateLock(uncheckedState initialState:State) -> some UnfairStateLock { if #available(iOS 16.0, tvOS 16.0, macCatalyst 16.0, watchOS 9.0, macOS 13.0, *) { return OSAllocatedUnfairLock(uncheckedState: initialState) } else { @@ -286,8 +386,8 @@ internal func createUncheckedStateLock(uncheckedState initialState:State) } } -@usableFromInline -internal func createCheckedStateLock(checkedState initialState:State) -> some UnfairStateLock { +@inlinable +package func createCheckedStateLock(checkedState initialState:State) -> some UnfairStateLock { if #available(iOS 16.0, tvOS 16.0, macCatalyst 16.0, watchOS 9.0, macOS 13.0, *) { return OSAllocatedUnfairLock(initialState: initialState) } else { @@ -295,11 +395,14 @@ internal func createCheckedStateLock(checkedState initialState:S } } -@usableFromInline -internal func createUnfairLock() -> some UnfairLockProtocol { +@inlinable +package func createUnfairLock() -> some UnfairLockProtocol { if #available(iOS 16.0, tvOS 16.0, macCatalyst 16.0, watchOS 9.0, macOS 13.0, *) { return OSAllocatedUnfairLock() } else { return ManagedUnfairLock() } } + + +#endif // canImport(os) && canImport(Darwin) diff --git a/Sources/Namespace/TetraExtension.swift b/Sources/Namespace/TetraExtension.swift new file mode 100644 index 0000000..689cbd2 --- /dev/null +++ b/Sources/Namespace/TetraExtension.swift @@ -0,0 +1,35 @@ +// +// TetraExtension.swift +// +// +// Created by 박병관 on 1/1/24. +// + +import Foundation + +public struct TetraExtension { + + @usableFromInline + package var value:Base + + @inlinable + public var base:Base { + get { value } + } + + @inlinable + public init(base: Base) { + self.value = base + } + + @inlinable + public init(_ base: Base) { + self.value = base + } + + +} + + +extension TetraExtension: Sendable where Base:Sendable {} + diff --git a/Sources/NamespaceExtension/TetraExtended.swift b/Sources/NamespaceExtension/TetraExtended.swift new file mode 100644 index 0000000..7d77ebf --- /dev/null +++ b/Sources/NamespaceExtension/TetraExtended.swift @@ -0,0 +1,35 @@ +// +// TetraExtended.swift +// +// +// Created by 박병관 on 6/27/24. +// +public import Namespace + +public protocol TetraExtended { + /// Type being extended. + associatedtype Base + + /// Static Tetra extension point. + @inlinable + static var tetra: TetraExtension.Type { get } + /// Instance Tetra extension point. + @inlinable + var tetra: TetraExtension { get } +} + +extension TetraExtended where Base == Self { + + /// Static Tetra extension point. + @inlinable + public static var tetra: TetraExtension.Type { + get { TetraExtension.self } + } + + /// Instance Tetra extension point. + @inlinable + public var tetra: TetraExtension { + get { TetraExtension(self) } + } + +} diff --git a/Sources/NamespaceExtension/conformance.swift b/Sources/NamespaceExtension/conformance.swift new file mode 100644 index 0000000..dfa42d1 --- /dev/null +++ b/Sources/NamespaceExtension/conformance.swift @@ -0,0 +1,27 @@ +// +// conformance.swift +// +// +// Created by 박병관 on 6/27/24. +// +import CoreData +import Foundation +import Combine +public import Namespace + +extension NSPersistentContainer: TetraExtended {} +extension NSPersistentStoreCoordinator: TetraExtended {} +extension NSManagedObjectContext: TetraExtended {} +extension NotificationCenter: TetraExtended {} +extension DispatchSource: TetraExtended {} +extension URLSession: TetraExtended {} +extension Task: TetraExtended {} + +public extension Publisher { + + @inlinable + var tetra:TetraExtension { + .init(self) + } + +} diff --git a/Sources/Tetra/Combine/AsyncFlatMapDemandState.swift b/Sources/Tetra/Combine/AsyncFlatMapDemandState.swift new file mode 100644 index 0000000..8c71b35 --- /dev/null +++ b/Sources/Tetra/Combine/AsyncFlatMapDemandState.swift @@ -0,0 +1,111 @@ +// +// AsyncFlatMapDemandState.swift +// +// +// Created by 박병관 on 6/7/24. +// + +import Foundation +import Combine +internal import struct DequeModule.Deque + +struct AsyncFlatMapDemandState: Sendable { + + private var suspended:Deque = [] + private(set) var pending = Subscribers.Demand.none + private var isInterrupted = false + typealias Element = UnsafeContinuation + + enum Event { + + case interrupt + case resume(Subscribers.Demand) + case suspend(Element) + } + + enum Effect { + + case resume(Deque, Bool) + case raise(Deque) + + func run() { + switch self { + case .resume(let array, let demand): + array.forEach{ + $0.resume(returning: demand) + } + case .raise(let array): + array.forEach{ + $0.resume(throwing: CancellationError()) + } + } + } + } + + mutating func transition(_ event:Event) -> Effect? { + switch event { + case .interrupt: + return interrupt() + case .resume(let demand): + return resume(demand) + case .suspend(let unsafeContinuation): + return suspend(unsafeContinuation) + } + } + + private mutating func resume(_ demand:Subscribers.Demand) -> Effect? { + if isInterrupted { + if suspended.isEmpty { + return nil + } else { + let jobs = suspended + suspended = [] + return .raise(jobs) + } + } + pending += demand + return populateResume() + } + + private mutating func suspend(_ continuation: Element) -> Effect? { + if isInterrupted { + return .raise([continuation]) + } + suspended.append(continuation) + + return populateResume() + } + + + private mutating func populateResume() -> Effect? { + // unlimited + guard let max = pending.max else { + let jobs = suspended + // zero capacity storage creation + suspended = [] + return .resume(jobs, true) + } + if max == 0 || suspended.count == 0 { + return nil + } + var buffer:Deque = [] + var count = min(suspended.count, max) + pending -= count + buffer.reserveCapacity(count) + while let token = suspended.popFirst(), count > 0 { + count -= 1 + buffer.append(token) + } + return .resume(buffer, false) + + } + + private mutating func interrupt() -> Effect? { + pending = .none + isInterrupted = true + let jobs = suspended + suspended = [] + return .raise(jobs) + } + +} diff --git a/Sources/Tetra/Combine/AsyncSubscriber.swift b/Sources/Tetra/Combine/AsyncSubscriber.swift index 37905e5..ca9a596 100644 --- a/Sources/Tetra/Combine/AsyncSubscriber.swift +++ b/Sources/Tetra/Combine/AsyncSubscriber.swift @@ -6,10 +6,11 @@ // import Foundation -import Combine +@preconcurrency import Combine +internal import CriticalSection @usableFromInline -internal struct AsyncSubscriber: Subscriber, Cancellable { +internal struct AsyncSubscriber: Sendable, Subscriber, Cancellable { public typealias Input = P.Output @@ -52,7 +53,9 @@ internal struct AsyncSubscriber: Subscriber, Cancellable { } @usableFromInline - func next() async -> Result? { + func next( + isolation: isolated (any Actor)? + ) async -> Result? { return await withUnsafeContinuation { continuation in lock.withLockUnchecked{ $0.transition(.suspend(continuation)) diff --git a/Sources/Tetra/Combine/AsyncSubscriberState.swift b/Sources/Tetra/Combine/AsyncSubscriberState.swift index 39cab90..493f8d7 100644 --- a/Sources/Tetra/Combine/AsyncSubscriberState.swift +++ b/Sources/Tetra/Combine/AsyncSubscriberState.swift @@ -68,7 +68,9 @@ struct AsyncSubscriberState { case .discard: break case .resumeValue(let continuation, let input): - continuation.resume(returning: .success(input)) + nonisolated(unsafe) + let value = Result.success(consume input) + continuation.resume(returning: Suppress(value: value).value) case .request(let subscription, let demand): subscription.request(demand) case .cancelAndDiscard(let array, discard: _): diff --git a/Sources/Tetra/Combine/AsyncSubscriptionState.swift b/Sources/Tetra/Combine/AsyncSubscriptionState.swift new file mode 100644 index 0000000..ffd13da --- /dev/null +++ b/Sources/Tetra/Combine/AsyncSubscriptionState.swift @@ -0,0 +1,170 @@ +// +// AsyncSubscriptionState.swift +// +// +// Created by 박병관 on 6/7/24. +// + +import Foundation +@preconcurrency import Combine + +enum AsyncSubscriptionState { + + + case waiting + case suspending(UnsafeContinuation) + case cached(any Subscription) + case cancelled + case finished + + var subscription:(any Subscription)? { + guard case let .cached(subscription) = self else { + return nil + } + return subscription + } + + + enum Event { + + case suspend(UnsafeContinuation) + case resume(any Subscription) + case cancel + case finish + + } + + enum Effect { + + case resume(UnsafeContinuation) + case raise(UnsafeContinuation) + case cancel(any Subscription) + // ensure deinit is called outside of lock + case discard(any Subscription) + + + consuming func run() { + switch self { + case .resume(let unsafeContinuation): + unsafeContinuation.resume() + case .raise(let unsafeContinuation): + unsafeContinuation.resume(throwing: CancellationError()) + case .cancel(let subscription): + subscription.cancel() + case .discard: + break + } + } + + } + + @usableFromInline + func shouldMutate(_ event: Event) -> Bool { + switch self { + case .waiting: + return true + case .suspending(_): + if case .suspend = event { + return false + } else { + return true + } + case .cached(_): + if case .resume = event { + return false + } else { + return true + } + case .cancelled, .finished: + return false + } + } + + mutating func transition(_ event:Event) -> sending Effect? { + switch event { + case .suspend(let unsafeContinuation): + return suspend(unsafeContinuation) + case .resume(let subscription): + return resume(subscription) + case .cancel: + return onCancel() + case .finish: + return finish() + } + } + + + + private mutating func onCancel() -> Effect?{ + switch self { + case .waiting, .cancelled: + self = .cancelled + return nil + case .suspending(let unsafeContinuation): + self = .cancelled + return .raise(unsafeContinuation) + case .cached(let subscription): + self = .cancelled + return .cancel(subscription) + case .finished: + return nil + } + } + + private mutating func resume(_ subscription:any Subscription) -> Effect? { + switch self { + case .waiting: + self = .cached(subscription) + return nil + case .suspending(let unsafeContinuation): + self = .cached(subscription) + return .resume(unsafeContinuation) + case .cached: + assertionFailure("Received Subscription more than Once") + return .cancel(subscription) + case .cancelled: + fallthrough + case .finished: + return .cancel(subscription) + } + } + + private mutating func suspend(_ continuation: UnsafeContinuation) -> sending Effect? { + switch self { + case .waiting: + self = .suspending(continuation) + return nil + case .suspending(let unsafeContinuation): + self = .suspending(unsafeContinuation) + assertionFailure("Received Continuation more than Once") + return .raise(continuation) + case .cancelled: + return .raise(continuation) + case .finished: + fallthrough + case .cached: + return .resume(continuation) + } + } + + @preconcurrency + private mutating func finish() -> sending Effect? { + switch self { + case .suspending(let unsafeContinuation): + self = .finished + return .resume(unsafeContinuation) + case .cached(let subscription): + self = .finished + let what = consume subscription + return .discard(what) + case .waiting: + self = .finished + fallthrough + case .finished: + fallthrough + case .cancelled: + return nil + } + } + +} diff --git a/Sources/Tetra/Combine/Combine+Concurrency.swift b/Sources/Tetra/Combine/Combine+Concurrency.swift index 23c96ba..8042148 100644 --- a/Sources/Tetra/Combine/Combine+Concurrency.swift +++ b/Sources/Tetra/Combine/Combine+Concurrency.swift @@ -7,37 +7,16 @@ import Foundation import Combine +internal import BackPortAsyncSequence +import Namespace -public extension Publisher { - @inlinable - var tetra:TetraExtension { - .init(self) - } - -} -public extension TetraExtension where Base: Publisher, Base.Failure == Never { - - @inlinable - var values: WrappedAsyncSequence { - if #available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) { - return WrappedAsyncSequence(base: base.values) - } else { - return WrappedAsyncSequence(base: CompatAsyncPublisher(publisher: base)) - } - } - -} public extension TetraExtension where Base: Publisher { @inlinable - var values: some AsyncTypedSequence { - if #available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) { - return base.values - } else { - return CompatAsyncThrowingPublisher(publisher: base) - } + var values: CompatAsyncThrowingPublisher { + CompatAsyncThrowingPublisher(publisher: base) } } @@ -45,48 +24,50 @@ public extension TetraExtension where Base: Publisher { public extension Publisher { @inlinable - func mapTask(transform: @escaping @Sendable (Output) async -> T) -> MapTask where Output:Sendable { - MapTask(upstream: self, transform: transform) + func mapTask( + priority: TaskPriority? = nil, + transform: @escaping @isolated(any) @Sendable (Output) async -> sending T + ) -> some Publisher where Output:Sendable { + MapTask(priority: priority, upstream: self, transform: transform) } @inlinable - func tryMapTask(transform: @escaping @Sendable (Output) async throws -> T) -> TryMapTask where Output:Sendable { - TryMapTask(upstream: self, transform: transform) + func tryMapTask( + priority: TaskPriority? = nil, + transform: @escaping @isolated(any) @Sendable (Output) async throws -> sending T + ) -> some Publisher where Output:Sendable { + TryMapTask(priority: priority, upstream: self, transform: transform) } @_spi(Experimental) @inlinable - func multiMapTask(maxTasks: Subscribers.Demand = .max(1), transform: @escaping @Sendable (Output) async -> Result) -> MultiMapTask where Output: Sendable { - MultiMapTask(maxTasks: maxTasks, upstream: self, transform: transform) + func multiMapTask( + priority: TaskPriority? = nil, + maxTasks: Subscribers.Demand = .max(1), + transform: @escaping @Sendable @isolated(any) (Output) async throws(Failure) -> sending T + ) -> some Publisher where Output: Sendable { + MultiMapTask(priority: priority, maxTasks: maxTasks, upstream: self, transform: transform) } -} - -public extension Publisher { - - @available(iOS, deprecated: 15.0, renamed: "values", message: "will be removed on Swift 6") - @available(macCatalyst, deprecated: 15.0, renamed: "values", message: "will be removed on Swift 6") - @available(tvOS, deprecated: 15.0, renamed: "values", message: "will be removed on Swift 6") - @available(macOS, deprecated: 12.0, renamed: "values", message: "will be removed on Swift 6") - @available(watchOS, deprecated: 8.0, renamed: "values", message: "will be removed on Swift 6") - @inlinable - var asyncSequence: some AsyncTypedSequence { - return TetraExtension(self).values - } } -public extension Publisher where Failure == Never { - - @available(iOS, deprecated: 15.0, renamed: "values", message: "will be removed on Swift 6") - @available(macCatalyst, deprecated: 15.0, renamed: "values", message: "will be removed on Swift 6") - @available(tvOS, deprecated: 15.0, renamed: "values", message: "will be removed on Swift 6") - @available(macOS, deprecated: 12.0, renamed: "values", message: "will be removed on Swift 6") - @available(watchOS, deprecated: 8.0, renamed: "values", message: "will be removed on Swift 6") - @inlinable - var asyncSequence:WrappedAsyncSequence { - return TetraExtension(self).values + +internal extension Publisher { + + func asyncFlatMap( + maxTasks: Subscribers.Demand = .unlimited, + priority: TaskPriority? = nil, + transform: @escaping @Sendable @isolated(any) (Output) async throws(Failure) -> sending Segment + ) -> AsyncFlatMap where Output:Sendable, Segment.Err == Failure { + return AsyncFlatMap( + priority: priority, + maxTasks: maxTasks, + upstream: self, + transform: transform + ) } } + diff --git a/Sources/Tetra/Combine/CompatAsyncPublisher.swift b/Sources/Tetra/Combine/CompatAsyncPublisher.swift index 6aa4906..d9c3472 100644 --- a/Sources/Tetra/Combine/CompatAsyncPublisher.swift +++ b/Sources/Tetra/Combine/CompatAsyncPublisher.swift @@ -8,11 +8,13 @@ import Foundation @preconcurrency import Combine +public import BackPortAsyncSequence public struct CompatAsyncPublisher: AsyncSequence where P.Failure == Never { public typealias AsyncIterator = Iterator public typealias Element = P.Output + public typealias Failure = P.Failure public var publisher:P @@ -26,20 +28,30 @@ public struct CompatAsyncPublisher: AsyncSequence where P.Failure = self.publisher = publisher } - public struct Iterator: NonThrowingAsyncIteratorProtocol { + public struct Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { public typealias Element = P.Output + public typealias Failure = P.Failure @usableFromInline internal let inner = AsyncSubscriber

() @usableFromInline internal let reference:AnyCancellable + @_disfavoredOverload @inlinable - public mutating func next() async -> P.Output? { - let result = await withTaskCancellationHandler(operation: inner.next) { [reference] in + public mutating func next() async throws(Never) -> P.Output? { + await next(isolation: nil) + } + + @inlinable + public func next(isolation actor: isolated (any Actor)? = #isolation) async -> P.Output? { + let result: Result? = await withTaskCancellationHandler { [inner] in + await inner.next(isolation: actor) + } onCancel: { [reference] in reference.cancel() } + switch result { case .none: return nil diff --git a/Sources/Tetra/Combine/CompatAsyncThrowingPublisher.swift b/Sources/Tetra/Combine/CompatAsyncThrowingPublisher.swift index 1bef09b..cbe037c 100644 --- a/Sources/Tetra/Combine/CompatAsyncThrowingPublisher.swift +++ b/Sources/Tetra/Combine/CompatAsyncThrowingPublisher.swift @@ -8,11 +8,12 @@ import Foundation @preconcurrency import Combine +public import BackPortAsyncSequence -public struct CompatAsyncThrowingPublisher: AsyncTypedSequence { +public struct CompatAsyncThrowingPublisher: AsyncSequence, TypedAsyncSequence { public typealias AsyncIterator = Iterator - public typealias Element = P.Output + public typealias Failure = P.Failure public var publisher:P @@ -21,19 +22,23 @@ public struct CompatAsyncThrowingPublisher: AsyncTypedSequence { Iterator(source: publisher) } - public struct Iterator: AsyncIteratorProtocol { + public struct Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { public typealias Element = P.Output + public typealias Failure = P.Failure @usableFromInline internal let inner = AsyncSubscriber

() @usableFromInline internal let reference:AnyCancellable @inlinable - public mutating func next() async throws -> P.Output? { - let result = await withTaskCancellationHandler(operation: inner.next) { [reference] in + public mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(P.Failure) -> P.Output? { + let result = await withTaskCancellationHandler { [inner] in + await inner.next(isolation: actor) + } onCancel: { [reference] in reference.cancel() } + switch result { case .failure(let failure): throw failure @@ -44,6 +49,12 @@ public struct CompatAsyncThrowingPublisher: AsyncTypedSequence { } } + @_disfavoredOverload + @inlinable + public mutating func next() async throws(P.Failure) -> P.Output? { + try await next(isolation: nil) + } + @usableFromInline internal init(source: P) { self.reference = AnyCancellable(inner) diff --git a/Sources/Tetra/Combine/DispatchTimePublisher.swift b/Sources/Tetra/Combine/DispatchTimePublisher.swift index f39b307..a5c0fed 100644 --- a/Sources/Tetra/Combine/DispatchTimePublisher.swift +++ b/Sources/Tetra/Combine/DispatchTimePublisher.swift @@ -8,8 +8,9 @@ import Foundation import Dispatch import Combine +internal import CriticalSection +import Namespace -extension DispatchSource: TetraExtended {} public extension TetraExtension where Base:DispatchSource { diff --git a/Sources/Tetra/Combine/ExperimentalMapTask.swift b/Sources/Tetra/Combine/ExperimentalMapTask.swift index a96d0a5..3d46296 100644 --- a/Sources/Tetra/Combine/ExperimentalMapTask.swift +++ b/Sources/Tetra/Combine/ExperimentalMapTask.swift @@ -7,9 +7,8 @@ import Foundation @preconcurrency import Combine - - - +internal import CriticalSection +internal import BackportDiscardingTaskGroup /** Manage Multiple Child Task. provides similair behavior of `flatMap`'s `maxPublisher` @@ -17,32 +16,60 @@ import Foundation precondition `maxTasks` must be none zero value */ @_spi(Experimental) -public struct MultiMapTask: Publisher where Upstream.Output:Sendable { +public struct MultiMapTask: Publisher where Upstream.Output:Sendable { public typealias Output = Output public typealias Failure = Upstream.Failure + public typealias Transformer = @Sendable @isolated(any) (Upstream.Output) async throws(Failure) -> sending Output - public let maxTasks:Subscribers.Demand + public var priority:TaskPriority? = nil + public var maxTasks:Subscribers.Demand public let upstream:Upstream - public let transform:@Sendable (Upstream.Output) async -> Result + public let transform: Transformer + public let taskExecutor: (any Executor)? public func receive(subscriber: S) where S : Subscriber, Upstream.Failure == S.Failure, Output == S.Input { let processor = Inner(maxTasks: maxTasks, subscriber: subscriber, transform: transform) - let task = Task(operation: processor.run) + let task = if #available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, *) { + Task.immediate(priority: priority, executorPreference: taskExecutor as? (any TaskExecutor), operation: processor.run) + } else if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *), let executor = taskExecutor as? (any TaskExecutor) { + Task(executorPreference: executor, priority: priority, operation: processor.run) + } else { + Task(priority: priority, operation: processor.run) + } processor.resumeCondition(task) upstream.subscribe(processor) } public init( + priority: TaskPriority? = nil, + maxTasks: Subscribers.Demand = .max(1), + upstream: Upstream, + transform: @escaping Transformer + ) { + precondition(maxTasks != .none, "maxTasks can not be zero") + self.maxTasks = maxTasks + self.upstream = upstream + self.transform = transform + self.taskExecutor = nil + self.priority = priority + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + public init( + priority:TaskPriority? = nil, maxTasks: Subscribers.Demand = .max(1), + executor:(any TaskExecutor)? = nil, upstream: Upstream, - transform: @Sendable @escaping (Upstream.Output) async -> Result + transform: @escaping Transformer ) { precondition(maxTasks != .none, "maxTasks can not be zero") self.maxTasks = maxTasks self.upstream = upstream self.transform = transform + self.taskExecutor = executor + self.priority = priority } } @@ -54,21 +81,26 @@ extension MultiMapTask { struct TaskState where S.Failure == Failure, S.Input == Output { var demand = PendingDemandState() var subscriber:S? = nil - var upstreamSubscription = SubscriptionContinuation.waiting + var upstreamSubscription = AsyncSubscriptionState.waiting var condition = TaskValueContinuation.waiting } + // this can run serially or in parallel by using demand config, so use adding actor isolation seems quite odd here struct Inner: CustomCombineIdentifierConvertible where S.Failure == Failure, S.Input == Output { private let maxTasks:Subscribers.Demand private let valueSource = AsyncStream>.makeStream() - private let demandSource = AsyncStream.makeStream() + // accessed from Combine intferface or isolated Actor + // which ever guarantee serialized access private let state: some UnfairStateLock> = createUncheckedStateLock(uncheckedState: TaskState()) - private let transform:@Sendable (Upstream.Output) async -> Result - + private let transform:Transformer let combineIdentifier = CombineIdentifier() - init(maxTasks:Subscribers.Demand, subscriber:S, transform: @escaping @Sendable (Upstream.Output) async -> Result) { + init( + maxTasks:Subscribers.Demand, + subscriber:S, + transform: @escaping Transformer + ) { self.maxTasks = maxTasks self.transform = transform state.withLockUnchecked{ @@ -76,38 +108,46 @@ extension MultiMapTask { } } - private func localTask( - subscription: any Subscription, - group: inout some CompatThrowingDiscardingTaskGroup +// private func prepareTermination(cancel:Bool) { +// let effect = state.withLockUnchecked { +// let effect1 = $0.condition.transition(cancel ? .cancel : .finish) +// let effect2 = $0.upstreamSubscription.transition(cancel ? .cancel : .finish) +// return (effect1, effect2) +// } +// effect.0?.run() +// effect.1?.run() +// } + + internal func localTask( + isolation actor: isolated (any Actor)? = #isolation, + group: inout some CompatDiscardingTaskGroup ) async { - group.addTask(priority: nil) { - for await demand in demandSource.stream { - let nextDemand = receive(demand: demand) - if nextDemand > .none { - subscription.request(nextDemand) - } + let barrier = actor as? SafetyRegion ?? SafetyRegion() + + for await event in valueSource.stream { + if await barrier.isFinished { + break } - } - var iterator = valueSource.stream.makeAsyncIterator() - while let upstreamValue = await iterator.next() { - switch upstreamValue { + switch event { case .failure(let failure): - send(completion: .failure(failure), cancel: false) + await barrier.markDone() + // no contention except `request` and `cancel` + await send(barrier: barrier, completion: .failure(failure), cancel: false) break case .success(let success): let flag = group.addTaskUnlessCancelled(priority: nil) { - switch await transform(success) { - case .failure(let failure): - send(completion: .failure(failure), cancel: true) - throw CancellationError() - case .success(let value): - if let demand = send(value) { - if demand > .none { - subscription.request(demand) - } - } else { - throw CancellationError() + do throws(Failure) { + let value = try await transform(success) + do { + // no contention except `request` and `cancel` + try await send(isolation: barrier, value) + } catch { + await barrier.markDone() } + } catch { + await barrier.markDone() + // no contention except `request` and `cancel` + await send(barrier: barrier, completion: .failure(error), cancel: true) } } if !flag { @@ -118,54 +158,65 @@ extension MultiMapTask { } private func terminateStream() { - demandSource.continuation.finish() valueSource.continuation.finish() } - private func send(completion: Subscribers.Completion?, cancel:Bool = false) { + private func send( + barrier: isolated (some Actor)? = #isolation, + completion: Subscribers.Completion, + cancel:Bool = false + ) { terminateStream() - let (subscriber, effect) = state.withLockUnchecked{ + let (subscriber, taskEffect) = state.withLockUnchecked{ let old = $0.subscriber $0.subscriber = nil - let effect = if cancel { + let taskEffect = if cancel { $0.condition.transition(.cancel) } else { $0.condition.transition(.finish) } - return (old, effect) - } - effect?.run() - if let completion { - subscriber?.receive(completion: completion) + return (old, taskEffect) } + (consume subscriber)?.receive(completion: completion) + (consume taskEffect)?.run() } - private func send(_ value: S.Input) -> Subscribers.Demand? { - let newDemand = state.withLockUnchecked{ - $0.subscriber - }?.receive(value) - guard let newDemand else { return nil } + + private func send( + isolation actor: isolated some Actor, + _ value: S.Input + ) async throws(CancellationError) { - if maxTasks == .unlimited { - return newDemand + let (subscriber, subscription) = state.withLockUnchecked{ + return ($0.subscriber, $0.upstreamSubscription.subscription) } - return state.withLock{ - $0.demand.transistion(maxTasks: maxTasks, newDemand, reduce: true) + guard let subscriber else { + throw CancellationError() } - } - - private func receive(demand:Subscribers.Demand) -> Subscribers.Demand { - if maxTasks == .unlimited { - return demand + // subscriber might call extra `request` or `cancel` but as we don't acquire the lock, it is safe to do so. + var demand = subscriber.receive(value) + // subscription can be null, if upstream is already completed + guard let subscription else { + return } - return state.withLock{ - $0.demand.transistion(maxTasks: maxTasks, demand, reduce: false) + defer { + if demand > .none { + subscription.request(demand) + } + } + guard maxTasks != .unlimited else { + return + } + // yield so that other task can access to subscriber for a while. + await Task.yield() + demand = state.withLockUnchecked{ + $0.demand.transistion(maxTasks: maxTasks, demand, reduce: true) } } - private func waitForUpStream() async -> (any Subscription)? { - await withTaskCancellationHandler { - await withUnsafeContinuation { coninuation in + private func waitForUpStream( isolation actor:isolated (any Actor)? = #isolation) async throws { + try await withTaskCancellationHandler { + try await withUnsafeThrowingContinuation { coninuation in state.withLockUnchecked{ $0.upstreamSubscription.transition(.suspend(coninuation)) }?.run() @@ -183,7 +234,7 @@ extension MultiMapTask { }?.run() } - private func waitForCondition() async throws { + private func waitForCondition( isolation actor:isolated (any Actor)? = #isolation) async throws { try await withUnsafeThrowingContinuation{ continuation in state.withLock{ $0.condition.transition(.suspend(continuation)) @@ -199,6 +250,7 @@ extension MultiMapTask { @Sendable nonisolated func run() async { + // contention can happen with `resumeCondition(_ :) ` let token:Void? = try? await waitForCondition() if token == nil { withUnsafeCurrentTask{ @@ -208,49 +260,40 @@ extension MultiMapTask { defer { clearCondition() } - let subscription = await waitForUpStream() - state.withLockUnchecked{ + // contention can happen with `receive(subscription:) + let success:Void? = try? await waitForUpStream() + async let job:Void? = state.withLockUnchecked{ $0.subscriber }?.receive(subscription: self) - guard let subscription else { + await job + guard success != nil else { terminateStream() return } - await withTaskCancellationHandler { - if #available(iOS 17.0, tvOS 17.0, macCatalyst 17.0, macOS 14.0, watchOS 10.0, visionOS 1.0, *) { - try? await withThrowingDiscardingTaskGroup(returning: Void.self) { group in - defer { terminateStream() } - await localTask( - subscription: subscription, - group: &group - ) - } - } else { - try? await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in - defer { terminateStream() } - var iterator = group.makeAsyncIterator() - let stream = AsyncThrowingStream(unfolding: { try await iterator.next() }) - async let subTask:() = { - for try await _ in stream { - - } - }() - await localTask( - subscription: subscription, - group: &group - ) - try await subTask - } + if #available(iOS 17.0, tvOS 17.0, macCatalyst 17.0, macOS 14.0, watchOS 10.0, visionOS 1.0, *) { + await withDiscardingTaskGroup(returning: Void.self) { group in + await localTask( + group: &group + ) } - send(completion: .finished) - } onCancel: { - subscription.cancel() - send(completion: nil, cancel: false) + } else { + let barrier = SafetyRegion() + await { (a:isolated SafetyRegion) in + await simuateDiscardingTaskGroup(isolation: a) { actor, group in + await localTask(isolation: actor, group: &group) + } + return () + }(barrier) } + // we assume no one is accessing other state + // except `Subscription.cancel()` + await send(barrier: SafetyRegion?.none, completion: .finished, cancel: false) + } } + } @@ -262,13 +305,17 @@ extension MultiMapTask.Inner: Subscriber { } func receive(completion: Subscribers.Completion) { + // release upstream subscription + state.withLockUnchecked{ + $0.upstreamSubscription.transition(.finish) + }?.run() switch completion { case .finished: break case .failure(let failure): valueSource.continuation.yield(.failure(failure)) } - valueSource.continuation.finish() + terminateStream() } typealias Input = Upstream.Output @@ -286,13 +333,33 @@ extension MultiMapTask.Inner: Subscriber { extension MultiMapTask.Inner: Subscription { func request(_ demand: Subscribers.Demand) { - demandSource.continuation.yield(demand) + let (subscription, nextDemand) = state.withLock{ + let subscription = $0.upstreamSubscription.subscription + let newDemand = if subscription == nil { + Subscribers.Demand.none + } else { + $0.demand.transistion(maxTasks: maxTasks, demand, reduce: false) + } + return (subscription, newDemand) + } + if let subscription, nextDemand > .none { + subscription.request(nextDemand) + } } func cancel() { - state.withLock{ - $0.condition.transition(.cancel) - }?.run() + terminateStream() + let (task, subscription, subscriber) = state.withLockUnchecked{ + let taskEffect = $0.condition.transition(.cancel) + let subscriptionEffect = $0.upstreamSubscription.transition(.cancel) + let downstream = $0.subscriber + $0.subscriber = nil + return (taskEffect, subscriptionEffect, downstream) + } + withExtendedLifetime(subscriber) { + subscription?.run() + task?.run() + } } } diff --git a/Sources/Tetra/Combine/Future+Concurrency.swift b/Sources/Tetra/Combine/Future+Concurrency.swift index 88394ea..a4801dc 100644 --- a/Sources/Tetra/Combine/Future+Concurrency.swift +++ b/Sources/Tetra/Combine/Future+Concurrency.swift @@ -7,75 +7,60 @@ import Foundation import Combine +import Namespace -public extension Combine.Future where Failure == Never { +extension TetraExtension where Base: _CombineFuterProtocol { - @available(iOS, deprecated: 15.0, renamed: "value") - @available(iOS, deprecated: 15.0, renamed: "value") - @available(iOS, deprecated: 15.0, renamed: "value") - @available(watchOS, deprecated: 8, renamed: "value") - @available(macOS, deprecated: 12.0, renamed: "value") @inlinable - final var compatValue: Output { - get async { - if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - return await value - } else { - return await withCheckedContinuation{ continuation in - self.subscribe(AnySubscriber( - receiveSubscription: { - $0.request(.max(1)) - }, - receiveValue: { - continuation.resume(returning: $0) - return .none + public var value:Base.Output { + get async throws(Base.Failure) { + let future = base._tetraFuture + // subscriber inferface is guaranteed to be called serially, so we can use variable without lock happily :) + var subscription: (any Subscription)? = nil + defer { + withExtendedLifetime(subscription, { }) + } + let result: Result = await withCheckedContinuation { continuation in + future.subscribe(AnySubscriber( + receiveSubscription: { + $0.request(.max(1)) + subscription = $0 + }, + receiveValue: { (value: Base.Output) in + let variable = Suppress(value: value) + continuation.resume(returning: .success(variable.value)) + return .none + }, + receiveCompletion: { + if case let .failure(error) = $0 { + continuation.resume(returning: .failure(error)) } - )) - } + subscription = nil + } + )) + } + switch result { + case .success(let success): + return success + case .failure(let failure): + throw failure } + } } } +public protocol _CombineFuterProtocol: Publisher { + + @inlinable + var _tetraFuture: Combine.Future { get } + +} -public extension Combine.Future { +extension Combine.Future: _CombineFuterProtocol { - @available(iOS, deprecated: 15.0, renamed: "value") - @available(iOS, deprecated: 15.0, renamed: "value") - @available(iOS, deprecated: 15.0, renamed: "value") - @available(watchOS, deprecated: 8, renamed: "value") - @available(macOS, deprecated: 12.0, renamed: "value") @inlinable - final var compatValue: Output { - get async throws { - if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - return try await value - } else { - let result: Result = await withCheckedContinuation { continuation in - self.subscribe(AnySubscriber( - receiveSubscription: { - $0.request(.max(1)) - }, - receiveValue: { - continuation.resume(returning: .success($0)) - return .none - }, - receiveCompletion: { - if case let .failure(error) = $0 { - continuation.resume(returning: .failure(error)) - } - } - )) - } - switch result { - case .success(let success): - return success - case .failure(let failure): - throw failure - } - } - } - } + public var _tetraFuture: Future { self } } diff --git a/Sources/Tetra/Combine/MapTaskInner.swift b/Sources/Tetra/Combine/MapTaskInner.swift new file mode 100644 index 0000000..05a3887 --- /dev/null +++ b/Sources/Tetra/Combine/MapTaskInner.swift @@ -0,0 +1,44 @@ +// +// MapTaskInner.swift +// +// +// Created by 박병관 on 6/28/24. +// +import Combine + +struct MapTaskInner: Subscription, Subscriber, CustomStringConvertible, CustomPlaygroundDisplayConvertible { + + var description: String + + var playgroundDescription: Any { description } + + var downstream:S + var upstream:Subscription? = nil + var combineIdentifier: CombineIdentifier { downstream.combineIdentifier } + + func receive(_ input: S.Input) -> Subscribers.Demand { + downstream.receive(input) + } + + func receive(subscription: any Subscription) { + let newSubscription = Self( + description: description, + downstream: downstream, + upstream: subscription + ) + downstream.receive(subscription: newSubscription) + } + + func receive(completion: Subscribers.Completion) { + downstream.receive(completion: completion) + } + + func request(_ demand: Subscribers.Demand) { + upstream?.request(demand) + } + + func cancel() { + upstream?.cancel() + } + +} diff --git a/Sources/Tetra/Combine/PendingDemandState.swift b/Sources/Tetra/Combine/PendingDemandState.swift index cc63bf9..b78a41e 100644 --- a/Sources/Tetra/Combine/PendingDemandState.swift +++ b/Sources/Tetra/Combine/PendingDemandState.swift @@ -8,7 +8,8 @@ import Foundation import Combine -struct PendingDemandState { +struct PendingDemandState{ + private var taskCount:Int private var pendingDemand:Subscribers.Demand @@ -51,7 +52,5 @@ struct PendingDemandState { return snapShot } } - - } diff --git a/Sources/Tetra/Combine/Publishers+AsyncFlatMap.swift b/Sources/Tetra/Combine/Publishers+AsyncFlatMap.swift new file mode 100644 index 0000000..39a9af7 --- /dev/null +++ b/Sources/Tetra/Combine/Publishers+AsyncFlatMap.swift @@ -0,0 +1,443 @@ +// +// File.swift +// +// +// Created by 박병관 on 6/7/24. +// + +import Foundation +@preconcurrency import Combine +internal import BackPortAsyncSequence +internal import CriticalSection +internal import BackportDiscardingTaskGroup + +struct AsyncFlatMap: Publisher where Upstream.Output:Sendable, Segment.AsyncIterator.Err == Upstream.Failure, Segment.AsyncIterator: TypedAsyncIteratorProtocol { + + typealias Output = Segment.Element + typealias Failure = Upstream.Failure + typealias Transform = @Sendable @isolated(any) (Upstream.Output) async throws(Failure) -> sending Segment + var priority: TaskPriority? = nil + let taskExecutor: (any Executor)? + var maxTasks:Subscribers.Demand + let upstream:Upstream + let transform:Transform + + func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Segment.Element == S.Input { + let processor = Inner(maxTasks: maxTasks, subscriber: subscriber, transform: transform) + let task = if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *), let executor = taskExecutor as? (any TaskExecutor) { + Task(executorPreference: executor, priority: priority, operation: processor.run) + } else { + Task(priority: priority, operation: processor.run) + } + processor.resumeCondition(task) + upstream.subscribe(processor) + } + + @usableFromInline + init( + priority: TaskPriority? = nil, + maxTasks: Subscribers.Demand, + upstream: Upstream, + transform: @escaping Transform + ) { + self.priority = priority + self.maxTasks = maxTasks + self.upstream = upstream + self.transform = transform + self.taskExecutor = nil + } + + @usableFromInline + init( + priority: TaskPriority? = nil, + maxTasks: Subscribers.Demand, + upstream: Upstream, + transform: @escaping @Sendable (Upstream.Output) async throws(Failure) -> Source + ) where Source: AsyncSequence, Segment == LegacyTypedAsyncSequence, Failure == any Error { + self.priority = priority + self.maxTasks = maxTasks + self.upstream = upstream + self.transform = { (value) throws(Failure) in + try await .init(base: transform(value)) + } + self.taskExecutor = nil + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @usableFromInline + init( + priority: TaskPriority? = nil, + taskExecutor: (any TaskExecutor)? = nil, + maxTasks: Subscribers.Demand, + upstream: Upstream, + typedTransform: @escaping @Sendable (Upstream.Output) async throws(Failure) -> Source + ) where Source: AsyncSequence, Segment == WrappedAsyncSequence { + self.priority = priority + self.maxTasks = maxTasks + self.upstream = upstream + self.transform = { (value) throws(Failure) in + try await .init(base: typedTransform(value)) + } + self.taskExecutor = taskExecutor + } + + +} + +extension AsyncFlatMap { + + struct Inner: Subscriber, Sendable, Subscription, CustomStringConvertible, CustomPlaygroundDisplayConvertible + where Segment.Element == Down.Input, Down.Failure == AsyncFlatMap.Failure { + + typealias Transformer = @isolated(any) @Sendable (Upstream.Output) async throws(Failure) -> sending Segment + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + let lock:some UnfairStateLock = createUncheckedStateLock(uncheckedState: TaskState()) + + struct TaskState { + var demandState = AsyncFlatMapDemandState() + var subscriber:Down? = nil + var upstreamSubscription = AsyncSubscriptionState.waiting + var taskCondition = TaskValueContinuation.waiting + } + + let maxTasks:Subscribers.Demand + let transform:Transformer + let valueSource = AsyncStream>.makeStream() + let combineIdentifier = CombineIdentifier() + + + init(maxTasks:Subscribers.Demand ,subscriber: Down,transform: @escaping Transformer) { + self.transform = transform + self.maxTasks = maxTasks + lock.withLockUnchecked{ + $0.subscriber = subscriber + } + } + + @Sendable + func run() async { + let token:Void? = try? await waitForCondition() + if token == nil { + withUnsafeCurrentTask{ + $0?.cancel() + } + } + defer { + clearCondition() + } + let success:Void? = try? await waitForUpStream() + async let job:Void? = lock.withLockUnchecked{ + $0.subscriber + }?.receive(subscription: self) + await job + guard success != nil else { + terminateStream() + return + } + if #available(iOS 17.0, tvOS 17.0, macCatalyst 17.0, macOS 14.0, watchOS 10.0, visionOS 1.0, *) { + try? await withThrowingDiscardingTaskGroup { group in + defer { terminateStream() } + await localTask( + group: &group + ) + } + } else { + let barrier:SafetyRegion = .init() + await { (isolation: isolated SafetyRegion) in + try? await simuateThrowingDiscardingTaskGroup(isolation: isolation) { + defer { terminateStream() } + await localTask(isolation: $0, group: &$1) + } + return () + }(barrier) + } + send(completion: .finished, shouldCancel: false) + + } + + var playgroundDescription: Any { description } + + var description: String { "AsyncFlatMap" } + + + func receive(_ input: Input) -> Subscribers.Demand { + valueSource.continuation.yield(.success(input)) + return .none + } + + func receive(completion: Subscribers.Completion) { + lock.withLockUnchecked{ + $0.upstreamSubscription.transition(.finish) + }?.run() + switch completion { + case .finished: + break + case .failure(let failure): + valueSource.continuation.yield(.failure(failure)) + } + valueSource.continuation.finish() + } + + func receive(subscription: any Subscription) { + let (effect, requestValue) = lock.withLockUnchecked{ + let requestValue:Bool + switch $0.upstreamSubscription { + case .waiting, .suspending: + requestValue = true + default: + requestValue = false + } + let effect = $0.upstreamSubscription.transition(.resume(subscription)) + return (effect, requestValue) + } + (consume effect)?.run() + if requestValue && maxTasks > .none { + subscription.request(maxTasks) + } + } + + func request(_ demand: Subscribers.Demand) { + lock.withLockUnchecked{ + $0.demandState.transition(.resume(demand)) + }?.run() + } + + func cancel() { + send(completion: nil, shouldCancel: true) + } + + // almost uncontented call + private func handleDownStream( + isolation actor: isolated some Actor, + event: Result?, Failure> + ) async { + switch event { + case .success(.none): + //finished + // check and request more transformer + // request one more from upstream subscription + if maxTasks != .unlimited { + let (subscription, effect) = lock.withLockUnchecked{ + let effect = if $0.demandState.pending == .unlimited { + $0.demandState.transition(.resume(.none)) + } else { + // reclaim discarded demand + $0.demandState.transition(.resume(.max(1))) + } + let subscription = $0.upstreamSubscription.subscription + return (subscription, effect) + } + if let subscription { + subscription.request(.max(1)) + } + effect?.run() + } + case .success(let success?): + send( + isolation: actor, + success.value + ) + return + case .failure(let failure): + send(completion: .failure(failure), shouldCancel: true) + return + } + return + } + + // almost uncontented call + private func send( + completion: Subscribers.Completion?, + shouldCancel:Bool + ) { + valueSource.continuation.finish() + let (subscriber, effect, interruption, taskEffect) = lock.withLockUnchecked{ + let old = $0.subscriber + $0.subscriber = nil + let effect = if shouldCancel { + $0.upstreamSubscription.transition(.cancel) + } else { + $0.upstreamSubscription.transition(.finish) + } + let interruption = $0.demandState.transition(.interrupt) + let taskEffect = if shouldCancel { + $0.taskCondition.transition(.cancel) + } else { + $0.taskCondition.transition(.finish) + } + return (old, effect, interruption, taskEffect) + } + // ensure compiler it is good to destroy the objects + (consume effect)?.run() + (consume interruption)?.run() + (consume taskEffect)?.run() + if let completion { + (consume subscriber)?.receive(completion: completion) + } + } + + // almost uncontented call + private func send( + isolation actor: isolated some Actor, + _ value:Down.Input + ) { + // use lock but this is isolated so we expect uncontended + let subscriber = lock.withLockUnchecked { + $0.subscriber + } + guard let newDemand = subscriber?.receive(value) else { + return + } + lock.withLockUnchecked{ + $0.demandState.transition(.resume(newDemand)) + }?.run() + } + + // contention case + private func waitForUpStream( isolation actor:isolated (any Actor)? = #isolation) async throws { + try await withTaskCancellationHandler { + try await withUnsafeThrowingContinuation { coninuation in + lock.withLockUnchecked{ + $0.upstreamSubscription.transition(.suspend(coninuation)) + }?.run() + } + } onCancel: { + lock.withLockUnchecked{ + $0.upstreamSubscription.transition(.cancel) + }?.run() + } + } + + // contention case + func resumeCondition(_ task:Task) { + lock.withLock{ + $0.taskCondition.transition(.resume(task)) + }?.run() + } + + // contention case + private func waitForCondition( isolation actor:isolated (any Actor)? = #isolation) async throws { + try await withUnsafeThrowingContinuation{ continuation in + lock.withLock{ + $0.taskCondition.transition(.suspend(continuation)) + }?.run() + } + } + // almost uncontented call + private func clearCondition() { + lock.withLock{ + $0.taskCondition.transition(.finish) + }?.run() + } + + private func terminateStream() { + valueSource.continuation.finish() + } + + private func makeSegment(_ input:Upstream.Output) async -> sending Result { + let result:Result + do { + let seg = try await transform(input) + result = .success(seg) + } catch { + result = .failure(error ) + } + return result + } + + // almost uncontented call + /// whether demand is unlimited + /// - Returns: `true` if demand is unlimited, `false` if demand is just `1`. + /// - throws: `CancellationError` if internal state reached cancellation + private func nextDemand( + barrier:isolated some Actor + ) async throws -> Bool { + try await withUnsafeThrowingContinuation { continuation in + lock.withLockUnchecked{ + $0.demandState.transition(.suspend(continuation)) + }?.run() + } + } + + + /// process next segment and send event to downstream + /// - Returns: `false` if iterator reached termination otherwise `true` + private func processNextSegment( + iterator: inout Segment.AsyncIterator, + barrier: some Actor + ) async -> Bool { +// let result:Result? + do throws(Failure) { +// let value = try await iterator.next(isolation: nil) + + if let value = try await iterator.next(isolation: nil) { + await handleDownStream(isolation: barrier, event: .success(.init(value: value))) + return true + } else { + await handleDownStream(isolation: barrier, event: .success(.none)) + return false + } + } catch { + await handleDownStream(isolation: barrier, event: .failure(error)) + return false + } + } + + private func localTask( + isolation actor: isolated (any Actor)? = #isolation, + group: inout some CompatDiscardingTaskGroup + ) async { + let barrier = actor as? SafetyRegion ?? .init() + for await result in valueSource.stream { + if await barrier.isFinished { + break + } + switch result { + case .failure(let failure): + let block = { (actor: isolated (any Actor)?) in + send(completion: .failure(failure), shouldCancel: false) + } + await block(barrier) + return + case .success(let value): + let isSuccess = group.addTaskUnlessCancelled(priority: nil) { + let segment:Segment + do throws(Failure) { + segment = try await transform(value) + } catch { + await barrier.markDone() + await handleDownStream( + isolation: barrier, + event: .failure(error) + ) + return + } + var iterator = segment.makeAsyncIterator() + while true { + let isUnlimited = try await nextDemand(barrier: barrier) + if isUnlimited { + while await processNextSegment(iterator: &iterator, barrier: barrier) { + + } + return + } else { + let hasNext = await processNextSegment(iterator: &iterator, barrier: barrier) + if !hasNext { + return + } + } + } + } + if !isSuccess { + return + } + + } + } + } + + } + +} diff --git a/Sources/Tetra/Combine/Publishers+MapTask.swift b/Sources/Tetra/Combine/Publishers+MapTask.swift index 0aa82c9..ddaf701 100644 --- a/Sources/Tetra/Combine/Publishers+MapTask.swift +++ b/Sources/Tetra/Combine/Publishers+MapTask.swift @@ -7,6 +7,7 @@ import Foundation @preconcurrency import Combine +internal import CriticalSection /** @@ -33,246 +34,54 @@ import Foundation ``` */ -public struct MapTask: Publisher where Upstream.Output:Sendable { +public struct MapTask: Publisher where Upstream.Output:Sendable { public typealias Output = Output public typealias Failure = Upstream.Failure - + public typealias Transform = @Sendable @isolated(any) (Upstream.Output) async -> sending Result + public var priority: TaskPriority? = nil public let upstream:Upstream - public var transform:@Sendable (Upstream.Output) async -> Result - - public init(upstream: Upstream, transform: @escaping @Sendable (Upstream.Output) async -> Output) { + public var transform:Transform + + public init( + priority: TaskPriority? = nil, + upstream: Upstream, + transform: @escaping @Sendable @isolated(any) (Upstream.Output) async -> sending Output + ) { + self.priority = priority self.upstream = upstream self.transform = { Result.success(await transform($0)) } } - public init(upstream: Upstream, handler: @escaping @Sendable (Upstream.Output) async -> Result) { + public init( + priority: TaskPriority? = nil, + upstream: Upstream, + handler: @escaping Transform + ) { + self.priority = priority self.upstream = upstream self.transform = handler } public func receive(subscriber: S) where S : Subscriber, Upstream.Failure == S.Failure, Output == S.Input { - let processor = Inner(subscriber: subscriber, transform: transform) - let task = Task(operation: processor.run) - processor.resumeCondition(task) - upstream.subscribe(processor) - } - -} - -extension MapTask: Sendable where Upstream: Sendable {} - -extension MapTask { - - - struct TaskState where S.Failure == Failure, S.Input == Output { - - var subscriber:S? = nil - var upstreamSubscription = SubscriptionContinuation.waiting - var condition = TaskValueContinuation.waiting - } - - struct Inner: CustomCombineIdentifierConvertible, Sendable where S.Failure == Failure, S.Input == Output { - - private let valueSource = AsyncStream>.makeStream(bufferingPolicy: .bufferingNewest(2)) - private let demandSource = AsyncStream.makeStream() - private let state: some UnfairStateLock> = createUncheckedStateLock(uncheckedState: .init()) - private let transform:@Sendable (Upstream.Output) async -> Result - let combineIdentifier = CombineIdentifier() - - init( - subscriber:S, - transform: @Sendable @escaping (Upstream.Output) async -> Result - ) { - self.transform = transform - state.withLockUnchecked{ $0.subscriber = subscriber } - } - - private func send(completion: Subscribers.Completion?, cancel:Bool = false) { - terminateStream() - let (subscriber, effect) = state.withLockUnchecked{ - let old = $0.subscriber - $0.subscriber = nil - let effect = if cancel { - $0.condition.transition(.cancel) - } else { - $0.condition.transition(.finish) - } - return (old, effect) - } - effect?.run() - if let completion { - subscriber?.receive(completion: completion) - } - } - - private func send(_ value:Output) -> Subscribers.Demand? { - state.withLockUnchecked{ - $0.subscriber - }?.receive(value) - } - - private func terminateStream() { - demandSource.continuation.finish() - valueSource.continuation.finish() - } - - private func waitForUpStream() async -> (any Subscription)? { - await withTaskCancellationHandler { - await withUnsafeContinuation { coninuation in - state.withLock{ - $0.upstreamSubscription.transition(.suspend(coninuation)) - }?.run() - } - } onCancel: { [state] in - state.withLockUnchecked{ - $0.upstreamSubscription.transition(.cancel) - }?.run() - } - } - - func resumeCondition(_ task:Task) { - state.withLock{ - $0.condition.transition(.resume(task)) - }?.run() - } - - private func waitForCondition() async throws { - try await withUnsafeThrowingContinuation{ continuation in - state.withLock{ - $0.condition.transition(.suspend(continuation)) - }?.run() - } - } - - private func clearCondition() { - state.withLock{ - $0.condition.transition(.finish) - }?.run() - } - - @Sendable - nonisolated func run() async { - let token:Void? = try? await waitForCondition() - if token == nil { - withUnsafeCurrentTask{ - $0?.cancel() - } - } - defer { - clearCondition() - } - let subscription = await waitForUpStream() - defer { terminateStream() } - state.withLockUnchecked{ - $0.subscriber - }?.receive(subscription: self) - guard let subscription else { - return - } - await withTaskCancellationHandler { - var iterator = valueSource.stream.makeAsyncIterator() - for await var demand in demandSource.stream { - while demand > .none { - demand -= 1 - subscription.request(.max(1)) - let upstreamResult = await iterator.next() - let upstreamValue:Upstream.Output - switch upstreamResult { - case .failure(let error): - send(completion: .failure(error), cancel: false) - return - case .none: - send(completion: .finished, cancel: false) - return - case .success(let value): - upstreamValue = value - } - // enqueue to separate task to prevent transformer cancelling root task using `UnsafeCurrentTask` - async let job = transform(upstreamValue) - switch (await job) { - case .success(let value): - if let newDemand = send(value) { - demand += newDemand - } else { - return - } - case .failure(let error): - send(completion: .failure(error), cancel: true) - return - } - } - - } - } onCancel: { - subscription.cancel() - send(completion: nil) - } - - } +// let processor = Inner(subscriber: subscriber, transform: transform) +// let task = Task(priority: priority, operation: processor.run) +// processor.resumeCondition(task) +// upstream.subscribe(processor) + MultiMapTask( + priority: priority, + maxTasks: .max(1), + upstream: upstream, + transform: { [transform] value throws(Failure) in + Suppress(value: try await transform(value).get()).value + }) + .subscribe(MapTaskInner(description: "MapTask", downstream: subscriber)) } } -extension MapTask.Inner: Subscriber { - - func receive(subscription: any Subscription) { - state.withLockUnchecked { - $0.upstreamSubscription.transition(.resume(subscription)) - }?.run() - } - - func receive(_ input: Upstream.Output) -> Subscribers.Demand { - let result = valueSource.continuation.yield(.success(input)) - switch result { - case .terminated: - break - case .enqueued: - break - case .dropped: - preconditionFailure("buffer overflow") - @unknown default: - fatalError("unknown case") - } - return .none - } - - func receive(completion: Subscribers.Completion) { - switch completion { - case .finished: - break - case .failure(let failure): - valueSource.continuation.yield(.failure(failure)) - } - valueSource.continuation.finish() - } - - -} - -extension MapTask.Inner: Subscription { - - func cancel() { - state.withLock{ - $0.condition.transition(.cancel) - }?.run() - } - - func request(_ demand: Subscribers.Demand) { - demandSource.continuation.yield(demand) - } - - -} - -extension MapTask.Inner: CustomStringConvertible, CustomPlaygroundDisplayConvertible { - - var playgroundDescription: Any { description } - - var description: String { "MapTask" } - -} +extension MapTask: Sendable where Upstream: Sendable {} diff --git a/Sources/Tetra/Combine/Publishers+TryMapTask.swift b/Sources/Tetra/Combine/Publishers+TryMapTask.swift index 6755584..639d595 100644 --- a/Sources/Tetra/Combine/Publishers+TryMapTask.swift +++ b/Sources/Tetra/Combine/Publishers+TryMapTask.swift @@ -8,6 +8,7 @@ import Foundation @preconcurrency import Combine import _Concurrency +internal import CriticalSection /** @@ -30,234 +31,44 @@ import _Concurrency ``` */ -public struct TryMapTask: Publisher where Upstream.Output:Sendable { +public struct TryMapTask: Publisher where Upstream.Output:Sendable { public typealias Output = Output public typealias Failure = any Error - + public typealias Transform = @isolated(any) @Sendable (Upstream.Output) async throws -> sending Output public let upstream:Upstream - public var transform:@Sendable (Upstream.Output) async throws -> Output - - public init(upstream: Upstream, transform: @escaping @Sendable (Upstream.Output) async throws -> Output) { + public var transform: Transform + public var priority: TaskPriority? = nil + + public init( + priority:TaskPriority? = nil, + upstream: Upstream, + transform: @escaping Transform + ) { self.upstream = upstream self.transform = transform + self.priority = priority } public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { - let processor = Inner(subscriber: subscriber, transform: transform) - let task = Task(operation: processor.run) - processor.resumeCondition(task) - upstream.subscribe(processor) +// let processor = Inner(subscriber: subscriber, transform: transform) +// let task = Task(priority: priority, operation: processor.run) +// processor.resumeCondition(task) +// upstream.subscribe(processor) + MultiMapTask( + priority: priority, + maxTasks: .max(1), + upstream: upstream.mapError{ $0 as any Error }, + transform: transform + ).subscribe(MapTaskInner( + description: "TryMapTask", + downstream: subscriber, + upstream: nil + )) } } -extension TryMapTask: Sendable where Upstream: Sendable {} - -extension TryMapTask { - - internal struct TaskState where S.Failure == Failure, S.Input == Output { - - var subscriber:S? = nil - var upstreamSubscription = SubscriptionContinuation.waiting - var condition = TaskValueContinuation.waiting - } - - internal struct Inner: CustomCombineIdentifierConvertible where S.Failure == Failure, S.Input == Output { - - private let valueSource = AsyncThrowingStream.makeStream(bufferingPolicy: .bufferingNewest(2)) - private let demandSource = AsyncStream.makeStream() - private let state: some UnfairStateLock> = createUncheckedStateLock(uncheckedState: .init()) - private let transform:@Sendable (Upstream.Output) async throws -> Output - let combineIdentifier = CombineIdentifier() - - init( - subscriber:S, - transform: @escaping @Sendable (Upstream.Output) async throws -> Output - ) { - - self.transform = transform - state.withLockUnchecked{ $0.subscriber = subscriber } - } - - private func send(completion: Subscribers.Completion?, cancel:Bool = false) { - terminateStream() - let (subscriber, effect) = state.withLockUnchecked{ - let old = $0.subscriber - $0.subscriber = nil - let effect = if cancel { - $0.condition.transition(.cancel) - } else { - $0.condition.transition(.finish) - } - return (old, effect) - } - effect?.run() - if let completion { - subscriber?.receive(completion: completion) - } - } - - private func send(_ value:Output) -> Subscribers.Demand? { - state.withLockUnchecked{ - $0.subscriber - }?.receive(value) - } - private func terminateStream() { - demandSource.continuation.finish() - valueSource.continuation.finish() - } - - private func waitForUpStream() async -> (any Subscription)? { - await withTaskCancellationHandler { - await withUnsafeContinuation { coninuation in - state.withLockUnchecked { - $0.upstreamSubscription.transition(.suspend(coninuation)) - }?.run() - } - } onCancel: { - state.withLockUnchecked{ - $0.upstreamSubscription.transition(.cancel) - }?.run() - } - } - - func resumeCondition(_ task:Task) { - state.withLock{ - $0.condition.transition(.resume(task)) - }?.run() - } - - private func waitForCondition() async throws { - try await withUnsafeThrowingContinuation{ continuation in - state.withLock{ - $0.condition.transition(.suspend(continuation)) - }?.run() - } - } - - private func clearCondition() { - state.withLock{ - $0.condition.transition(.finish) - }?.run() - } - - @Sendable - nonisolated func run() async { - let token:Void? = try? await waitForCondition() - if token == nil { - withUnsafeCurrentTask{ - $0?.cancel() - } - } - defer { - clearCondition() - } - let subscription = await waitForUpStream() - state.withLockUnchecked{ - $0.subscriber - }?.receive(subscription: self) - defer { terminateStream() } - guard let subscription else { - return - } - let stream = valueSource.stream - await withTaskCancellationHandler { - var iterator = stream.makeAsyncIterator() - for await var demand in demandSource.stream { - while demand > .none { - demand -= 1 - subscription.request(.max(1)) - let upstreamValue:Upstream.Output - do { - guard let value = try await iterator.next() else { - send(completion: .finished, cancel: false) - return - } - upstreamValue = value - } catch { - send(completion: .failure(error), cancel: false) - return - } - do { - // enqueue to separate task to prevent transformer cancelling root task using `UnsafeCurrentTask` - async let job = transform(upstreamValue) - let value = try await job - guard let newDemand = send(value) else { - return - } - demand += newDemand - } catch { - send(completion: .failure(error), cancel: true) - return - } - } - - } - } onCancel: { - subscription.cancel() - send(completion: nil) - } - - } - - } - - -} - -extension TryMapTask.Inner: Subscription { - - func cancel() { - state.withLock{ - $0.condition.transition(.cancel) - }?.run() - } - - func request(_ demand: Subscribers.Demand) { - demandSource.continuation.yield(demand) - } - -} - -extension TryMapTask.Inner: Subscriber { - - func receive(subscription: any Subscription) { - state.withLockUnchecked { - $0.upstreamSubscription.transition(.resume(subscription)) - }?.run() - } - - func receive(_ input: Upstream.Output) -> Subscribers.Demand { - let result = valueSource.continuation.yield(input) - switch result { - case .enqueued, .terminated: - break - case .dropped: - preconditionFailure("Buffer overflow") - @unknown default: - fatalError("unknown case") - } - return .none - } - - func receive(completion: Subscribers.Completion) { - switch completion { - case .finished: - valueSource.continuation.finish() - case .failure(let failure): - valueSource.continuation.finish(throwing: failure) - } - } - -} - -extension TryMapTask.Inner: CustomStringConvertible, CustomPlaygroundDisplayConvertible { - - var playgroundDescription: Any { description } - - var description: String { "TryMapTask" } - -} +extension TryMapTask: Sendable where Upstream: Sendable {} diff --git a/Sources/Tetra/Combine/RunLoopScheduler.swift b/Sources/Tetra/Combine/RunLoopScheduler.swift deleted file mode 100644 index aa09698..0000000 --- a/Sources/Tetra/Combine/RunLoopScheduler.swift +++ /dev/null @@ -1,306 +0,0 @@ -// -// RunLoopScheduler.swift -// -// -// Created by pbk on 2022/12/10. -// - -@preconcurrency import Foundation -import Dispatch -import os -import Combine - -/** - RunLoopScheduler suitable for background runLoop - - this class runs RunLoop indefinitely in default Mode, until deinitialized. - - #1 Nested RunLoop - - It's not a good idea to create nested RunLoop inside RunLoopScheduler but if you do need that, keep strong reference to the Scheduler. - - - - important: Memory leaks found in instrument from this class are not acually leaked and they will be released as soon as `RunLoopScheduler`'s `Thread` terminate. - */ -public final class RunLoopScheduler: Scheduler, @unchecked Sendable, Hashable { - - public static func == (lhs: RunLoopScheduler, rhs: RunLoopScheduler) -> Bool { - lhs.cfRunLoop == rhs.cfRunLoop && lhs.source == rhs.source - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(cfRunLoop) - hasher.combine(source) - } - - - public typealias SchedulerTimeType = RunLoop.SchedulerTimeType - public typealias SchedulerOptions = Never - - private let source:CFRunLoopSource - nonisolated - public let cfRunLoop:CFRunLoop - - nonisolated - public let config:Configuration - - deinit { - CFRunLoopSourceInvalidate(source) - CFRunLoopWakeUp(cfRunLoop) - } - - public init(async: Void = (), config: Configuration = .init()) async { - var nullContext = CFRunLoopSourceContext() - nullContext.version = 0 - nullContext.cancel = { _, runLoop, _ in - guard let runLoop else { return } - CFRunLoopStop(runLoop) - } - nullContext.copyDescription = { _ in - .passRetained("RunLoopScheduler Default Source" as CFString) - } - let emptySource = CFRunLoopSourceCreate(nil, 0, &nullContext).unsafelyUnwrapped - let runLoop = await withUnsafeContinuation{ continuation in - let runner = RunLoopRunner(emptySource) { - continuation.resume(returning: $0) - } - let workerThread = Thread(target: runner, selector: #selector(runner.main), object: nil) - workerThread.qualityOfService = config.qos - workerThread.start() - } - self.cfRunLoop = runLoop - self.source = emptySource - self.config = config - } - - /** - Create Scheduler in sync - - Create new Thread and run the CFRunLoop of that Thread. This initializer blocks the current thread until the Scheduler is ready. - */ - public init(sync: Void = (), config: Configuration = .init()) { - let reference = UnsafeMutablePointer.allocate(capacity: 1) - defer { reference.deallocate() } - var nullContext = CFRunLoopSourceContext() - nullContext.cancel = { _, runLoop, _ in - guard let runLoop else { return } - CFRunLoopStop(runLoop) - } - nullContext.copyDescription = { _ in - .passRetained("RunLoopScheduler Default Source" as CFString) - } - nullContext.version = 0 - let emptySource = CFRunLoopSourceCreate(nil, 0, &nullContext).unsafelyUnwrapped - - let condition = NSCondition() - let runner = RunLoopRunner(emptySource) { - reference.initialize(to: $0) - condition.withLock { - condition.signal() - } - } - let workerThread = Thread(target: runner, selector: #selector(runner.main), object: nil) - workerThread.qualityOfService = config.qos - let runLoop = condition.withLock { - workerThread.start() - condition.wait() - return reference.move() - } - self.cfRunLoop = runLoop - self.source = emptySource - self.config = config - } - - @usableFromInline - struct Block: @unchecked Sendable { - - let block: () -> Void - - @usableFromInline - func callAsFunction() { - block() - } - - @usableFromInline - init(block: @escaping () -> Void) { - self.block = block - } - - } - - @inlinable - nonisolated - public func schedule( - after date: SchedulerTimeType, - interval: SchedulerTimeType.Stride, - tolerance: SchedulerTimeType.Stride, - options: SchedulerOptions?, - _ action: @escaping () -> Void - ) -> Cancellable { - let timer:Timer - let block = Block(block: action) - if config.keepAliveUntilFinish { - let observer = createRetainToken() - timer = .init(fire: date.date, interval: interval.timeInterval, repeats: true) { _ in - CFRunLoopObserverInvalidate(observer) - block() - } - } else { - timer = .init(fire: date.date, interval: interval.timeInterval, repeats: true) { _ in block() } - } - timer.tolerance = tolerance.timeInterval - let cfTimer = timer as CFRunLoopTimer - CFRunLoopAddTimer(cfRunLoop, cfTimer, .commonModes) - return AnyCancellable{ - CFRunLoopTimerInvalidate(cfTimer) - } - } - - @inlinable - nonisolated - public func schedule( - after date: SchedulerTimeType, - tolerance: SchedulerTimeType.Stride, - options: SchedulerOptions?, - _ action: @escaping () -> Void - ) { - let timer:Timer - let block = Block(block: action) - if config.keepAliveUntilFinish { - let observer = createRetainToken() - timer = .init(fire: date.date, interval: 0, repeats: false) { _ in - CFRunLoopObserverInvalidate(observer) - block() - } - } else { - timer = .init(fire: date.date, interval: 0, repeats: false) { _ in block() } - } - timer.tolerance = tolerance.timeInterval - CFRunLoopAddTimer(cfRunLoop, timer as CFRunLoopTimer, .commonModes) - } - - @inlinable - nonisolated - public func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { - if config.keepAliveUntilFinish { - let observer = createRetainToken() - CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue) { - CFRunLoopObserverInvalidate(observer) - action() - } - } else { - CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue, action) - } - if CFRunLoopIsWaiting(cfRunLoop) { - CFRunLoopWakeUp(cfRunLoop) - } - } - - nonisolated - public var now: SchedulerTimeType { .init(Date()) } - - nonisolated - public var minimumTolerance: SchedulerTimeType.Stride { 0.0 } - - public func scheduleTask(_ block: @escaping () throws -> T) async rethrows -> T { - let result:Result = await withUnsafeContinuation{ continuation in - CFRunLoopPerformBlock(cfRunLoop, CFRunLoopMode.commonModes.rawValue) { - continuation.resume(returning: .init(catching: { try block() })) - } - if CFRunLoopIsWaiting(cfRunLoop) { - CFRunLoopWakeUp(cfRunLoop) - } - } - switch result { - case .success(let success): - return success - case .failure: - try result._rethrowOrFail() - } - } - - @usableFromInline - internal func createRetainToken() -> CFRunLoopObserver { - var context = CFRunLoopObserverContext(version: 0, info: Unmanaged.passUnretained(self).toOpaque()) { - UnsafeRawPointer(Unmanaged.fromOpaque($0.unsafelyUnwrapped).retain().toOpaque()) - } release: { - Unmanaged.fromOpaque($0.unsafelyUnwrapped).release() - } copyDescription: { - .passRetained(String(describing: Unmanaged.fromOpaque($0.unsafelyUnwrapped).takeUnretainedValue()) as CFString) - } - - return CFRunLoopObserverCreate(nil, 0, false, 0, nil, &context) - } - - private struct RunnerParameter { - let source:CFRunLoopSource - let completion:(CFRunLoop) -> () - } - - private final class RunLoopRunner { - - private var parameter:RunnerParameter? - - init(_ source:CFRunLoopSource, completionHandler: @escaping (CFRunLoop) -> ()) { - self.parameter = .init(source: source, completion: completionHandler) - } - - @objc - func main() { - let emptySource:CFRunLoopSource - if let parameter { - emptySource = parameter.source - parameter.completion(CFRunLoopGetCurrent()) - self.parameter = nil - } else { - return - } - Thread.setThreadPriority(0) - let interrupter = createNestedLoopInterrupter(emptySource) - CFRunLoopAddSource(CFRunLoopGetCurrent(), emptySource, .defaultMode) - CFRunLoopAddObserver(CFRunLoopGetCurrent(), interrupter, .commonModes) - defer { CFRunLoopObserverInvalidate(interrupter) } - while - CFRunLoopSourceIsValid(emptySource), - RunLoop.current.run(mode: .default, before: .distantFuture) - { } - } - - } - - @usableFromInline - static func createNestedLoopInterrupter(_ emptySource:CFRunLoopSource) -> CFRunLoopObserver { - var context = CFRunLoopObserverContext(version: 0, info: Unmanaged.passUnretained(emptySource).toOpaque()) { .init(Unmanaged.fromOpaque($0.unsafelyUnwrapped).retain().toOpaque()) - } release: { Unmanaged.fromOpaque($0.unsafelyUnwrapped).release() - } copyDescription: { _ in - .passRetained("RunLoopScheduler.NestedRunLoop.Interrupter" as CFString) - } - return CFRunLoopObserverCreate(nil, CFRunLoopActivity.exit.union([.beforeTimers, .beforeSources, .beforeWaiting]).rawValue, true, 0, { _, _, ref in - let source = Unmanaged.fromOpaque(ref.unsafelyUnwrapped).takeUnretainedValue() - if CFRunLoopSourceIsValid(source) { - return - } else { - CFRunLoopStop(CFRunLoopGetCurrent()) - } - }, &context) - } - -} - - -public extension RunLoopScheduler { - - struct Configuration: Hashable, Sendable { - - public var qos:QualityOfService = .default - /** whether to keep the scheduler alive until submitted tasks are finished */ - public var keepAliveUntilFinish = true - - @inlinable - public init(qos: QualityOfService = .default, keepAliveUntilFinish: Bool = true) { - self.qos = qos - self.keepAliveUntilFinish = keepAliveUntilFinish - } - } - -} diff --git a/Sources/Tetra/Combine/SchedulerTimePublisher.swift b/Sources/Tetra/Combine/SchedulerTimePublisher.swift index c89b475..9a9bbbb 100644 --- a/Sources/Tetra/Combine/SchedulerTimePublisher.swift +++ b/Sources/Tetra/Combine/SchedulerTimePublisher.swift @@ -7,6 +7,7 @@ import Foundation @preconcurrency import Combine +internal import CriticalSection public struct SchedulerTimePublisher: Publisher { @@ -45,7 +46,7 @@ public struct SchedulerTimePublisher: Publisher { final class Inner: Subscription, CustomStringConvertible, CustomPlaygroundDisplayConvertible where S.Input == Output, S.Failure == Failure { var description: String { - "SchedulerTimer<\(type(of: publisher.scheduler))>" + "SchedulerTimePublisher" } var playgroundDescription: Any { description } diff --git a/Sources/Tetra/Combine/SubscriptionContinuation.swift b/Sources/Tetra/Combine/SubscriptionContinuation.swift deleted file mode 100644 index 8192068..0000000 --- a/Sources/Tetra/Combine/SubscriptionContinuation.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// SubscriptionContinuation.swift -// -// -// Created by pbk on 2023/01/29. -// - -import Foundation -@preconcurrency import Combine - -@usableFromInline -internal enum SubscriptionContinuation { - - case waiting - case cached(any Subscription) - case suspending(UnsafeContinuation) - case finished - - enum Event { - case resume(any Subscription) - case suspend(UnsafeContinuation) - case cancel - } - - enum Effect { - case drop(any Subscription) - case resume(UnsafeContinuation, any Subscription) - case cancel(UnsafeContinuation) - - consuming func run() { - switch consume self { - case .drop(let subscription): - subscription.cancel() - case .resume(let unsafeContinuation, let subscription): - unsafeContinuation.resume(returning: subscription) - case .cancel(let unsafeContinuation): - unsafeContinuation.resume(returning: nil) - } - } - } - - mutating func transition(_ event:consuming Event) -> Effect? { - switch consume event { - case .resume(let subscription): - return resume(subscription) - case .suspend(let unsafeContinuation): - return suspend(unsafeContinuation) - case .cancel: - return cancel() - } - } - - - private mutating func resume(_ subscription: any Subscription) -> Effect? { - switch self { - case .waiting: - self = .cached(subscription) - return nil - case .cached(let oldValue): - self = .cached(subscription) - assertionFailure("received subscption more than once") - return .drop(oldValue) - case .suspending(let unsafeContinuation): - self = .finished - return .resume(unsafeContinuation, subscription) - case .finished: - return nil - } - } - - private mutating func suspend(_ continuation: UnsafeContinuation) -> Effect? { - switch self { - case .waiting: - self = .suspending(continuation) - return nil - case .cached(let subscription): - self = .finished - return .resume(continuation, subscription) - case .suspending(let oldValue): - self = .suspending(continuation) - assertionFailure("received continuation more than once") - - return .cancel(oldValue) - case .finished: - - return .cancel(continuation) - } - } - - private mutating func cancel() -> Effect? { - switch self { - case .waiting: - self = .finished - return nil - case .cached(let subscription): - self = .finished - return .drop(subscription) - case .suspending(let unsafeContinuation): - self = .finished - return .cancel(unsafeContinuation) - case .finished: - return nil - } - } - -} diff --git a/Sources/Tetra/Concurrency/AsyncSequencePublisher.swift b/Sources/Tetra/Concurrency/AsyncSequencePublisher.swift index 94e7e15..e82a84f 100644 --- a/Sources/Tetra/Concurrency/AsyncSequencePublisher.swift +++ b/Sources/Tetra/Concurrency/AsyncSequencePublisher.swift @@ -7,65 +7,95 @@ import Foundation @preconcurrency import Combine +public import BackPortAsyncSequence +internal import CriticalSection +import Namespace -public extension AsyncSequence where Self:Sendable { +public extension TetraExtension where Base: AsyncSequence { - @inlinable - var tetra:TetraExtension { - .init(self) + @available(*, deprecated, renamed: "toPublisher()", message: "use toPublisher() which provides task priority and isolation") + var publisher:some Publisher { + toPublisher() } -} - -public extension TetraExtension where Base: AsyncSequence & Sendable { + @_disfavoredOverload @inlinable - var publisher:AsyncSequencePublisher { - .init(base: base) + func toPublisher( + barrier: (any Actor)? = #isolation, + priority: TaskPriority? = nil + ) -> some Publisher { + AsyncSequencePublisher( + base: LegacyTypedAsyncSequence(base: base), + barrier: barrier, + priority: priority + ) } } -public extension AsyncSequence where Self:Sendable { +public extension TetraExtension where Base: TypedAsyncSequence, Base.AsyncIterator: TypedAsyncIteratorProtocol { - @available(*, deprecated, message: "use explicit extension publisher property instead, will be removed on Swift 6") @inlinable - var asyncPublisher:AsyncSequencePublisher { - TetraExtension(base: self).publisher + func toPublisher( + barrier: (any Actor)? = #isolation, + priority: TaskPriority? = nil + ) -> some Publisher where Element == Base.Element, Failure == Base.AsyncIterator.Err { + AsyncSequencePublisher( + base: base, + barrier: barrier, + priority: priority + ) } } -public struct AsyncSequencePublisher: Publisher { - public typealias Output = Base.Element - public typealias Failure = Error +public struct AsyncSequencePublisher: Publisher where Base.AsyncIterator: TypedAsyncIteratorProtocol { + public typealias Output = Base.AsyncIterator.Element + + public typealias Failure = Base.AsyncIterator.Err public var base:Base + public var barrier: (any Actor)? = nil + public var priority: TaskPriority? = nil @inlinable - public init(base: Base) { + public init( + base: Base, + barrier: (any Actor)? = nil, + priority: TaskPriority? = nil + ) { self.base = base + self.barrier = barrier + self.priority = priority } - public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Base.Element == S.Input { + public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Base.AsyncIterator.Element == S.Input { let processor = Inner(subscriber: subscriber) - let task = Task { [base] in - await processor.run(base) + let unsafe = Suppress(value: base.makeAsyncIterator()) + let task = Task(priority: priority) { [capture = consume unsafe, barrier] in + var iter = capture.value + await processor.run(barrier, &iter) } processor.resumeCondition(task) } } + extension AsyncSequencePublisher: Sendable where Base: Sendable, Base.Element: Sendable {} + extension AsyncSequencePublisher { internal struct TaskState where S.Input == Output, S.Failure == Failure { var subscriber:S? = nil var condition = TaskValueContinuation.waiting + var demand = Subscribers.Demand.none + var continuation:UnsafeContinuation? = nil + var terminated = false } @@ -75,7 +105,6 @@ extension AsyncSequencePublisher { var playgroundDescription: Any { description } let combineIdentifier = CombineIdentifier() - private let demandSource = AsyncStream.makeStream() private let state:some UnfairStateLock> = createUncheckedStateLock(uncheckedState: .init()) init(subscriber:S) { @@ -84,15 +113,25 @@ extension AsyncSequencePublisher { } } - func cancel() { - state.withLock{ - return $0.condition.transition(.cancel) - }?.run() + send(completion: nil) } func request(_ demand: Subscribers.Demand) { - demandSource.continuation.yield(demand) + let tuple:(UnsafeContinuation, Subscribers.Demand)? = state.withLock{ + $0.demand += demand + let old = $0.continuation + $0.continuation = nil + if let old, $0.demand > .none { + let newDemand = $0.demand + $0.demand = .none + return (old, newDemand) + } else { + return .none + } + } + guard let (token, newDemand) = tuple else { return } + token.resume(returning: newDemand) } private func send(_ value:S.Input) -> Subscribers.Demand? { @@ -100,14 +139,20 @@ extension AsyncSequencePublisher { } private func send(completion: Subscribers.Completion?) { - let subscriber = state.withLockUnchecked{ + let (subscriber, effect, token) = state.withLockUnchecked{ let old = $0.subscriber $0.subscriber = nil - return old + $0.terminated = true + let effect = $0.condition.transition(completion == nil ? .cancel : .finish) + let token = $0.continuation + $0.continuation = nil + return (old, effect, token) } if let completion { subscriber?.receive(completion: completion) } + token?.resume(returning: nil) + effect?.run() } func resumeCondition(_ task:Task) { @@ -116,7 +161,7 @@ extension AsyncSequencePublisher { }?.run() } - private func waitForCondition() async throws { + private func waitForCondition( _ actor: isolated (any Actor)? = #isolation) async throws { try await withUnsafeThrowingContinuation{ continuation in state.withLock{ $0.condition.transition(.suspend(continuation)) @@ -124,51 +169,66 @@ extension AsyncSequencePublisher { } } - private func clearCondition() { - state.withLock{ - $0.condition.transition(.finish) - }?.run() + private func nextDemand( _ actor: isolated (any Actor)? = #isolation) async -> Subscribers.Demand? { + await withUnsafeContinuation{ continuation in + let demand:Subscribers.Demand? = state.withLock{ + if $0.demand > .none { + let newDemand = $0.demand + $0.demand = .none + return newDemand + } else if $0.continuation == nil { + $0.continuation = continuation + return Subscribers.Demand.none + } else { + assertionFailure("received \(#function) twice at the same time") + return nil + } + + } + if let demand, demand > .none { + continuation.resume(returning: demand) + } + if demand == nil { + continuation.resume(returning: nil) + } + + } } + - func run(_ base:consuming Base) async { + func run( + _ actor: isolated (any Actor)? = #isolation, + _ iterator: inout Base.AsyncIterator + ) async { let token:Void? = try? await waitForCondition() - defer { - demandSource.continuation.finish() - } if token == nil { - withUnsafeCurrentTask { $0?.cancel() } + send(completion: nil) } - defer { clearCondition() } - state.withLockUnchecked{ - $0.subscriber - }?.receive(subscription: self) - var iterator = base.makeAsyncIterator() - await withTaskCancellationHandler { - do { - for await var pending in demandSource.stream { - while pending > .none { - if let value = try await iterator.next() { - pending -= 1 - if let newDemand = send(value) { - pending += newDemand - } else { - return - } - } else { - send(completion: .finished) - return - } + + async let job:Void = { (actor:isolated (any Actor)?) in + state.withLockUnchecked{ + $0.subscriber + }?.receive(subscription: self) + }(actor) + await job + do { + while var pending = await nextDemand() { + while pending > .none { + pending -= 1 + guard let value = try await iterator.next(isolation: actor) + else { + send(completion: .finished) + return } + guard let newDemand = send(value) else { + return + } + pending += newDemand } - send(completion: .finished) - } catch { - send(completion: .failure(error)) } - } onCancel: { - demandSource.continuation.finish() - send(completion: nil) + } catch { + send(completion: .failure(error)) } - } } diff --git a/Sources/Tetra/Concurrency/AsyncTypedSequence.swift b/Sources/Tetra/Concurrency/AsyncTypedSequence.swift deleted file mode 100644 index 6b8ceb8..0000000 --- a/Sources/Tetra/Concurrency/AsyncTypedSequence.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// AsyncTypedSequence.swift -// -// -// Created by pbk on 2022/09/26. -// - -import Combine -import _Concurrency - -@usableFromInline -internal protocol NonThrowingAsyncIteratorProtocol: AsyncIteratorProtocol { - - mutating func next() async -> Element? - -} - -@usableFromInline -internal protocol NonThrowingAsyncSequence: AsyncSequence where AsyncIterator: NonThrowingAsyncIteratorProtocol { -} - -public protocol AsyncTypedSequence:AsyncSequence {} - -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension AsyncThrowingPublisher: AsyncTypedSequence {} - -@available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) -extension AsyncPublisher.Iterator: NonThrowingAsyncIteratorProtocol {} -extension AsyncStream.Iterator: NonThrowingAsyncIteratorProtocol {} -extension AsyncStream: NonThrowingAsyncSequence {} -public struct WrappedAsyncSequence:AsyncSequence { - - public func makeAsyncIterator() -> Iterator { - builder() - } - - public typealias AsyncIterator = Iterator - private let builder: () -> AsyncIterator - - @usableFromInline - internal init(base:T) where T.Element == Element, T.AsyncIterator: NonThrowingAsyncIteratorProtocol { - builder = { [base] in - Iterator(base: base.makeAsyncIterator()) - } - } - - public struct Iterator: AsyncIteratorProtocol { - - private var iterator:any NonThrowingAsyncIteratorProtocol - - mutating public func next() async -> Element? { - await iterator.next() - } - - internal init(base:T) where T.Element == Element { - iterator = base - } - } -} - diff --git a/Sources/Tetra/Concurrency/CompatDiscardigTaskGroup.swift b/Sources/Tetra/Concurrency/CompatDiscardigTaskGroup.swift deleted file mode 100644 index ee7cbfc..0000000 --- a/Sources/Tetra/Concurrency/CompatDiscardigTaskGroup.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// File.swift -// -// -// Created by 박병관 on 5/16/24. -// - -import Foundation - -internal protocol CompatThrowingDiscardingTaskGroup { - - var isCancelled:Bool { get } - var isEmpty:Bool { get } - func cancelAll() - mutating func addTaskUnlessCancelled( - priority: TaskPriority?, - operation: @escaping @Sendable () async throws -> Void - ) -> Bool - mutating func addTask( - priority: TaskPriority?, - operation: @escaping @Sendable () async throws -> Void - ) - -} -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, macCatalyst 17.0, visionOS 1.0, *) -extension ThrowingDiscardingTaskGroup: CompatThrowingDiscardingTaskGroup { - -} - -extension ThrowingTaskGroup: CompatThrowingDiscardingTaskGroup where ChildTaskResult == Void { - -} diff --git a/Sources/Tetra/Concurrency/CoreDataStack+Concurrency.swift b/Sources/Tetra/Concurrency/CoreDataStack+Concurrency.swift index 9ae2e6c..f621ec8 100644 --- a/Sources/Tetra/Concurrency/CoreDataStack+Concurrency.swift +++ b/Sources/Tetra/Concurrency/CoreDataStack+Concurrency.swift @@ -9,77 +9,69 @@ import Foundation import _Concurrency #if canImport(CoreData) -import CoreData +@preconcurrency import CoreData +import Namespace + -extension NSPersistentContainer: TetraExtended {} -extension NSPersistentStoreCoordinator: TetraExtended {} -extension NSManagedObjectContext: TetraExtended {} extension TetraExtension where Base: NSPersistentStoreCoordinator { @usableFromInline - internal func _perform(_ body: () throws -> T) async rethrows -> T { - let result:Result - do { - let value: T = try await withoutActuallyEscaping(body) { escapingClosure in - let closureHolder = ClosureHolder(closure: escapingClosure) - defer { - withExtendedLifetime(closureHolder) {} - } - return try await withUnsafeThrowingContinuation { continuation in - base.perform{ [unowned closureHolder, continuation] in - continuation.resume(with: Result { try closureHolder.closure() }) - } + internal func _perform(_ body: () throws(Failure) -> T) async throws(Failure) -> T { + let value:Result = await withoutActuallyEscaping(body) { escapingClosure in + let block = ClosureHolder(closure: escapingClosure) + defer { + withExtendedLifetime(block, {}) + } + return await withUnsafeContinuation { continuation in + base.perform { [unowned block, continuation] in + continuation.resume(returning: block()) } } - result = .success(value) - } catch { - result = .failure(error) - } - switch result { - case .success(let success): - return success - case .failure: - try result._rethrowOrFail() } + return try value.get() } @inlinable - public func perform(_ body: () throws -> T) async rethrows -> T { + public func perform(_ body: () throws(Failure) -> T) async throws(Failure) -> T { return if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - try await withoutActuallyEscaping(body) { - let holder = ClosureHolder(closure: $0) - defer { withExtendedLifetime(holder, {})} - return try await base.perform{ [unowned holder] in - try holder.closure() + try await withoutActuallyEscaping(body) { + let block = ClosureHolder(closure: $0) + defer { + withExtendedLifetime(block, {}) } - } + return await base.perform{ [unowned block] in + let result:Result = block() + return result + } + }.get() } else { try await _perform(body) } } @usableFromInline - internal func _performAndWait(_ body: () throws -> T) rethrows -> T { - var result:Result? = nil + internal func _performAndWait(_ body: () throws(Failure) -> T) throws(Failure) -> T { + var result:Result? = nil base.performAndWait { - result = Result { try body() } + do throws(Failure) { + result = .success(try body()) + } catch { + result = .failure(error) + } } guard let result else { preconditionFailure("performAndWait didn't run") } - switch result { - case .success(let success): - return success - case .failure: - try result._rethrowOrFail() - } + return try result.get() } @inlinable - public func performAndWait(_ body: () throws -> T) rethrows -> T { + public func performAndWait(_ body: () throws(Failure) -> T) throws(Failure) -> T { return if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - try base.performAndWait(body) + try base.performAndWait{ + Result(catching: body) + }.get() } else { try _performAndWait(body) } @@ -104,110 +96,104 @@ extension TetraExtension where Base: NSManagedObjectContext { /// /// This method supports reentrancy — meaning it’s safe to call the method again, from within the closure, before the previous invocation completes. @usableFromInline - internal func _performImmediate( - _ body: () throws -> T - ) rethrows -> Result? { + internal func _performImmediate( + _ body: () throws(Failure) -> T + ) throws(Failure) -> Result? { // Suppress deprecation let lock:any ObjcLocking = base // NSManagedObjectContext has Reentrant Locking guard lock.tryLock() else { return nil } defer { lock.unlock() } - let value = try performAndWait(body) + let value = try _performAndWait(body) return .success(value) } @usableFromInline - internal func _performEnqueue( - _ body: () throws -> T - ) async rethrows -> T { - let result:Result - do { - let value: T = try await withoutActuallyEscaping(body) { escapingClosure in - let closureHolder = ClosureHolder(closure: escapingClosure) - defer { - withExtendedLifetime(closureHolder) {} - } - return try await withUnsafeThrowingContinuation { continuation in - base.perform{ [unowned closureHolder, continuation] in - continuation.resume(with: Result { try closureHolder.closure() }) - } + internal func _performEnqueue( + _ body: () throws(Failure) -> T + ) async throws(Failure) -> T { + let result: Result = await withoutActuallyEscaping(body) { escapingClosure in + let holder = ClosureHolder(closure: escapingClosure) + defer { + withExtendedLifetime(holder, {}) + } + return await withUnsafeContinuation { continuation in + base.perform{ [unowned(unsafe) holder, continuation] in + let result = holder() + continuation.resume(returning: result) } } - result = .success(value) - } catch { - result = .failure(error) } - switch result { - case .success(let success): - return success - case .failure: - try result._rethrowOrFail() + return try result.get() + } + + @usableFromInline + internal func _perform( + schedule:CoreDataScheduledTaskType = .immediate, + isolation: isolated (any Actor)? = #isolation, + _ body: @escaping () throws(Failure) -> T + ) async throws(Failure) -> Suppress { + nonisolated(unsafe) + let callRef = self + let ref = UnsafeClosureHolder(closure: body) + defer { + withExtendedLifetime(ref, {}) + } + if schedule == .immediate, let result = try _performImmediate(body) { + let value = result.get() + return .init(value: value) + } else { + return try await callRef._performEnqueue { [unowned(unsafe) ref] in + ref().map(Suppress.init) + }.get() } } + /// Asynchronously performs the specified closure on the context’s queue. @inlinable - @_unsafeInheritExecutor - public func perform( - schedule: CoreDataScheduledTaskType = .immediate, - _ body: () throws -> T - ) async rethrows -> T { - /* - - Since this method and NSManagedObjectContext peform has no actor preference and isolation restriction. - These two are always called on global nonisolated context (Actor switching happen). - Which means that `immediate` execution option is totally no-op. - - - - `_performImmediate:` is never called when using `NSManagedObjectContext.perform(schedule: .immediate)` in iOS 15 ~ iOS 17 - - - @_unsafeInheritExecutor do fix the above problem - */ - return if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - try await withoutActuallyEscaping(body) { - let holder = ClosureHolder(closure: $0) + public func perform( + schedule:CoreDataScheduledTaskType = .immediate, + isolation: isolated (any Actor)? = #isolation, + _ body: () throws(Failure) -> T + ) async throws(Failure) -> T { + let box: Suppress = try await withoutActuallyEscaping(body) { escapingClosure async throws(Failure) in + if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { + let ref:UnsafeClosureHolder = UnsafeClosureHolder(closure: escapingClosure) defer { - withExtendedLifetime(holder, { }) + withExtendedLifetime(ref, {}) } - return try await base.perform(schedule: schedule.platformValue) { [unowned holder] in - try holder.closure() - } - } - } else if schedule == .enqueued { - try await _performEnqueue(body) - } else { - if let result = try _performImmediate(body) { - switch result { - case .success(let success): - success + let wrapped = await base.perform(schedule: schedule.platformValue) { [unowned(unsafe) ref] in + ref().map(Suppress.init) } + return try wrapped.get() } else { - try await _performEnqueue(body) + return try await self._perform(schedule: schedule, isolation: isolation, escapingClosure) } } + return box.value } @usableFromInline - internal func _performAndWait(_ body: () throws -> T) rethrows -> T { - var result:Result? = nil + internal func _performAndWait(_ body: () throws(Failure) -> T) throws(Failure) -> T { + var result:Result? = nil base.performAndWait { - result = Result { try body() } + do throws(Failure) { + result = .success(try body()) + } catch { + result = .failure(error) + } } guard let result else { preconditionFailure("performAndWait didn't run") } - switch result { - case .success(let success): - return success - case .failure: - try result._rethrowOrFail() - } + return try result.get() } @inlinable - public func performAndWait(_ body: () throws -> T) rethrows -> T { + public func performAndWait(_ body: () throws(Failure) -> T) throws(Failure) -> T { return if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - try base.performAndWait(body) + try base.performAndWait{ Result(catching: body) }.get() } else { try _performAndWait(body) } @@ -218,51 +204,44 @@ extension TetraExtension where Base: NSManagedObjectContext { extension TetraExtension where Base: NSPersistentContainer { @inlinable - public func performBackground(_ body: (NSManagedObjectContext) throws -> T) async rethrows -> T { + public func performBackground(_ body: (NSManagedObjectContext) throws(Failure) -> T) async throws(Failure) -> T { return if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { try await withoutActuallyEscaping(body) { - let holder = CoreDataContextClosureHolder(closure: $0) - defer { - withExtendedLifetime(holder, { }) + let block = CoreDataContextClosureHolder(closure: $0) + defer { withExtendedLifetime(block, {}) } + return await base.performBackgroundTask{ [unowned block] in + let result:Result = block($0) + return result } - return try await base.performBackgroundTask{ [unowned holder] in - try holder.closure($0) - } - } + }.get() } else { try await _performBackground(body) } } + + @usableFromInline - internal func _performBackground(_ body: (NSManagedObjectContext) throws -> T) async rethrows -> T { - let result:Result - do { - let value: T = try await withoutActuallyEscaping(body) { escapingClosure in - let closureHolder = CoreDataContextClosureHolder(closure: escapingClosure) - defer { - withExtendedLifetime(closureHolder) {} - } - return try await withUnsafeThrowingContinuation { continuation in - base.performBackgroundTask { [unowned closureHolder, continuation] newContext in - continuation.resume(with: Result{ try closureHolder.closure(newContext) }) - } + internal func _performBackground(_ body: (NSManagedObjectContext) throws(Failure) -> T) async throws(Failure) -> T { + let result:Result = await withoutActuallyEscaping(body) { escapingClosure in + let holder = CoreDataContextClosureHolder(closure: escapingClosure) + defer { + withExtendedLifetime(holder, {}) + } + return await withUnsafeContinuation { continuation in + base.performBackgroundTask { [unowned holder, continuation] newContext in + nonisolated(unsafe) + let result = holder(newContext) + continuation.resume(returning: result) } } - result = .success(value) - } catch { - result = .failure(error) - } - switch result { - case .success(let success): - return success - case .failure: - try result._rethrowOrFail() } + return try result.get() } } + public enum CoreDataScheduledTaskType: Sendable, Hashable { case immediate @@ -282,3 +261,31 @@ public enum CoreDataScheduledTaskType: Sendable, Hashable { } #endif + +extension TetraExtension: Sendable where Base: Sendable {} + +@usableFromInline +internal final class UnsafeClosureHolder: @unchecked Sendable { + + @inline(__always) + @usableFromInline let closure: () throws(Failure) -> R + + @inline(__always) + @inlinable + init(closure: @escaping () throws(Failure) -> R) { + self.closure = closure + } + + @inline(__always) + @inlinable + func callAsFunction() -> Result { + do { + let value = try closure() + return .success(value) + } catch { + return .failure(error) + } + } + +} + diff --git a/Sources/Tetra/Concurrency/Dispatch+Extension.swift b/Sources/Tetra/Concurrency/Dispatch+Extension.swift index 9eb3f34..82c5401 100644 --- a/Sources/Tetra/Concurrency/Dispatch+Extension.swift +++ b/Sources/Tetra/Concurrency/Dispatch+Extension.swift @@ -6,9 +6,10 @@ // @preconcurrency import Foundation -import Dispatch +@preconcurrency import Dispatch +internal import CriticalSection +import Namespace -extension Task: TetraExtended {} public extension TetraExtension where Base == Task { @@ -18,9 +19,9 @@ public extension TetraExtension where Base == Task { - Throws: `CancellationError` if task is cancelled */ @inlinable - static func sleep(deadline: DispatchTime, tolerance: DispatchTimeInterval? = nil) async throws { + static func sleep(deadline: DispatchTime, tolerance: DispatchTimeInterval? = nil) async throws(CancellationError) { let source = DispatchSource.makeTimerSource(flags: [.strict]) - + defer { source.cancel() } source.schedule(deadline: deadline, repeating: .never, leeway: tolerance ?? .nanoseconds(0)) try await dispatchTimerSleep(source: source) } @@ -31,8 +32,9 @@ public extension TetraExtension where Base == Task { - Throws: `CancellationError` if task is cancelled */ @inlinable - static func sleep(wallDeadline: DispatchWallTime, tolerance: DispatchTimeInterval? = nil) async throws { + static func sleep(wallDeadline: DispatchWallTime, tolerance: DispatchTimeInterval? = nil) async throws(CancellationError) { let source = DispatchSource.makeTimerSource(flags: [.strict]) + defer { source.cancel() } source.schedule(wallDeadline: wallDeadline, repeating: .never, leeway: tolerance ?? .nanoseconds(0)) try await dispatchTimerSleep(source: source) } @@ -41,7 +43,7 @@ public extension TetraExtension where Base == Task { @usableFromInline -internal func dispatchTimerSleep(source:DispatchSourceTimer) async throws { +internal func dispatchTimerSleep(source:DispatchSourceTimer) async throws(CancellationError) { let lock = createCheckedStateLock(checkedState: DispatchSleepState.waiting) source.setEventHandler{ @@ -54,37 +56,40 @@ internal func dispatchTimerSleep(source:DispatchSourceTimer) async throws { $0.take() }?.resume(throwing: CancellationError()) } - return try await withTaskCancellationHandler { - return try await withUnsafeThrowingContinuation{ continuation in - let snapShot = lock.withLock{ - let oldValue = $0 - switch oldValue { + do { + try await withTaskCancellationHandler { + return try await withUnsafeThrowingContinuation{ continuation in + let snapShot = lock.withLock{ + let oldValue = $0 + switch oldValue { + case .finished: + break + case .waiting, .continuation: + $0 = .continuation(continuation) + + } + return oldValue + } + switch snapShot { + case .continuation(let unsafeContinuation): + assertionFailure("reached unexpected state") + unsafeContinuation.resume(throwing: CancellationError()) case .finished: + continuation.resume(throwing: CancellationError()) + case .waiting: break - case .waiting, .continuation: - $0 = .continuation(continuation) - } - return oldValue - } - switch snapShot { - case .continuation(let unsafeContinuation): - assertionFailure("reached unexpected state") - unsafeContinuation.resume(throwing: CancellationError()) - case .finished: - continuation.resume(throwing: CancellationError()) - case .waiting: - break + source.activate() } - source.activate() + } onCancel: { + lock.withLock{ + $0.take() + }?.resume(throwing: CancellationError()) + source.cancel() } - } onCancel: { - lock.withLock{ - $0.take() - }?.resume(throwing: CancellationError()) - source.cancel() + } catch { + throw CancellationError() } - } diff --git a/Sources/Tetra/Concurrency/DispatchSerialExecutor.swift b/Sources/Tetra/Concurrency/DispatchSerialExecutor.swift index 00b156e..3599b98 100644 --- a/Sources/Tetra/Concurrency/DispatchSerialExecutor.swift +++ b/Sources/Tetra/Concurrency/DispatchSerialExecutor.swift @@ -8,7 +8,7 @@ import Foundation import Dispatch -public final class DispatchQueueExecutor: SerialExecutor { +package final class DispatchQueueExecutor: SerialExecutor { let queue:DispatchQueue @@ -99,6 +99,10 @@ public final class DispatchQueueExecutor: SerialExecutor { return result } + public func checkIsolated() { + dispatchPrecondition(condition: .onQueue(queue)) + } + } diff --git a/Sources/Tetra/Concurrency/Notification+AsyncSequence.swift b/Sources/Tetra/Concurrency/Notification+AsyncSequence.swift index e0cb07a..735ecf9 100644 --- a/Sources/Tetra/Concurrency/Notification+AsyncSequence.swift +++ b/Sources/Tetra/Concurrency/Notification+AsyncSequence.swift @@ -8,103 +8,112 @@ import Foundation import _Concurrency +public import BackPortAsyncSequence +import Namespace -extension NotificationCenter: TetraExtended {} +internal import struct DequeModule.Deque +public import CriticalSection -extension TetraExtension where Base: NotificationCenter { - - func notifications(named: Notification.Name, object: AnyObject? = nil) -> WrappedAsyncSequence { - if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) { - return WrappedAsyncSequence(base: base.notifications(named: named, object: object)) - } else { - return WrappedAsyncSequence(base: NotificationSequence(center: base, named: named, object: object)) - } - } - -} - -@available(iOS 13.0, tvOS 13.0, macCatalyst 13.0, watchOS 6.0, macOS 10.15, *) -public extension NotificationCenter { - +extension TetraExtension where Base: NotificationCenter { - @available(iOS, introduced: 13.0, deprecated: 15.0, renamed: "notifications", message: "use explicit extension method, will be removed on Swift 6") - @available(tvOS, introduced: 13.0, deprecated: 15.0, renamed: "notifications", message: "use explicit extension method, will be removed on Swift 6") - @available(macCatalyst, introduced: 13.0, deprecated: 15.0, renamed: "notifications", message: "use explicit extension method, will be removed on Swift 6") - @available(watchOS, introduced: 6.0, deprecated: 8.0, renamed: "notifications", message: "use explicit extension method, will be removed on Swift 6") - @available(macOS, introduced: 10.15, deprecated: 12.0, renamed: "notifications", message: "use explicit extension method, will be removed on Swift 6") - func sequence(named:Notification.Name, object:AnyObject? = nil) -> WrappedAsyncSequence { - tetra.notifications(named: named, object: object) + @inlinable + func notifications(named: Notification.Name, object: AnyObject? = nil) -> NotificationSequence { + return NotificationSequence(center: base, named: named, object: object) } } -@available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, watchOS 8.0, macOS 12.0, *) -extension NotificationCenter.Notifications.AsyncIterator: NonThrowingAsyncIteratorProtocol {} -public final class NotificationSequence: AsyncSequence, Sendable { +public final class NotificationSequence: AsyncSequence, Sendable, TypedAsyncSequence { - public typealias Element = Notification public typealias AsyncIterator = Iterator + public typealias Failure = Never public func makeAsyncIterator() -> Iterator { Iterator(parent: self) } + @usableFromInline let center: NotificationCenter - private let lock:some UnfairStateLock = createUncheckedStateLock(uncheckedState: NotficationState()) + @usableFromInline + let lock:some UnfairStateLock = createUncheckedStateLock(uncheckedState: NotficationState()) - public struct Iterator: NonThrowingAsyncIteratorProtocol { + public struct Iterator: AsyncIteratorProtocol, TypedAsyncIteratorProtocol { public typealias Element = Notification + public typealias Failure = Never + @usableFromInline let parent:NotificationSequence - - public func next() async -> Notification? { -// next를 호출한 동안에 task cancellation이 발생하면 observer Token이 무효화되는 것이 확인되므로 아래와 같이 canellation을 추가한다. + + @inlinable + public func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Never) -> Notification? { + // next를 호출한 동안에 task cancellation이 발생하면 observer Token이 무효화되는 것이 확인되므로 아래와 같이 canellation을 추가한다. await withTaskCancellationHandler( - operation: parent.next, - onCancel: parent.cancel + operation: { [parent] in + await parent.next(isolation: actor) + }, + onCancel: parent.cancel, + isolation: actor ) } + + @_disfavoredOverload + @inlinable + public func next() async throws(Never) -> Notification? { + await next(isolation: nil) + } } - private struct NotficationState { - var buffer:[Notification] = [] - var pending:[UnsafeContinuation] = [] + @usableFromInline + internal struct NotficationState { +// @usableFromInline + var buffer:Deque = [] +// @usableFromInline + var pending:Deque> = [] + @usableFromInline var observer:NSObjectProtocol? + + @usableFromInline + mutating func resume(_ value:Notification) -> UnsafeContinuation? { + let captured = pending.first + + if pending.isEmpty { + buffer.append(value) + } else { + pending.removeFirst() + } + return captured + } + } - + @inlinable public init( center: NotificationCenter, named name: Notification.Name, object: AnyObject? = nil ) { - self.center = center let observer = center.addObserver(forName: name, object: object, queue: nil) { [lock] notification in - lock.withLockUnchecked { state in - let captured = state.pending.first - if state.pending.isEmpty { - state.buffer.append(notification) - } else { - state.pending.removeFirst() - } - return captured - }?.resume(returning: notification) + let continuation = lock.withLockUnchecked { state in + return state.resume(notification) + } + continuation?.resume(returning: Suppress(value: notification).value) } lock.withLockUnchecked{ $0.observer = observer } } - + @inlinable deinit { cancel() } + @usableFromInline @Sendable func cancel() { let snapShot = lock.withLockUnchecked { @@ -120,8 +129,9 @@ public final class NotificationSequence: AsyncSequence, Sendable { snapShot.pending.forEach{ $0.resume(returning: nil) } } - func next() async -> Notification? { - await withUnsafeContinuation { continuation in + @usableFromInline + func next(isolation: isolated (any Actor)? = #isolation) async -> Notification? { + await withUnsafeContinuation(isolation: isolation) { continuation in let (notification, isCancelled) = lock.withLockUnchecked { state in if !state.buffer.isEmpty { return (state.buffer.removeFirst() as Notification?, false) diff --git a/Sources/Tetra/Concurrency/RunLoopExecutor.swift b/Sources/Tetra/Concurrency/RunLoopExecutor.swift deleted file mode 100644 index 7765bf1..0000000 --- a/Sources/Tetra/Concurrency/RunLoopExecutor.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// RunLoopExecutor.swift -// -// -// Created by 박병관 on 8/20/23. -// - -@preconcurrency import Foundation - -internal struct RunLoopRunner: ~Copyable, @unchecked Sendable { - - internal let thread:Thread - private let source:CFRunLoopSource - - fileprivate init(qos:QualityOfService = .default) { - var context = CFRunLoopSourceContext() - context.version = 0 - self.source = CFRunLoopSourceCreate(nil, 0, &context) - self.thread = Thread { [source] in - runInCurrent(source: source) - } - thread.name = "RunLoopSerialExecutor" - thread.qualityOfService = qos - thread.threadPriority = 0.0 - thread.start() - } - - deinit { - let source = source - submit { - CFRunLoopSourceInvalidate(source) - CFRunLoopStop(CFRunLoopGetCurrent()) - } - } - - internal func submit(_ block: @Sendable @escaping () -> Void) { - let job = Timer(timeInterval: 0, repeats: false) { _ in - block() - } - job.perform( - #selector(job.fire), - on: thread, - with: nil, - waitUntilDone: false, - modes: [RunLoop.Mode.common.rawValue] - ) - } - - -} - -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -public final class RunLoopExecutor: SerialExecutor { - - internal let runner:RunLoopRunner - - public init(qos: QualityOfService = .default) { - runner = .init(qos: qos) - } - - public func enqueue(_ job: consuming ExecutorJob) { - let ref = UnownedJob(job) - let executor = asUnownedSerialExecutor() - runner.submit { - ref.runSynchronously(on: executor) - } - } - - - public func isSameExclusiveExecutionContext(other: RunLoopExecutor) -> Bool { - return runner.thread == other.runner.thread - } - - public func checkIsolation() { - precondition(runner.thread == Thread.current, "Expected \(runner.thread) but found \(Thread.current)") - } - - -} - -@inlinable -internal func runInCurrent(source:CFRunLoopSource) { - guard RunLoop.current.currentMode == nil else { return } - CFRunLoopAddSource(CFRunLoopGetCurrent(), source, CFRunLoopMode.defaultMode) - defer { CFRunLoopSourceInvalidate(source) } - while CFRunLoopContainsSource(CFRunLoopGetCurrent(), source, CFRunLoopMode.defaultMode) { - let processed = autoreleasepool { - RunLoop.current.run(mode: .default, before: .distantFuture) - } - if !processed { - break - } - } -} - -@available(macOS, deprecated: 14.0, renamed: "RunLoopExecutor") -@available(iOS, deprecated: 17.0, renamed: "RunLoopExecutor", message: "LegacyRunLoopExecutor is deprecated by MoveOnly Types use RunLoopExecutor instead") -@available(watchOS, deprecated: 10.0, renamed: "RunLoopExecutor", message: "LegacyRunLoopExecutor is deprecated by MoveOnly Types use RunLoopExecutor instead") -@available(tvOS, deprecated: 17.0, renamed: "RunLoopExecutor", message: "LegacyRunLoopExecutor is deprecated by MoveOnly Types use RunLoopExecutor instead") -@available(visionOS, deprecated: 1.0, renamed: "RunLoopExecutor", message: "LegacyRunLoopExecutor is deprecated by MoveOnly Types use RunLoopExecutor instead") -public final class LegacyRunLoopExecutor: SerialExecutor { - - internal let runner:RunLoopRunner - - public init(qos:QualityOfService = .default) { - runner = .init(qos: qos) - } - - #if os(macOS) || os(tvOS) || os(watchOS) || os(iOS) - public func enqueue(_ job: UnownedJob) { - let executor = asUnownedSerialExecutor() - runner.submit { - job.runSynchronously(on: executor) - } - } - #else - public func enqueue(_ job: consuming ExecutorJob) { - let ref = UnownedJob(job) - let executor = self.asUnownedSerialExecutor() - runner.submit { - ref.runSynchronously(on: executor) - } - } - #endif - - @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) - public func isSameExclusiveExecutionContext(other: LegacyRunLoopExecutor) -> Bool { - return runner.thread == other.runner.thread - } - #if os(macOS) || os(tvOS) || os(watchOS) || os(iOS) - public func asUnownedSerialExecutor() -> UnownedSerialExecutor { - return .init(ordinary: self) - } - #endif - -} - - -@inlinable -public func newRunLoopExecutor(qos: QualityOfService) -> some SerialExecutor { - if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) { - return RunLoopExecutor(qos: qos) - } else { - return LegacyRunLoopExecutor(qos: qos) - } -} - diff --git a/Sources/Tetra/Concurrency/TaskQos.swift b/Sources/Tetra/Concurrency/TaskQos.swift new file mode 100644 index 0000000..3408094 --- /dev/null +++ b/Sources/Tetra/Concurrency/TaskQos.swift @@ -0,0 +1,92 @@ +// +// TaskQos.swift +// +// +// Created by 박병관 on 7/6/24. +// +import Dispatch + +extension TaskPriority { + + func evaluateQos() -> DispatchQoS { + let userInteractive = TaskPriority.userInteractive + switch self { + case userInteractive: + return .userInteractive + case .high: + return .userInitiated + case .medium: + return .default + case .low: + return .utility + case .background: + return .background + case .unspecified: + return .unspecified + default: + break + } + if self > userInteractive { + return .userInteractive + } + let calculate = { (basis: TaskPriority) in + let diff = basis.rawValue - self.rawValue + return max(-Int(diff), Int(QOS_MIN_RELATIVE_PRIORITY)) + } + if self < .background { + return .init(qosClass: .background, relativePriority: calculate(.background)) + } + if (TaskPriority.userInitiated.. TaskPriority? { + let evaluate = { (base:TaskPriority) in + let rawValue = Int8(bitPattern: base.rawValue) + Int8(relativePriority) + return TaskPriority(rawValue: UInt8(bitPattern: rawValue)) + } + switch qosClass { + case .userInteractive: + return evaluate(.userInteractive) + case .userInitiated: + return evaluate(.high) + case .default: + return evaluate(.medium) + case .utility: + return evaluate(.low) + case .background: + return evaluate(.background) + default: + return nil + } + + } + +} + + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +extension JobPriority { + + func evaluateQos() -> DispatchQoS { + return TaskPriority(rawValue: rawValue).evaluateQos() + } + + +} + diff --git a/Sources/Tetra/Concurrency/TaskValueContinuation.swift b/Sources/Tetra/Concurrency/TaskValueContinuation.swift index b74b878..a9a5541 100644 --- a/Sources/Tetra/Concurrency/TaskValueContinuation.swift +++ b/Sources/Tetra/Concurrency/TaskValueContinuation.swift @@ -53,6 +53,29 @@ enum TaskValueContinuation: Sendable { } } + borrowing + func shouldMutate(_ event: Event) -> Bool { + switch self { + case .waiting: + return true + case .suspending(_): + if case .suspend(_) = event { + return false + } else { + return true + } + case .cached(_): + if case .resume(_) = event { + return false + } else { + return true + } + case .cancelled, .finished: + return false + + } + } + private mutating func suspend(_ continuation:UnsafeContinuation) -> Effect? { switch self { case .waiting: diff --git a/Sources/Tetra/Concurrency/TetraExtension+URLSession.swift b/Sources/Tetra/Concurrency/TetraExtension+URLSession.swift index d0abd8a..3b5c166 100644 --- a/Sources/Tetra/Concurrency/TetraExtension+URLSession.swift +++ b/Sources/Tetra/Concurrency/TetraExtension+URLSession.swift @@ -6,8 +6,8 @@ // import Foundation +import Namespace -extension URLSession: TetraExtended {} @available(iOS 13.0, tvOS 13.0, macCatalyst 13.0, macOS 10.15, watchOS 6.0, *) public extension TetraExtension where Base: URLSession { diff --git a/Sources/Tetra/Concurrency/URLSessionDownloadTask+Concurrency.swift b/Sources/Tetra/Concurrency/URLSessionDownloadTask+Concurrency.swift index 9f58bab..4ca105e 100644 --- a/Sources/Tetra/Concurrency/URLSessionDownloadTask+Concurrency.swift +++ b/Sources/Tetra/Concurrency/URLSessionDownloadTask+Concurrency.swift @@ -1,6 +1,6 @@ // // URLSessionDownloadTask+Concurrency.swift -// +// // // Created by pbk on 2022/12/08. // @@ -8,6 +8,7 @@ import Foundation import Dispatch import _Concurrency +internal import CriticalSection @usableFromInline internal func randomDownloadFileURL() -> URL { diff --git a/Sources/Tetra/Foundation/ClosureHolder.swift b/Sources/Tetra/Foundation/ClosureHolder.swift index 5bdb3db..345cbd9 100644 --- a/Sources/Tetra/Foundation/ClosureHolder.swift +++ b/Sources/Tetra/Foundation/ClosureHolder.swift @@ -11,23 +11,45 @@ import CoreData #endif @usableFromInline -final class ClosureHolder { - @usableFromInline let closure: () throws -> R +internal final class ClosureHolder: @unchecked Sendable { + @usableFromInline let closure: () throws(Failure) -> R @inlinable - init(closure: @escaping () throws -> R) { + init(closure: @escaping () throws(Failure) -> R) { self.closure = closure } + + @inlinable + func callAsFunction() -> Result { + do { + let value = try closure() + return .success(value) + } catch { + return .failure(error) + } + } + } + #if canImport(CoreData) @usableFromInline -final class CoreDataContextClosureHolder { - @usableFromInline let closure: (NSManagedObjectContext) throws -> R +internal final class CoreDataContextClosureHolder { + @usableFromInline let closure: (NSManagedObjectContext) throws(Failure) -> R @inlinable - init(closure: @escaping (NSManagedObjectContext) throws -> R) { + init(closure: @escaping (NSManagedObjectContext) throws(Failure) -> R) { self.closure = closure } + + @inlinable + func callAsFunction(_ context:NSManagedObjectContext) -> Result { + do { + let value = try closure(context) + return .success(value) + } catch { + return .failure(error) + } + } } #endif diff --git a/Sources/Tetra/Foundation/Mics.swift b/Sources/Tetra/Foundation/Mics.swift index d0d94ed..6abc58b 100644 --- a/Sources/Tetra/Foundation/Mics.swift +++ b/Sources/Tetra/Foundation/Mics.swift @@ -9,45 +9,6 @@ import Foundation import Combine import os -internal enum SubscriptionStatus { - case awaitingSubscription - case subscribed(any Subscription) - case terminal - - var subscription:Subscription? { - guard case .subscribed(let subscription) = self else { - return nil - } - return subscription - } - -} - - - -@rethrows -@usableFromInline -internal protocol _ErrorMechanism { - associatedtype Output - func get() throws -> Output -} - -extension _ErrorMechanism { - // rethrow an error only in the cases where it is known to be reachable - - @usableFromInline - internal func _rethrowOrFail() rethrows -> Never { - _ = try _rethrowGet() - fatalError("materialized error without being in a throwing context") - } - - @usableFromInline - internal func _rethrowGet() rethrows -> Output { - return try get() - } -} - -extension Result: _ErrorMechanism { } diff --git a/Sources/Tetra/Foundation/PruneMemory.swift b/Sources/Tetra/Foundation/PruneMemory.swift new file mode 100644 index 0000000..e529e4f --- /dev/null +++ b/Sources/Tetra/Foundation/PruneMemory.swift @@ -0,0 +1,240 @@ +// +// PruneString.swift +// +// +// Created by 박병관 on 7/8/24. +// + +import Foundation + +#if canImport(ObjectiveC) + + +// since core foundation and objective-c foundation has it's internal optimzation +// it's hard to gurantee if memoryerasing is actullay applied. +// the only way to gurantee is to create mutable memoryerasing instance and fill it +// this method are pretty slow and not even a perfect choice. +// instances created by these methods are guranteed to fill zero to it's owned storage before deallocated. +enum MemoryErasing { + + + // create mutable String which is guarnteed to zero underlying memory when deinitialized + // this does not erase memory foot print caused by os level memory management such as paging + // briding to Swift type create implicit copy which does have memory foot print + static func createMutableString() -> NSMutableString { + + return CFStringCreateMutable(Self.allocator, 0) + } + + static func createMutableData() -> NSMutableData { + + return CFDataCreateMutable(Self.allocator, 0) + } + + + // this Data structure keeps its internal storage shared with Swift runtime + // so you can cast it to immutable Swift.Data, but do not cast to mutable Data + static func immutableData(_ source: T) -> NSData { + let fastPath = source.withContiguousStorageIfAvailable{ + return if let base = $0.baseAddress, $0.count > 0 { + CFDataCreate(allocator, base, $0.count)! + } else { + Data() as CFData + } + } + if let fastPath { + return fastPath + } + let mutable = CFDataCreateMutable(Self.allocator, 0) as NSMutableData + for page in source.regions { + mutable.increaseLength(by: page.count) + page.withUnsafeBytes{ + if let base = $0.baseAddress, $0.count > 0 { + mutable.append(base, length: $0.count) + } + } + } + return CFDataCreateCopy(allocator, mutable) + } + + static func immutableString( + decoding source: C, + as SourceEncoding: T.Type = T.self + ) -> NSString where C.Element == T.CodeUnit { + if source.isEmpty { + return "" + } + + let fastPath = source.withContiguousStorageIfAvailable{ + + if SourceEncoding == UTF8.self { + return $0.withMemoryRebound(to: UTF8.CodeUnit.self) { buffer in + if buffer.count < 14, buffer.allSatisfy(Unicode.UTF8.isASCII) { + return CFStringCreateWithBytes(allocator, buffer.baseAddress!, buffer.count, CFStringBuiltInEncodings.nonLossyASCII.rawValue, false)! as CFString? + } + return CFStringCreateWithBytes(allocator, buffer.baseAddress!, buffer.count, CFStringBuiltInEncodings.UTF8.rawValue, false)! as CFString? + } + } + if SourceEncoding == UTF16.self { + return $0.withMemoryRebound(to: UInt8.self) { buffer in + CFStringCreateWithBytes(allocator, buffer.baseAddress!, buffer.count, CFStringBuiltInEncodings.UTF16.rawValue, false)! as CFString? + + } + } + if SourceEncoding == UTF32.self { + + return $0.withMemoryRebound(to: UInt8.self) { buffer in + var normal = CFStringCreateWithBytes(allocator, buffer.baseAddress!, buffer.count, CFStringBuiltInEncodings.UTF32LE.rawValue, false) + if normal == nil { + normal = CFStringCreateWithBytes(allocator, buffer.baseAddress!, buffer.count, CFStringBuiltInEncodings.UTF32.rawValue, false) + } + return normal + } + } + return nil + } + if let fastPath, let fastPath { + return fastPath + } + + + let mutable = CFDataCreateMutable(allocator, 0) as NSMutableData + let deallocator:CFAllocator + do { + var context = CFAllocatorContext() + context.info = Unmanaged.passUnretained(mutable).toOpaque() + context.retain = { + let ptr = Unmanaged.fromOpaque($0!).retain().toOpaque() + return .init(ptr) + } + context.release = { + Unmanaged.fromOpaque($0!).release() + } + deallocator = CFAllocatorCreate(nil, &context).takeRetainedValue() + } + var encoding:CFStringBuiltInEncodings = .nonLossyASCII + let _ = transcode(source.makeIterator(), from: SourceEncoding, to: UTF8.self, stoppingOnError: false) { code in + if !Unicode.ASCII.isASCII(code) { + encoding = .UTF8 + } + withUnsafePointer(to: code) { + CFDataAppendBytes(mutable, $0, 1) + } + } + + + let msg = CFStringCreateWithBytesNoCopy(nil, mutable.bytes.assumingMemoryBound(to: UInt8.self), mutable.length, encoding.rawValue, false, deallocator)! + return msg + } + + + static func immutableString(_ reduce: (NSMutableString) -> Void) -> NSString { + let source = CFStringCreateMutable(allocator, 0)! as NSMutableString + + reduce(source) + + if source.length == 0 { + // This will return singleton empty string + return NSString() + } + if CFStringGetLength(source) < __kCFStringInlineBufferLength { + let encoding = CFStringGetFastestEncoding(source) + if encoding != kCFStringEncodingInvalidId { + var buffer = CFStringInlineBuffer() + CFStringInitInlineBuffer(source, &buffer, .init(location: 0, length: CFStringGetLength(source))) + if encoding == CFStringBuiltInEncodings.ASCII.rawValue { + return CFStringCreateWithCString(allocator, buffer.directCStringBuffer, CFStringBuiltInEncodings.nonLossyASCII.rawValue) + } + return CFStringCreateWithCString(allocator, buffer.directCStringBuffer, encoding) + } + } + return CFStringCreateCopy(allocator, source) + } + + + static func createMutableAttributeString() -> NSMutableAttributedString { + + return CFAttributedStringCreateMutable(allocator, 0) + } + + static func immutableAttributeString( + _ reduce: (NSMutableAttributedString) -> Void + ) -> NSAttributedString { + let source = CFAttributedStringCreateMutable(allocator, 0)! + reduce(source) + return CFAttributedStringCreateCopy(allocator, source) + } + + static func createUInt16BufferDeallocator(length: Int) -> CFAllocator { + var context = CFAllocatorContext() + context.deallocate = { ptr, info in + guard let ptr else { return } + let count = Int(bitPattern: info!) + let base = ptr.assumingMemoryBound(to: UniChar.self) + let buffer = UnsafeMutableBufferPointer(start: base, count: count) + buffer.update(repeating: 0) + buffer.deinitialize().deallocate() + } + context.info = .init(bitPattern: length) + context.copyDescription = { _ in + .passRetained("NoCopyNSStringDeallocator" as CFString) + } + let deallocator = CFAllocatorCreate(nil, &context).takeRetainedValue() + return deallocator + } + + + + nonisolated(unsafe) + static let allocator:CFAllocator = { + var context = CFAllocatorContext() + context.allocate = { size, hint, _ in + return malloc(size) + } + context.info = nil + context.retain = nil + context.release = nil + context.deallocate = { ptr, _ in + if let ptr { + let size = malloc_size(ptr) + // memset_s ignore compiler optimization + memset_s(ptr, size, 0, size) + free(ptr) + } + } + context.copyDescription = { _ in + return .passRetained("ZeroingCFAllocator" as CFString) + } + context.preferredSize = { size, flag, _ in + return malloc_good_size(size) + } + context.reallocate = { ptr, newSize, flag, _ in + guard let ptr, newSize > 0 else { + return nil + } + let old_size = malloc_size(ptr) + if old_size > newSize { + let diff = old_size - newSize + memset(ptr.advanced(by: newSize), 0, diff) + } + return realloc(ptr, newSize) + } + + let allocatorRef = CFAllocatorCreate(nil, &context) + return allocatorRef!.takeRetainedValue() + }() + + + + +} + +#else + +@available(*, unavailable) +enum MemoryErasing { + + +} + +#endif diff --git a/Sources/Tetra/Foundation/RunLoopSourceBlock.swift b/Sources/Tetra/Foundation/RunLoopSourceBlock.swift new file mode 100644 index 0000000..0000dc8 --- /dev/null +++ b/Sources/Tetra/Foundation/RunLoopSourceBlock.swift @@ -0,0 +1,156 @@ +// +// RunLoopSourceBlock.swift +// +// +// Created by 박병관 on 7/6/24. +// +import CoreFoundation + +@usableFromInline +enum RunLoopSourceEvent: Hashable { + /// called when Source Fire, check current Threads RunLoop more detail infomation. e.g current Mode, runLoop + case perform + /// called when Source is added to the RunLoop + case schedule(CFRunLoop, CFRunLoopMode) + /// called when Source is removed from the RunLoop + case cancel(CFRunLoop, CFRunLoopMode) +} + + +/// This creates Block based version 0 CFRunLoopSource +/// +/// schedule and cancel event can be invoke concurrenlty +/// +/// +/// - Parameters: +/// - allocator: pass `nil` unless you have a reason for it +/// - order: pass `0` unless you have a reason for it +/// - block: RunLoopSource Event handler, +/// - Returns: this block based `CFRunLoopSource` +@usableFromInline +func RunLoopSourceCreateWithHandler( + _ allocator: CFAllocator? = nil, + _ order: CFIndex = 0, + _ block: @escaping (RunLoopSourceEvent) -> () +) -> CFRunLoopSource { + typealias BlockSourceType = @convention(block) (CFRunLoop?, CFRunLoopMode?, UnsafePointer?) -> Void + let cBlock: BlockSourceType = { + switch $2?.pointee { + case .none: + block(.perform) + case .some(true): + block(.schedule($0!, $1!)) + case .some(false): + block(.cancel($0!, $1!)) + } + } + let ref = cBlock as AnyObject + let info = Unmanaged.passUnretained(ref).toOpaque() + var sourceContext = CFRunLoopSourceContext() + sourceContext.perform = { info in + let ref = Unmanaged.fromOpaque(info!).takeUnretainedValue() + + let block = unsafeBitCast(ref, to: BlockSourceType.self) + block(nil, nil, nil) + } + sourceContext.schedule = { info, runLoop, mode in + let ref = Unmanaged.fromOpaque(info!).takeUnretainedValue() + let block = unsafeBitCast(ref, to: BlockSourceType.self) + withUnsafePointer(to: true) { + block(runLoop, mode, $0) + } + } + sourceContext.cancel = { info, runLoop, mode in + let ref = Unmanaged.fromOpaque(info!).takeUnretainedValue() + let block = unsafeBitCast(ref, to: BlockSourceType.self) + withUnsafePointer(to: false) { + block(runLoop, mode, $0) + } + } + sourceContext.hash = nil + sourceContext.equal = nil + sourceContext.copyDescription = { info in + if let info { + let address = UInt(bitPattern: info) + let hex = String(address, radix: 16) + return .passRetained("RunLoopSourceCreateWithHandler (0x\(hex))" as CFString) + } else { + return .passRetained("RunLoopSourceCreateWithHandler" as CFString) + } + } + sourceContext.version = 0 + sourceContext.release = { + Unmanaged.fromOpaque($0!).release() + } + sourceContext.retain = { + .init(Unmanaged.fromOpaque($0!).retain().toOpaque()) + } + sourceContext.info = info + let source = CFRunLoopSourceCreate(allocator, order, &sourceContext)! + return source +} + +@usableFromInline +func RunLoopSourceCreateWithHandler2( + _ allocator: CFAllocator? = nil, + _ order: CFIndex = 0, + _ perform: @escaping () -> (), + _ schedule: ( (CFRunLoop, CFRunLoopMode) -> Void)? = nil, + _ cancel: ( (CFRunLoop, CFRunLoopMode) -> Void)? = nil +) -> CFRunLoopSource { + typealias BlockSourceType = @convention(block) (CFRunLoop?, CFRunLoopMode?, UnsafePointer?) -> Void + let cBlock: BlockSourceType = { + switch $2?.pointee { + case .none: + perform() + case .some(true): + schedule?($0!, $1!) + case .some(false): + cancel?($0!, $1!) + } + } + let ref = cBlock as AnyObject + let info = Unmanaged.passUnretained(ref).toOpaque() + var sourceContext = CFRunLoopSourceContext() + sourceContext.perform = { info in + let ref = Unmanaged.fromOpaque(info!).takeUnretainedValue() + + let block = unsafeBitCast(ref, to: BlockSourceType.self) + block(nil, nil, nil) + } + sourceContext.schedule = { info, runLoop, mode in + let ref = Unmanaged.fromOpaque(info!).takeUnretainedValue() + let block = unsafeBitCast(ref, to: BlockSourceType.self) + withUnsafePointer(to: true) { + block(runLoop, mode, $0) + } + } + sourceContext.cancel = { info, runLoop, mode in + let ref = Unmanaged.fromOpaque(info!).takeUnretainedValue() + let block = unsafeBitCast(ref, to: BlockSourceType.self) + withUnsafePointer(to: false) { + block(runLoop, mode, $0) + } + } + sourceContext.hash = nil + sourceContext.equal = nil + sourceContext.copyDescription = { info in + if let info { + let address = UInt(bitPattern: info) + let hex = String(address, radix: 16) + return .passRetained("RunLoopSourceCreateWithHandler (0x\(hex))" as CFString) + } else { + return .passRetained("RunLoopSourceCreateWithHandler" as CFString) + } + } + sourceContext.version = 0 + sourceContext.release = { + Unmanaged.fromOpaque($0!).release() + } + sourceContext.retain = { + .init(Unmanaged.fromOpaque($0!).retain().toOpaque()) + } + sourceContext.info = info + let source = CFRunLoopSourceCreate(allocator, order, &sourceContext)! + return source +} diff --git a/Sources/Tetra/Foundation/Suppress.swift b/Sources/Tetra/Foundation/Suppress.swift new file mode 100644 index 0000000..7a3393d --- /dev/null +++ b/Sources/Tetra/Foundation/Suppress.swift @@ -0,0 +1,46 @@ +// +// Suppress.swift +// +// +// Created by 박병관 on 6/11/24. +// + +import Foundation + +// just using to suppress sendable check for unsafe concurrent operation +@usableFromInline +struct Suppress: @unchecked Sendable { + + @inline(__always) + @usableFromInline + var value:T + + @inline(__always) + @usableFromInline + init(value: T) { + self.value = value + } + +} + +extension AsyncIteratorProtocol { + + + @usableFromInline + mutating func advanceUnsafe() async throws -> sending Element? { + nonisolated(unsafe) + var unsafe = Suppress(value: self) + defer { self = unsafe.value } + return try await unsafe.value.next() + } + +} + +extension Suppress where T: AsyncIteratorProtocol { + + @usableFromInline + mutating func advanceUnsafe() async throws -> sending T.Element? { + return try await value.next() + } + +} diff --git a/Sources/Tetra/SwiftUI/AsyncImage+BackPort.swift b/Sources/Tetra/SwiftUI/AsyncImage+BackPort.swift index 0293d01..444b138 100644 --- a/Sources/Tetra/SwiftUI/AsyncImage+BackPort.swift +++ b/Sources/Tetra/SwiftUI/AsyncImage+BackPort.swift @@ -8,6 +8,7 @@ import Foundation import Combine +import Namespace #if canImport(SwiftUI) diff --git a/Sources/Tetra/SwiftUI/Binding+Collection.swift b/Sources/Tetra/SwiftUI/Binding+Collection.swift index b610075..a664561 100644 --- a/Sources/Tetra/SwiftUI/Binding+Collection.swift +++ b/Sources/Tetra/SwiftUI/Binding+Collection.swift @@ -43,11 +43,15 @@ public struct BindingCollection: Collection { if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, macOS 12.0, watchOS 8.0, *) { return binding[position] } else { - return .init { - binding.wrappedValue[position] - } set: { newValue, transaction in + nonisolated(unsafe) + let index = position + return .init { [binding] in + binding.wrappedValue[index] + } set: { [binding] newValue, transaction in + nonisolated(unsafe) + let ref = binding withTransaction(transaction) { - binding.wrappedValue[position] = newValue + ref.wrappedValue[index] = newValue } } diff --git a/Sources/Tetra/SwiftUI/RefreshableScrollView.swift b/Sources/Tetra/SwiftUI/RefreshableScrollView.swift index d9bcae2..52a432d 100644 --- a/Sources/Tetra/SwiftUI/RefreshableScrollView.swift +++ b/Sources/Tetra/SwiftUI/RefreshableScrollView.swift @@ -18,10 +18,10 @@ import UIKit @available(macOS, deprecated: 13, renamed: "ScrollView") @available(watchOS, deprecated: 9, renamed: "ScrollView") public struct RefreshableScrollView: View { - +#if os(iOS) || targetEnvironment(macCatalyst) @State private var task:Task? = nil @State private var flag = false - +#endif public var content:Content public var axes: Axis.Set = .vertical public var showsIndicators: Bool = true @@ -42,9 +42,11 @@ public struct RefreshableScrollView: View { } .onDisappear{ +#if os(iOS) || targetEnvironment(macCatalyst) task?.cancel() task = nil flag = false +#endif } } @@ -81,56 +83,38 @@ public extension View { } @usableFromInline internal -struct RefreshActionModifier: EnvironmentalModifier { +struct RefreshActionModifier: ViewModifier { @Binding var task:Task? @Binding var refreshing:Bool + @Environment(\.self) private var environment @usableFromInline - func resolve(in environment: EnvironmentValues) -> ResolvedModifier { - var modifier = ResolvedModifier(task: $task, refreshing: refreshing) - if #available(iOS 15.0, tvOS 15.0, macOS 12.0, macCatalyst 15.0, watchOS 8.0, *), - let refresh = environment.refresh { - modifier.action = { - refreshing = true - await refresh() - refreshing = false - } - } else if let refresh = environment.refreshControl { - modifier.action = { - refreshing = true - await refresh.action() - refreshing = false - } - } - return modifier - } - - @usableFromInline - struct ResolvedModifier: ViewModifier { - @usableFromInline - @Binding var task:Task? - @usableFromInline - var refreshing:Bool - @usableFromInline - var action:( () async -> ())? - - @usableFromInline - func body(content: Content) -> some View { + func body(content: Content) -> some View { #if os(iOS) || targetEnvironment(macCatalyst) - content.background(Group{ + content.background( + Group{ + let action = if #available(iOS 15.0, tvOS 15.0, macOS 12.0, macCatalyst 15.0, watchOS 8.0, *) { + environment.refresh?.callAsFunction + } else { + environment.refreshControl?.action + } if let action { - ScrollRefreshImp(task: $task, refreshing: refreshing, operation: action) - .frame(width: 0, height: 0) + ScrollRefreshImp(task: $task, refreshing: refreshing) { + refreshing = true + defer { refreshing = false } + await action() + }.frame(width: 0, height: 0) } - }) + + } + ) #else - content + content #endif - } - } + } diff --git a/Sources/Tetra/TetraExtension.swift b/Sources/Tetra/TetraExtension.swift index 2a3c8b3..c3b9726 100644 --- a/Sources/Tetra/TetraExtension.swift +++ b/Sources/Tetra/TetraExtension.swift @@ -6,57 +6,7 @@ // import Foundation - -public struct TetraExtension { - - @usableFromInline - internal var value:Base - @inlinable - public var base:Base { - get { value } - } - - @inlinable - public init(base: Base) { - self.value = base - } - - @inlinable - public init(_ base: Base) { - self.value = base - } - - -} - - -extension TetraExtension: Sendable where Base:Sendable {} - -public protocol TetraExtended { - /// Type being extended. - associatedtype Base - - /// Static Tetra extension point. - @inlinable - static var tetra: TetraExtension.Type { get set } - /// Instance Tetra extension point. - @inlinable - var tetra: TetraExtension { get set } -} - -extension TetraExtended { - /// Static Tetra extension point. - @inlinable - public static var tetra: TetraExtension.Type { - get { TetraExtension.self } - set {} - } - - /// Instance Tetra extension point. - @inlinable - public var tetra: TetraExtension { - get { TetraExtension(self) } - set {} - } -} - +//public import Namespace +//public import NamespaceExtension +//public import BackPortAsyncSequence +//public import CriticalSection diff --git a/Sources/TetraRunLoopConcurrency/FixedSizedElement.swift b/Sources/TetraRunLoopConcurrency/FixedSizedElement.swift new file mode 100644 index 0000000..309b08a --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/FixedSizedElement.swift @@ -0,0 +1,103 @@ +// +// ThreeElement.swift +// Tetra +// +// Created by 박병관 on 7/7/26. +// + + +struct ThreeElement:~Copyable { + var e0: T + var e1: T + var e2: T + + init(_ body: (Int) -> T) { + e0 = body(0) + e1 = body(1) + e2 = body(2) + } + + + + init(repeating value: T) where T:Copyable{ + self.init { _ in value } + } + + var indices: Range { 0..<3 } + + subscript(i: Int) -> T { + _read { + switch i { + case 0: yield e0 + case 1: yield e1 + case 2: yield e2 + default: preconditionFailure("ThreeElement index out of bounds: \(i)") + } + } + _modify { + switch i { + case 0: yield &e0 + case 1: yield &e1 + case 2: yield &e2 + default: preconditionFailure("ThreeElement index out of bounds: \(i)") + } + } + } +} + +extension ThreeElement: Sendable where T: Sendable {} +extension ThreeElement:Copyable where T:Copyable {} +extension ThreeElement:BitwiseCopyable where T:BitwiseCopyable {} + +/// Five inline elements, indexable `0...4` — the QoS ready lanes. Supports noncopyable +/// `T` (the per-lane MPSC queues and QoS-override atomics). +internal struct FiveElement: ~Copyable { + var e0: T + var e1: T + var e2: T + var e3: T + var e4: T + + public init(_ body: (Int) -> T) { + e0 = body(0) + e1 = body(1) + e2 = body(2) + e3 = body(3) + e4 = body(4) + } + init(repeating value: T) where T:Copyable{ + self.init { _ in value } + } + + public var indices: Range { 0..<5 } + public var startIndex: Int { 0 } + public var endIndex: Int { 5 } + + public subscript(i: Int) -> T { + _read { + switch i { + case 0: yield e0 + case 1: yield e1 + case 2: yield e2 + case 3: yield e3 + case 4: yield e4 + default: preconditionFailure("FiveElement index out of bounds: \(i)") + } + } + _modify { + switch i { + case 0: yield &e0 + case 1: yield &e1 + case 2: yield &e2 + case 3: yield &e3 + case 4: yield &e4 + default: preconditionFailure("FiveElement index out of bounds: \(i)") + } + } + + } +} + +extension FiveElement: Sendable where T: Sendable & ~Copyable {} +extension FiveElement: Copyable where T:Copyable {} +extension FiveElement: BitwiseCopyable where T:BitwiseCopyable {} diff --git a/Sources/TetraRunLoopConcurrency/KQScheduler.swift b/Sources/TetraRunLoopConcurrency/KQScheduler.swift new file mode 100644 index 0000000..4478bee --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/KQScheduler.swift @@ -0,0 +1,463 @@ +// +// KQScheduler.swift +// TetraRunLoopConcurrency +// +// The kqueue run-loop ENGINE. Ported from swift-platform-executors' +// `KQScheduler2` (the engine half of `StackBoundRunLoopExecutor2`), adapted to +// Tetra's primitives: +// +// * `AtomicStore` (not Synchronization `Atomic`) for the phase / wake / stop flags. +// * `SlicedJobQueue` (ready lanes only) for the 5-QoS ready queues. +// * The timer state lives HERE (DESIGN A): three `Heap` domains +// behind one `createCheckedStateLock`, replacing the reference's `MinMaxHeap` +// `TimerState`. The queue no longer owns any timer state. +// +// The engine wraps the kqueue in a CFFileDescriptor run-loop source, drains the +// ready lanes in `handleReadable`, fires due timers into the ready lanes, and arms +// the earliest pending deadline per domain via `EVFILT_TIMER`. +// + +#if canImport(Darwin) +import Atomics +import Darwin +import Dispatch +import Foundation +import CoreFoundation +import CriticalSection +import HeapModule +import Builtin + +@available(macOS 10.15, iOS 13, *) +final class KQScheduler: @unchecked Sendable { + + /// Lifecycle phase. Stored as a raw `UInt8` in an `AtomicStore` (Tetra's atomic + /// primitive), since `AtomicStore` requires `Value: AtomicValue`. + enum Phase: UInt8 { case live = 0, closing = 1, dead = 2 } + + // MARK: Stored state + + private let facade: StackBoundRunLoopExecutor + private let serial: UnownedSerialExecutor + /// Optional task-executor reference threaded through to `processLane`. + private let taskRefOrNil: Builtin.Executor? + let cfRunLoop: CFRunLoop + let thread: pthread_t + /// Raw kqueue descriptor, valid from init; exposed for cross-thread wakeups. + let kqueueFD: Int32 + + /// The 5-QoS ready lanes. Drained (only) by `drainReadyJobs` on the owning thread. + let ready = SlicedJobQueue(cacheSize: 2048) + + /// Pending-timer heaps and the armed-deadline record, per clock domain, together + /// behind one lock — the reference engine's `TimerState` shape. Producers arm the + /// kqueue `EVFILT_TIMER` directly under this lock (`kevent64` is thread-safe), so + /// there is no producer/owning-thread split-brain over the armed idents and no + /// `EVFILT_USER` wakeup per timer enqueue. + struct TimerState { + /// Monotonic insertion counter — the FIFO tie-break for equal deadlines. + var sequence: UInt64 = 0 + /// One min-heap per clock domain — continuous / suspending / wall. MOVED here + /// from `SlicedJobQueue` (DESIGN A). + var heaps: ThreeElement> = .init(repeating: .init()) + /// The deadline currently armed on the kqueue per domain (`nil` = unarmed). + /// `armIfEarlierLocked` re-arms a domain only when the candidate is strictly + /// earlier than what is armed, and one-shot fires clear the entry — + /// eliminating the per-pump re-arm `kevent64` thrash of unconditional arming. + var armed: ThreeElement = .init(repeating: nil) + } + + let timers: some UnfairStateLock = + createCheckedStateLock(checkedState: TimerState()) + + /// Timers currently in the heaps. Lets the drain pump skip the whole timer pass + /// (no lock, no clock reads) when zero — the common, timer-free case. + private let timerCount = ManagedAtomic(0) + + /// Owning-thread-only scratch for due jobs, so fired timer jobs are moved into + /// the ready lanes OUTSIDE the timer lock; reused so steady state allocates nothing. + private nonisolated(unsafe) var dueBuffer = ContiguousArray() + + /// Arm `index` if `candidate` is strictly earlier than what is armed (or nothing + /// is). Caller holds `timers`. Reads the domain clock only when it actually arms. + private func armIfEarlierLocked(_ state: inout TimerState, index: SlicedJobQueue.ClockIndex, candidate: Timestamp) { + let raw = index.rawValue + if let armedStamp = state.armed[raw], candidate.deadline >= armedStamp.deadline { return } + KQueueSelector.armTimer(fileDescriptor: kqueueFD, index: index, + target: candidate.target, leeway: candidate.leeway, + now: KQueueSelector.now(index: index)) + state.armed[raw] = candidate + } + + /// True while a drain is pending/imminent; producers elide the wakeup when they + /// lose the false→true race. RMW-only (see `handleReadable`). + private let pendingJobPop = AtomicStore(false) + private let stopFlag = AtomicStore(false) + private let phaseStorage = AtomicStore(Phase.live.rawValue) + + nonisolated(unsafe) var deferredReadable = false + /// True once the CFFileDescriptor exists: from then it owns the kqueue fd + /// (closeOnInvalidate) and the frame invalidates it, so `deinit` must not close. + private nonisolated(unsafe) var didCreateFileDescriptor = false + + // MARK: Init / deinit + + /// - Parameter taskRef: the facade's task-executor reference (its + /// `asUnownedTaskExecutor()._executor`) on the iOS-18 `TaskExecutor` path, or + /// `nil` on the iOS-13 `SerialExecutor`-only path. When non-nil, `processLane` + /// runs jobs via `runSynchronously(isolatedTo:taskExecutor:)` so a task's + /// preferred task executor is respected (Task 5). + init(facade: StackBoundRunLoopExecutor, serial: UnownedSerialExecutor, + taskRef: Builtin.Executor?, cfRunLoop: CFRunLoop) { + self.facade = facade + self.serial = serial + self.taskRefOrNil = taskRef + self.cfRunLoop = cfRunLoop + self.thread = pthread_self() + self.kqueueFD = KQueueSelector.makeKQueue() + } + + deinit { + // Only the never-mounted path (no descriptor ever created) closes the fd; + // otherwise the CFFileDescriptor closes it on invalidation. + if !didCreateFileDescriptor { + close(kqueueFD) + } + } + + // MARK: Phase helpers + + private var phase: Phase { Phase(rawValue: phaseStorage.load(ordering: .acquiring))! } + + var isStopRequested: Bool { stopFlag.load(ordering: .acquiring) } + + // MARK: Enqueue (producer side — the facade holds `producerGate` across this) + + func enqueueReady(_ job: UnownedJob) { + let phase = self.phase + if phase == .dead { + preconditionFailure("StackBoundRunLoopExecutor.enqueue(_:) called after its run loop finished.") + } + if phase == .closing, pthread_equal(thread, pthread_self()) == 0 { + preconditionFailure("StackBoundRunLoopExecutor.enqueue(_:) raced with its run loop shutting down.") + } + ready.enqueue(job, thread) + if phase == .live { + // Deliberately an unconditional RMW, never load-then-exchange: the RMW + // chain on this variable is what guarantees the pump's clearing + // `exchange(false)` synchronizes-with our lane push when we skip the wake. + if pendingJobPop.exchange(true, ordering: .acquiringAndReleasing) == false { + KQueueSelector.wakeup(fileDescriptor: kqueueFD) + } + } + } + + func enqueueBatch(_ jobs: ContiguousArray) { + precondition(phase == .live, + "StackBoundRunLoopExecutor.enqueueBatch(_:) called after its run loop finished.") + for job in jobs { + ready.enqueue(job, thread) + } + } + + /// Inserts a delayed job and, if it is a strictly-earlier deadline for its domain, + /// arms that domain's kqueue timer right here, under the lock — no `EVFILT_USER` + /// wakeup round trip (an already-due deadline arms `0` and fires immediately, so + /// the kernel itself wakes the pump). The timestamp computation was MOVED here + /// from `SlicedJobQueue.enqueue(_:after:...)`. + @available(iOS 16, macOS 13, watchOS 9, tvOS 16, visionOS 1, *) + func enqueueTimer(_ job: UnownedJob, after delay: Duration, tolerance: Duration?, index: SlicedJobQueue.ClockIndex) { + guard phase == .live else { + preconditionFailure("StackBoundRunLoopExecutor.enqueue(_:after:...) called while shutting down.") + } + let timestamp = Self.timestamp(after: delay, tolerance: tolerance, index: index) + timerCount.wrappingIncrement(ordering: .releasing) + timers.withLock { state in + state.sequence &+= 1 + state.heaps[index.rawValue].insert( + TimestampJob(job: job, sequence: state.sequence, timestamp: timestamp) + ) + armIfEarlierLocked(&state, index: index, candidate: timestamp) + } + } + + /// Resolves a delay + tolerance into a domain fire deadline in the same mach units + /// `KQueueSelector.now(index:)` reports. Moved verbatim from the old + /// `SlicedJobQueue.enqueue(_:after:tolerance:index:)`. + @available(iOS 16, macOS 13, watchOS 9, tvOS 16, visionOS 1, *) + private static func timestamp(after delay: Duration, tolerance: Duration?, index: SlicedJobQueue.ClockIndex) -> Timestamp { + let (delaySec, delayAtto) = delay.components + let dispatch_now: UInt64 + switch index { + case .continuous: + let mask = 1 << 63 as dispatch_time_t + dispatch_now = mask + case .suspending: + dispatch_now = 0 + case .walltime: + dispatch_now = .init(DISPATCH_WALLTIME_NOW) + } + let dispatch_target = Dispatch.__dispatch_time( + dispatch_now, + delaySec * Int64(Dispatch.NSEC_PER_SEC) + Int64(delayAtto / 1_000_000_000) + ) + let dispatch_deadline: dispatch_time_t + if let tolerance { + let (tol_sec, tol_atto) = tolerance.components + dispatch_deadline = Dispatch.__dispatch_time( + dispatch_target, + Int64(Dispatch.NSEC_PER_SEC) * tol_sec + Int64(tol_atto / 1_000_000_000) + ) + } else { + dispatch_deadline = dispatch_target + } + switch index { + case .continuous: + return .init(target: dispatch_target & ~dispatch_now, + leeway: (dispatch_deadline & ~dispatch_now) - (dispatch_target & ~dispatch_now)) + case .suspending: + return .init(target: dispatch_target, leeway: dispatch_deadline - dispatch_target) + case .walltime: + return .init(target: 0 &- dispatch_target, + leeway: (0 &- dispatch_deadline) - (0 &- dispatch_target)) + } + } + + // MARK: Stop + + func requestStop() { + stopFlag.store(true, ordering: .releasing) + KQueueSelector.wakeup(fileDescriptor: kqueueFD) + } + func clearStopRequested() { stopFlag.store(false, ordering: .releasing) } + + // MARK: Drain pump (owning thread, from the CFFileDescriptor callout) + + func handleReadable(_ fd: CFFileDescriptor) { + if withUnsafeCurrentTask(body: { $0 != nil }) { + deferredReadable = true + // Leave the callback disabled; the beforeWaiting observer re-enables it. + return + } + deferredReadable = false + pendingJobPop.store(true, ordering: .relaxed) + // The explicit pool bounds job-autoreleased objects to this pass — a bare + // thread's CFRunLoop is not guaranteed to push one of its own on every OS + // Tetra supports. + autoreleasepool { + let fired = KQueueSelector.drainEvents(fileDescriptor: kqueueFD) // consume wake + timer fires + let hadTimers = timerCount.load(ordering: .acquiring) > 0 + // Fire the due pass whenever timers exist OR the kqueue reported a timer + // firing (so a fire is never dropped even if the count just changed). + if hadTimers || fired.continuous || fired.suspending || fired.wall { + fireDueTimers(fired) // pop due timers -> ready lanes + } + drainReadyJobs() // drain the 5 ready lanes only + if timerCount.load(ordering: .acquiring) > 0 { + armNextDeadlines() // arm EVFILT_TIMER from heap mins + } + } + var refire = !readyLanesEmpty() + if !refire { + _ = pendingJobPop.exchange(false, ordering: .acquiringAndReleasing) + refire = !readyLanesEmpty() + } + if refire { + KQueueSelector.wakeup(fileDescriptor: kqueueFD) + } else { + // Busy period ended (I4a): release the QoS overrides here, once, instead of + // per drain. Mirrors libdispatch's runloop-queue discipline. + ready.endAllBoosts() + } + // Predicate unwinding (Task 3 concern #1): the facade binds `currentPredicate` + // around a `runUntil` frame. After draining, evaluate it; if satisfied, stop + // the run loop so the frame unwinds. `run()` binds it to nil, so this is a + // no-op there. + let predicateSatisfied: Bool = StackBoundRunLoopExecutor.currentPredicate?.block() ?? false + if stopFlag.load(ordering: .acquiring) { + CFRunLoopStop(CFRunLoopGetCurrent()) + } else if predicateSatisfied { + stopFlag.store(true, ordering: .releasing) + CFRunLoopStop(CFRunLoopGetCurrent()) + } + CFFileDescriptorEnableCallBacks(fd, kCFFileDescriptorReadCallBack) + } + + /// Pop due entries (per domain, `timestamp.target <= now`) into `dueBuffer` and + /// feed them to the ready lanes OUTSIDE the lock, so no MPSC push (or override + /// syscall) ever happens while producers wait on `timers`. Also clears the armed + /// record for domains the kqueue reported as fired — a one-shot EVFILT_TIMER that + /// fired is disarmed in the kernel, so the next arm pass must re-arm that domain. + /// Clock reads happen lazily, only for non-empty heaps. + private func fireDueTimers(_ fired: KQueueSelector.FiredDomains) { + dueBuffer.removeAll(keepingCapacity: true) + var firedCount = 0 + timers.withLockUnchecked { state in + if fired.continuous { state.armed[0] = nil } + if fired.suspending { state.armed[1] = nil } + if fired.wall { state.armed[2] = nil } + for raw in 0..<3 { + guard state.heaps[raw].min != nil else { continue } + let now = KQueueSelector.now(index: SlicedJobQueue.ClockIndex(rawValue: raw)!) + var popped = false + while let box = state.heaps[raw].min, box.timestamp.target <= now { + state.heaps[raw].removeMin() + dueBuffer.append(box.job) + firedCount &+= 1 + popped = true + } + // The armed min was just consumed — clear so the next arm pass re-arms + // the new min (guards against a stale `armed` skipping the next deadline). + if popped { state.armed[raw] = nil } + } + } + if firedCount > 0 { + timerCount.wrappingDecrement(by: firedCount, ordering: .releasing) + } + // `thread: nil`: the pump is about to drain these itself — no boost install. + for job in dueBuffer { + ready.enqueue(job, nil) + } + dueBuffer.removeAll(keepingCapacity: true) + } + + /// Arm the earliest not-yet-due deadline per domain — but only when it is strictly + /// earlier than what is already armed, so a steady state with an unchanged min + /// issues no `kevent64` per pump pass. + private func armNextDeadlines() { + timers.withLockUnchecked { state in + for raw in 0..<3 { + let index = SlicedJobQueue.ClockIndex(rawValue: raw)! + guard let stamp = state.heaps[raw].min?.timestamp else { continue } + armIfEarlierLocked(&state, index: index, candidate: stamp) + } + } + } + + /// Drains the 5 ready lanes on the owning thread, highest-priority-first. + /// + /// Two disciplines ported from the reference engine: + /// * I4a (QoS-once): the per-lane `pthread_override` boosts are NOT ended + /// here. They are held across drains and released ONCE by `endAllBoosts()` + /// on the idle path (no refire) / at teardown — avoiding per-drain start/end + /// syscall thrash and a mid-busy priority drop. The drain never alters the + /// owning thread's own QoS: effective priority is thread base + overrides, + /// matching libdispatch's runloop-queue discipline — low-QoS jobs are not + /// demoted below the thread's base. + /// * I4b (fairness cap): each lane's dequeue loop is capped at + /// `getDrainIterations(queueIndex:)`, so a high-priority flood cannot starve + /// the run loop's other CFRunLoop sources. A capped lane may leave jobs; the + /// pump re-checks `readyLanesEmpty()` and re-fires until empty. + private func drainReadyJobs() { + for index in 0..<5 { + processLane(index) + } + } + + private func processLane(_ index: Int) { + let iterations = getDrainIterations(queueIndex: index) + var count = 0 + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *), let t = taskRefOrNil { + let taskExecutor = UnownedTaskExecutor(t) + while count < iterations, let j = ready.jobs[index].dequeue() { + j.runSynchronously(isolatedTo: serial, taskExecutor: taskExecutor) + count &+= 1 + } + } else { + while count < iterations, let j = ready.jobs[index].dequeue() { + j.runSynchronously(on: serial) + count &+= 1 + } + } + } + + private func readyLanesEmpty() -> Bool { + for i in 0..<5 where !ready.jobs[i].isEmpty { return false } + return true + } + + // MARK: Shutdown + + func beginClosing() { + let old = phaseStorage.exchange(Phase.closing.rawValue, ordering: .acquiringAndReleasing) + precondition(old == Phase.live.rawValue, "KQScheduler.beginClosing() called in an invalid phase.") + } + + /// Drains the ready lanes to empty once the facade's producer gate quiesces, drops + /// any not-yet-due timers, and transitions to `.dead`. + func finishAndDie() { + while true { + drainReadyJobs() + if facade.producerGate.load(ordering: .acquiring) == 0 { + drainReadyJobs() + if readyLanesEmpty() { + // Final teardown release of any QoS overrides (I4a backstop before + // the SlicedJobQueue.deinit backstop). + ready.endAllBoosts() + dropPendingTimers() + phaseStorage.store(Phase.dead.rawValue, ordering: .releasing) + break + } + } else { + _ = sched_yield() + } + } + } + + + /// Not-yet-due timers are dropped at unwind (lifecycle contract). The kqueue + /// timers themselves die when the descriptor is invalidated in the frame's defer. + private func dropPendingTimers() { + timers.withLockUnchecked { state in + for raw in 0..<3 { + state.heaps[raw] = .init() + state.armed[raw] = nil + } + } + timerCount.store(0, ordering: .releasing) + } + + // MARK: Run-loop source + + /// Creates the CFFileDescriptor wrapping the kqueue. Its context retains this + /// scheduler; the facade's frame owns the descriptor and invalidates it on exit + /// (closeOnInvalidate closes the kqueue then). Called exactly once, from the + /// facade's outermost run frame. + func makeFileDescriptor() -> CFFileDescriptor { + precondition(!didCreateFileDescriptor, "makeFileDescriptor() called more than once") + let names = ["CFRetain", "CFRelease", "CFCopyDescription"] + var buffer = Array(repeating: nil, count: names.count) + CFBundleGetFunctionPointersForNames( + CFBundleGetBundleWithIdentifier("com.apple.CoreFoundation" as CFString), + names as CFArray, &buffer + ) + let _CFRetain = unsafeBitCast(buffer[0], to: (@convention(c) (UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer?).self) + let _CFRelease = unsafeBitCast(buffer[1], to: (@convention(c) (UnsafeMutableRawPointer?) -> Void).self) + let _CFCopyDescription = unsafeBitCast(buffer[2], to: (@convention(c) (UnsafeMutableRawPointer?) -> Unmanaged?).self) + + var context = CFFileDescriptorContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: _CFRetain, + release: _CFRelease, + copyDescription: _CFCopyDescription + ) + let callback: CFFileDescriptorCallBack = { fd, _, info in + guard let info, let fd else { return } + Unmanaged.fromOpaque(info).takeUnretainedValue().handleReadable(fd) + } + guard let created = CFFileDescriptorCreate(nil, kqueueFD, true, callback, &context) else { + preconditionFailure("Failed to create CFFileDescriptor for kqueue") + } + didCreateFileDescriptor = true + return created + } + + /// Owning-thread teardown, run in the facade frame's defer. The kqueue's one-shot + /// timers are torn down with the descriptor (closeOnInvalidate); nothing to disarm + /// here beyond clearing the deferred-readable latch. + func teardown() { + deferredReadable = false + } +} + +#endif diff --git a/Sources/TetraRunLoopConcurrency/KQueueSelector.swift b/Sources/TetraRunLoopConcurrency/KQueueSelector.swift new file mode 100644 index 0000000..bbcab06 --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/KQueueSelector.swift @@ -0,0 +1,206 @@ +// +// KQueueSelector.swift +// TetraRunLoopConcurrency +// +// A STATELESS kqueue syscall wrapper. Holds NO state — every function takes the +// raw descriptor. Ported from swift-platform-executors KQueueSelector2.swift. +// +// * Wakeups use an `EVFILT_USER` event (ident 0). +// * Timers use one-shot `EVFILT_TIMER` events in the mach clock domains +// (idents 1/2/3 = continuous/suspending/wall, matching ClockIndex rawValues 0/1/2). +// + +#if canImport(Darwin) +import Darwin +import Dispatch + +/// Minimal error type for `kevent64(2)` failures, available at iOS 13 / macOS 10.15. +/// Callers only `assertionFailure`/`preconditionFailure` on catch, so the exact type +/// is cosmetic — `System.Errno` (macOS 11+) is intentionally avoided here. +struct KQueueSyscallError: Error { + let code: Int32 +} + +/// Thin `kqueue` descriptor wrapper: issues `kevent64(2)` and surfaces failures as +/// `KQueueSyscallError`. `rawValue` is the kqueue file descriptor. Stateless — safe +/// to construct on demand around a descriptor from any thread. +struct KQueueHelper: RawRepresentable, @unchecked Sendable { + var rawValue: Int32 + init(rawValue: Int32) { self.rawValue = rawValue } + + /// Direct `kevent64(2)` call. Returns the number of events placed in `eventlist` + /// (0 for a pure registration); throws `Errno` on a -1 return. + @discardableResult + func kevent64( + changelist: UnsafePointer?, nchanges: CInt, + eventlist: UnsafeMutablePointer?, nevents: CInt, + flags: UInt32, timeout: UnsafePointer? + ) throws -> CInt { + let result = Darwin.kevent64(rawValue, changelist, nchanges, eventlist, nevents, flags, timeout) + if result == -1 { throw KQueueSyscallError(code: errno) } + return result + } + + /// Registers a change set (no events collected): a pure `kevent64` registration. + func applyEventChangeSet(_ changes: UnsafeMutableBufferPointer) throws { + _ = try kevent64( + changelist: UnsafePointer(changes.baseAddress), nchanges: CInt(changes.count), + eventlist: nil, nevents: 0, flags: 0, timeout: nil + ) + } +} + +enum KQueueSelector { + + /// Which timer domains fired in a `drainEvents` pass. The engine uses this to + /// clear the corresponding armed deadline under its timer mutex. + struct FiredDomains { + var continuous = false + var suspending = false + var wall = false + } + + // MARK: Setup + + /// Creates a kqueue and registers the `EVFILT_USER` wakeup channel (ident 0). + static func makeKQueue() -> Int32 { + let fd = Darwin.kqueue() + precondition(fd >= 0, "kqueue() failed: \(String(cString: strerror(errno)))") + var event = kevent64_s() + event.ident = 0 + event.filter = Int16(EVFILT_USER) + event.fflags = UInt32(bitPattern: NOTE_FFNOP) + event.flags = UInt16(EV_ADD | EV_ENABLE | EV_CLEAR) + do { + try withUnsafeMutablePointer(to: &event) { + try KQueueHelper(rawValue: fd).applyEventChangeSet( + UnsafeMutableBufferPointer(start: $0, count: 1) + ) + } + } catch { + preconditionFailure("Failed to install kqueue user event: \(error)") + } + return fd + } + + // MARK: Wakeup + + /// Posts the `EVFILT_USER` wakeup, making the kqueue readable. Safe from any + /// thread; touches no shared state beyond the descriptor. + static func wakeup(fileDescriptor: Int32) { + var event = kevent64_s() + event.ident = 0 + event.filter = Int16(EVFILT_USER) + event.fflags = UInt32(NOTE_TRIGGER | NOTE_FFNOP) + do { + _ = try withUnsafePointer(to: &event) { + try KQueueHelper(rawValue: fileDescriptor).kevent64( + changelist: $0, nchanges: 1, eventlist: nil, nevents: 0, + flags: UInt32(KEVENT_FLAG_IMMEDIATE), timeout: nil + ) + } + } catch { + preconditionFailure("Failed to signal kqueue wakeup: \(error)") + } + } + + // MARK: Drain + + /// Consumes pending events non-blocking; returns which timer domains fired. + /// The `EVFILT_USER` wakeup is consumed and reported by no flag (it only + /// exists to make the kqueue readable). + static func drainEvents(fileDescriptor: Int32, maxEvents: Int = 8) -> FiredDomains { + var fired = FiredDomains() + withUnsafeTemporaryAllocation(of: kevent64_s.self, capacity: maxEvents) { buffer in + do { + let count = try KQueueHelper(rawValue: fileDescriptor).kevent64( + changelist: nil, nchanges: 0, + eventlist: buffer.baseAddress!, nevents: CInt(maxEvents), + flags: UInt32(KEVENT_FLAG_IMMEDIATE), timeout: nil + ) + for index in 0.. UInt64 { + switch index { + case .continuous: + // continuous: __dispatch_time(1<<63, 0) & ~(1<<63) + Dispatch.__dispatch_time(1 << 63, 0) & ~(1 << 63) + case .suspending: + // suspending: __dispatch_time(0, 0) + Dispatch.__dispatch_time(0, 0) + case .walltime: + // walltime: 0 &- __dispatch_walltime(nil, 0) + 0 &- Dispatch.__dispatch_walltime(nil, 0) + } + } + + // MARK: Arm + + /// Arms (or replaces) the one-shot timer for `index`. Re-arming the same + /// ident replaces the prior arming, so a producer installing an earlier + /// deadline overwrites a later one. `data` is the interval in the domain's + /// mach units; a past deadline arms `0`, firing immediately. + /// + /// - Parameters: + /// - fileDescriptor: The kqueue file descriptor. + /// - index: The clock domain (`SlicedJobQueue.ClockIndex`). + /// - target: The earliest acceptable instant (same units as `now(index:)`). + /// - leeway: Leeway window for timer coalescing (0 = no leeway). + /// - now: The current instant from `now(index:)` for computing the interval. + static func armTimer( + fileDescriptor: Int32, + index: SlicedJobQueue.ClockIndex, + target: UInt64, + leeway: UInt64, + now: UInt64 + ) { + var event = kevent64_s() + event.filter = Int16(EVFILT_TIMER) + event.flags = UInt16(EV_ADD | EV_ENABLE | EV_ONESHOT) + event.data = target > now ? Int64(target - now) : 0 + switch index { + case .continuous: + // ident 1, fflags: NOTE_MACH_CONTINUOUS_TIME | NOTE_MACHTIME + event.ident = 1 + event.fflags = UInt32(NOTE_MACH_CONTINUOUS_TIME | NOTE_MACHTIME) + case .suspending: + // ident 2, fflags: NOTE_MACHTIME + event.ident = 2 + event.fflags = UInt32(NOTE_MACHTIME) + case .walltime: + // ident 3, fflags: NOTE_NSECONDS | NOTE_MACH_CONTINUOUS_TIME + event.ident = 3 + event.fflags = UInt32(NOTE_NSECONDS | NOTE_MACH_CONTINUOUS_TIME) + } + if leeway != 0 { + event.fflags |= UInt32(NOTE_LEEWAY) + event.ext = (event.ext.0, leeway) + } + do { + try withUnsafeMutablePointer(to: &event) { + try KQueueHelper(rawValue: fileDescriptor).applyEventChangeSet( + UnsafeMutableBufferPointer(start: $0, count: 1) + ) + } + } catch { + assertionFailure("Failed to arm kqueue timer: \(error)") + } + } +} +#endif diff --git a/Sources/TetraRunLoopConcurrency/MPMCBoundedQueue.swift b/Sources/TetraRunLoopConcurrency/MPMCBoundedQueue.swift new file mode 100644 index 0000000..561d5c4 --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/MPMCBoundedQueue.swift @@ -0,0 +1,150 @@ +// +// MPMCBoundedQueue.swift +// Tetra +// +// Created by 박병관 on 2/14/26. +// +import Atomics +import CriticalSection + +internal struct MPMCBoundedQueue: ~Copyable, @unchecked Sendable { + @usableFromInline + internal struct BufferNode: ~Copyable { + @usableFromInline + internal var data: Element? + + @usableFromInline + internal let sequence: AtomicStore = .init(0) + + @inlinable + init(data: consuming Element) { + self.data = consume data + } + + @inlinable + init() { + self.data = nil + } + } + + @usableFromInline + internal let mask: Int + + @usableFromInline + internal let buffer: ManagedBuffer + + @usableFromInline + internal let head: AtomicStore = .init(0) + + @usableFromInline + internal let tail: AtomicStore = .init(0) + + public var count: Int { + let headIndex = head.load(ordering: .relaxed) + let tailIndex = tail.load(ordering: .relaxed) + return tailIndex < headIndex ? (buffer.header - headIndex + tailIndex) : (tailIndex - headIndex) + } + + public var wasFull: Bool { + buffer.header - count == 1 + } + + public init(size: Int) { + let size = size.nextPowerOf2() + self.mask = size - 1 + self.buffer = .create(minimumCapacity: size, makingHeaderWith: { _ in + size + }) + buffer.withUnsafeMutablePointerToElements { + let pointer = UnsafeMutableBufferPointer(start: $0, count: size) + for i in 0.. sending Element? { + var result:Element? = consume value + return buffer.withUnsafeMutablePointers { + + let pointer = UnsafeMutableBufferPointer(start: $1, count: $0.pointee) + var node: UnsafeMutablePointer! + var pos = tail.load(ordering: .relaxed) + + while true { + node = pointer.baseAddress?.advanced(by: pos & mask) + let seq = node.pointee.sequence.load(ordering: .acquiring) + let difference = seq - pos + + if difference == 0 { + if tail.weakCompareExchange(expected: pos, + desired: pos + 1, + successOrdering: .relaxed, + failureOrdering: .relaxed).exchanged { + break + } + } else if difference < 0 { + let a = consume result + result = nil + return a + } else { + pos = tail.load(ordering: .relaxed) + } + } + swap(&node.pointee.data, &result) + + node.pointee.sequence.store(pos + 1, ordering: .releasing) + return nil + } + + } + + @inlinable + public func dequeue() -> sending Element? { + return buffer.withUnsafeMutablePointers { + let pointer = UnsafeMutableBufferPointer(start: $1, count: $0.pointee) + var node: UnsafeMutablePointer! + var pos = head.load(ordering: .relaxed) + + while true { + node = pointer.baseAddress?.advanced(by: pos & mask) + let seq = node.pointee.sequence.load(ordering: .acquiring) + let difference = seq - (pos + 1) + + if difference == 0 { + if head.weakCompareExchange(expected: pos, desired: pos + 1, successOrdering: .relaxed, failureOrdering: .relaxed).exchanged { + break + } + } else if difference < 0 { + return nil + } else { + pos = head.load(ordering: .relaxed) + } + } + var result: Element? = nil + swap(&result, &node.pointee.data) +// let result = node.pointee.data.move() + node.pointee.sequence.store(pos + mask + 1, ordering: .releasing) + return result + } + + } + + @inline(__always) + public func dequeueAll(_ closure: (consuming sending Element) -> Void) { + while let element = dequeue() { + closure(element) + } + } +} diff --git a/Sources/TetraRunLoopConcurrency/SerialExecutorRef.swift b/Sources/TetraRunLoopConcurrency/SerialExecutorRef.swift new file mode 100644 index 0000000..53ec353 --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/SerialExecutorRef.swift @@ -0,0 +1,54 @@ +// +// SerialExecutorRef.swift +// TetraRunLoopConcurrency +// +// Reconstructs the concrete `any SerialExecutor` backing the *currently running* +// task, so `StackBoundRunLoopExecutor.peek()` can identify itself even when it is +// not the `currentOnLocal` TaskLocal (e.g. while running a job whose task is +// isolated to this executor). Reads the runtime's current-executor ref via +// `swift_task_getCurrentExecutor` and bit-casts it to the `(Identity, Implementation)` +// layout of `UnownedSerialExecutor`. Ported from swift-platform-executors. +// + +struct SerialExecutorRef: BitwiseCopyable { + var Identity: UnsafeRawPointer? + var Implementation: UnsafeRawPointer? + + func isGeneric() -> Bool { + return Identity == nil + } + + func isDefaultActor() -> Bool { + return !isGeneric() && Implementation == nil + } + + nonisolated + private static var WitnessTableMask: UInt { + unsafe ~(UInt(MemoryLayout.alignment) - 1) + } + + /// Rebuild the `any SerialExecutor` existential from the ref's identity + witness + /// table pointers. Returns nil for the generic/default-actor executors (no concrete + /// custom executor to recover). + nonisolated + func unsafeConvert() -> (any SerialExecutor)? { + guard !isGeneric() else { return nil } + guard !isDefaultActor() else { return nil } + let transformed = Implementation.flatMap(UInt.init) ?? 0 + let alignedExecutor = (Identity, transformed & Self.WitnessTableMask) + return unsafe unsafeBitCast(alignedExecutor, to: (any SerialExecutor)?.self) + } + + /// The concrete executor the current task is running on, or nil if not in a task. + nonisolated + static func peek() -> (any Executor)? { + return withUnsafeCurrentTask { + if $0 == nil { return nil } + let t = _task_getCurrentExecutor() + return unsafeBitCast(t, to: SerialExecutorRef.self).unsafeConvert() + } + } + + @_silgen_name("swift_task_getCurrentExecutor") + private nonisolated static func _task_getCurrentExecutor() -> UnownedSerialExecutor +} diff --git a/Sources/TetraRunLoopConcurrency/SlicedJobQueue.swift b/Sources/TetraRunLoopConcurrency/SlicedJobQueue.swift new file mode 100644 index 0000000..5ba3eb3 --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/SlicedJobQueue.swift @@ -0,0 +1,616 @@ +// +// SlicedJobQueue.swift +// Tetra +// +// Created by 박병관 on 2/14/26. +// +import Atomics +import Darwin +import CriticalSection +import Dispatch +import Foundation +import HeapModule +import os +import Builtin + +internal struct SlicedJobQueue: ~Copyable, Sendable { + + + let jobs:FiveElement<__MPSCQueue> + nonisolated(unsafe) + let boost:FiveElement> + + let cache1:__MPSCQueue.NodeCache +// let cache2:__MPSCQueue.NodeCache + + init(cacheSize:Int = 2048) { + let cache1 = __MPSCQueue.NodeCache(size: cacheSize) +// let cache2 = __MPSCQueue.NodeCache(size: cacheSize / 2) + self.cache1 = cache1 +// self.cache2 = cache2 + boost = .init({ _ in + .init(nil) + }) + jobs = .init({ _ in + .init(cache: cache1) + }) + } + + /// Release every active QoS override. Called by the engine ONLY when the busy + /// period ends (no refire) or at teardown — NOT per drain. Holding overrides + /// across drains mirrors libdispatch's runloop-queue discipline + /// (`_dispatch_runloop_queue_wakeup`: end the override only when the queue drains + /// empty), avoiding per-drain start/end syscall thrash and the mid-busy priority + /// drop that would otherwise open an inversion window. + internal func endAllBoosts() { + for i in 0..<5 { + if let override = boost[i].exchange(nil, ordering: .acquiring) { + pthread_override_qos_class_end_np(.init(override)) + } + } + } + + /// `thread == nil` skips the QoS-override install: the pump passes nil when moving + /// its own fired timer jobs into the lanes (it is about to drain them itself, so + /// boosting its own thread would only buy a wasted syscall pair). + nonisolated func enqueue(_ job:UnownedJob, _ thread:pthread_t?) { + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *){ + let priority = TaskPriority(job.priority) + let index = priority?.jobQueueIndex ?? 4 + jobs[index].enqueue(job) + // Install a QoS override for this lane only when none is active yet — the + // hot path (a burst of same-priority jobs) then costs one acquiring load and + // no syscall. (Was inverted `!= nil`, which never installed an override.) + if let thread, boost[index].load(ordering: .acquiring) == nil { + let qos = switch index { + case 0: + QOS_CLASS_USER_INTERACTIVE + case 1: + QOS_CLASS_USER_INITIATED + case 2: + QOS_CLASS_DEFAULT + case 3: + QOS_CLASS_UTILITY + case 4: + fallthrough + default: + QOS_CLASS_BACKGROUND + } + + let override = pthread_override_qos_class_start_np(thread, qos, 0) + let (exchanged, _) = boost[index].compareExchange(expected: nil, desired: .init(override), ordering: .releasing) + if !exchanged { + pthread_override_qos_class_end_np(override) + } + } + } else { + jobs[2].enqueue(job) + } + } + + internal enum ClockIndex:Int, Sendable, BitwiseCopyable { + case continuous + case suspending + case walltime + } + + + deinit { +// let span = boost.span + for i in 0..<5 { + if let t = boost[i].exchange(nil, ordering: .relaxed) { + pthread_override_qos_class_end_np(.init(t)) + } + } + } + public struct __MPSCQueue: ~Copyable, @unchecked Sendable { + + typealias ForwardItem = ForwardItemT + + + @usableFromInline + internal struct BufferNode: ~Copyable { + @usableFromInline + internal var data: Element? + + @usableFromInline + internal let next: AtomicStore> = .init(nil) + + @inlinable + internal init(data: consuming Element?) { + self.data = data + } + } + + + @usableFromInline + internal struct Header:Copyable { + @usableFromInline + internal var capacity:Int + } + + @usableFromInline + internal let head: AtomicStore + + @usableFromInline + internal let tail: AtomicStore + + + @usableFromInline + internal let _cache:NodeCache + + @usableFromInline + final class NodeCache: Sendable { + + @usableFromInline + let pool:MPMCBoundedQueue + + deinit { + while let node:UnsafeMutablePointer = pool.dequeue()?.load() { + node.deinitialize(count: 1) + node.deallocate() + } + } + @usableFromInline + init(size:Int = 1024) { + pool = .init(size: size) + } + + + @preconcurrency + @usableFromInline + nonisolated + func consume(_ node: consuming sending UnsafeMutablePointer) { + if let dropped = pool.enqueue(.init(rawValue: node))?.load(BufferNode.self) { + dropped.deinitialize(count: 1) + dropped.deallocate() + } + } + @usableFromInline + func dequeue() -> UnsafeMutablePointer? { + let t = pool.dequeue()?.load(BufferNode.self) + t?.pointee.next.store(nil, ordering: .relaxed) + return t + } + } + + @inlinable + public init(cacheSize: Int = 1024) { + + let node = UnsafeMutablePointer.allocate(capacity: 1) + node.initialize(to: BufferNode(data: nil)) + self.head = .init(.init(rawValue: node)) + self.tail = .init(.init(rawValue: node)) + self._cache = .init(size: cacheSize) + } + + @inlinable + internal init(cache:NodeCache) { + + let node = UnsafeMutablePointer.allocate(capacity: 1) + node.initialize(to: BufferNode(data: nil)) + self.head = .init(.init(rawValue: node)) + self.tail = .init(.init(rawValue: node)) + self._cache = cache + } + + deinit { + while let _ = dequeue() {} + let last = tail.load(ordering: .relaxed).load(BufferNode.self) + last.deinitialize(count: 1) + last.deallocate() +// tail.load(ordering: .relaxed).deallocate() + } + + + + @inlinable + public func enqueue(_ value: consuming sending Element) { + let bufferNode = allocateNode() + bufferNode.pointee.data = consume value + let previous = tail.exchange(.init(rawValue: bufferNode), ordering: .acquiringAndReleasing).load(BufferNode.self) + previous.pointee.next.store(.init(rawValue: bufferNode), ordering: .releasing) + } + + @inlinable + public borrowing func dequeue() -> sending Element? { + let currentHead = head.load(ordering: .relaxed).load(BufferNode.self) + guard let next = currentHead.pointee.next.load(ordering: .acquiring)?.load(BufferNode.self) else { + return nil + } + var result:Element? = nil + swap(&result, &next.pointee.data) +// let result = next.pointee.data.take() + + head.store(.init(rawValue: next), ordering: .releasing) + do { + let t = Int(bitPattern: currentHead) + _cache.consume(.init(bitPattern: t)!) + } + return result + } + + /// Consumer-side emptiness check: true when there is no dequeuable node. + /// Mirrors `dequeue`'s producer-published-`next` protocol (an in-flight + /// producer that has swung `tail` but not yet published `next` reads as empty, + /// same as `dequeue` returning nil). + @inline(__always) + public var isEmpty: Bool { + let currentHead = head.load(ordering: .relaxed).load(BufferNode.self) + return currentHead.pointee.next.load(ordering: .acquiring) == nil + } + + @inline(__always) + public func withFirst(_ body: (borrowing Element?) throws(Failure) -> T) throws(Failure) -> T { + let currentHead = head.load(ordering: .relaxed).load(BufferNode.self) + guard let next = currentHead.pointee.next.load(ordering: .acquiring)?.load(BufferNode.self) else { + return try body(nil) + } + let first = next.pointee.data.take() + let result = try body(first) + next.pointee.data = first + return result + } + + @inline(__always) + public func dequeueAll(_ closure: (consuming sending Element) -> Void) { + while let element = dequeue() { + closure(element) + } + } + + @inlinable + internal func allocateNode() -> UnsafeMutablePointer { + if let node = _cache.dequeue() { + return node + } + let node: UnsafeMutablePointer = .allocate(capacity: 1) + node.initialize(to: BufferNode(data: nil)) + return node + } + } + + +} +/// A delayed job, ordered by fire deadline; `sequence` breaks deadline ties so +/// equal-deadline timers pop in enqueue (FIFO) order. +struct TimestampJob: Comparable { + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.timestamp.deadline != rhs.timestamp.deadline { + return lhs.timestamp.deadline < rhs.timestamp.deadline + } + return lhs.sequence < rhs.sequence + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.sequence == rhs.sequence + } + + let timestamp:Timestamp + let sequence:UInt64 + let job:UnownedJob + + init(job:consuming UnownedJob, sequence:UInt64, timestamp:Timestamp) { + self.job = job + self.sequence = sequence + self.timestamp = timestamp + } +} + +struct Timestamp:BitwiseCopyable, Hashable, Sendable, Copyable { + /// The earliest time at which a job should run. + /// + /// Jobs will never be run earlier than this. + var target: UInt64 + + /// The maximum (ideal) tolerable delay. + /// + /// We make no guarantee that we won't run over this, but it is taken + /// into consideration when scheduling jobs. + var leeway: UInt64 + + /// The latest time at which a job should (ideally) run. + /// + /// We may run the job after this point, but we will not run other jobs + /// with later deadlines before this job. + var deadline: UInt64 { + get { + if UInt64.max - target < leeway { + return UInt64.max + } + return target + leeway + } + set { + if newValue < leeway { + target = 0 + } else { + target = newValue - leeway + } + } + } +} + +extension TaskPriority { + + /// Reference-engine band mapping (`>=` boundaries): a priority lands in the lane + /// of the highest named priority it meets or exceeds. `.userInteractive` (33) is + /// spelled via rawValue — the named stdlib symbol is newer than Tetra's iOS 13 floor. + var jobQueueIndex:Int { + + if self >= .init(rawValue: 33) { + 0 + } else if self >= .high { + 1 + } else if self >= .medium { + 2 + } else if self >= .low { + 3 + } else { + 4 + } + } + +} + +/// Per-lane drain cap (I4b fairness). Ported from the reference engine's exact +/// values: userInteractive(0) is uncapped; high(1) and default(2) get 128; utility(3) +/// gets 2; background(4) gets 1. A capped lane leaves residual jobs, which the engine +/// pump drains on subsequent re-fires (see `handleReadable`'s `readyLanesEmpty` recheck). +@inlinable +@inline(__always) +internal func getDrainIterations(queueIndex: Int) -> Int { + switch queueIndex { + case 0: .max // userInteractive + case 1: 128 // high + case 2: 128 // default + case 3: 2 // utility + default: 1 // background and lower + } +} + + + +class Backing { + + + + unowned(unsafe) var runLoop:RunLoop? = nil + unowned(unsafe) var source:CFRunLoopSource! = nil + var serialExecutor:UnownedSerialExecutor! = nil + let timers = [ + DispatchSource.makeTimerSource(), + DispatchSource.makeTimerSource(), + DispatchSource.makeTimerSource(), + ] as! [DispatchSource & DispatchSourceTimer] + let store = SlicedJobQueue() + let registry = NSMapTable.weakToStrongObjects() + + open var taskRef:Builtin.Executor? { nil } + + required init() { + + timers.forEach { + $0.setEventHandler { [unowned(unsafe) self] in + if let s = source { + CFRunLoopSourceSignal(s) + withExtendedLifetime(s) { + let valueTypes = Unmanaged.passUnretained(s).toOpaque().assumingMemoryBound(to: CFRunLoopSourceOpaqeueValue.self) + let bag:CFBag + do { + CFRunLoopSourceOpaqeueValue.lock(&valueTypes.pointee.mutex) + if let mutbag = valueTypes.pointee.mutableBag?.takeUnretainedValue() { + bag = CFBagCreateCopy(nil, mutbag) + } else { + + bag = withUnsafePointer(to: kCFTypeBagCallBacks) { + CFBagCreateMutable(nil, 0, $0) + } + } + CFRunLoopSourceOpaqeueValue.unlock(&valueTypes.pointee.mutex) + } + let runloopArrays = withUnsafeTemporaryAllocation(of: UnsafeRawPointer?.self, capacity: CFBagGetCount(bag)) { buffer in + CFBagGetValues(bag, buffer.baseAddress!) + return buffer.withMemoryRebound(to: CFRunLoop.self) { + Array($0) + } + } + + } + + } + if let rl = runLoop?.getCFRunLoop() { + CFRunLoopWakeUp(rl) + } + + } + } + timers.forEach{ $0.activate() } + } + + deinit { + timers.forEach { $0.cancel() } + } + + func dispatch() { + // Same capped lane drain as the engine's `drainReadyJobs`/`processLane`, + // inlined: this experimental CFRunLoopSource backing has no engine to host it. + let executor = serialExecutor.unsafelyUnwrapped + for index in 0..<5 { + let iterations = getDrainIterations(queueIndex: index) + var count = 0 + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *), let t = taskRef { + let taskExecutor = UnownedTaskExecutor(t) + while count < iterations, let j = store.jobs[index].dequeue() { + j.runSynchronously(isolatedTo: executor, taskExecutor: taskExecutor) + count &+= 1 + } + } else { + while count < iterations, let j = store.jobs[index].dequeue() { + j.runSynchronously(on: executor) + count &+= 1 + } + } + } + } + + func schedule(_ runloop:CFRunLoop, _ mode:CFRunLoopMode) { + registry.object(forKey: runloop)?.add(mode.rawValue) + } + + func cancel(_ runloop:CFRunLoop, _ mode:CFRunLoopMode) { + registry.object(forKey: runloop)?.remove(mode.rawValue) + } + + + class func create() -> CFRunLoopSource { + let ob = Self.init() + NSMapTable.weakToStrongObjects() +// malloc_zone_t + var context = CFRunLoopSourceContext() + context.info = Unmanaged.passUnretained(ob).toOpaque() + context.copyDescription = kCFTypeSetCallBacks.copyDescription + context.equal = kCFTypeSetCallBacks.equal + context.hash = kCFTypeSetCallBacks.hash + context.retain = unsafeBitCast(CFBundleGetFunctionPointerForName(CFBundleGetBundleWithIdentifier("com.apple.CoreFoundation" as CFString), "CFRetain" as CFString), to: (@convention(c) (UnsafeRawPointer?) -> UnsafeRawPointer?).self) + context.release = unsafeBitCast(CFBundleGetFunctionPointerForName(CFBundleGetBundleWithIdentifier("com.apple.CoreFoundation" as CFString), "CFRelease" as CFString), to: (@convention(c) (UnsafeRawPointer?) -> Void).self) + context.perform = { + Unmanaged.fromOpaque($0.unsafelyUnwrapped).takeUnretainedValue().dispatch() + } + context.schedule = { + Unmanaged.fromOpaque($0.unsafelyUnwrapped).takeUnretainedValue() + .schedule($1.unsafelyUnwrapped, $2.unsafelyUnwrapped) + } + context.cancel = { + Unmanaged.fromOpaque($0.unsafelyUnwrapped).takeUnretainedValue() + .cancel($1.unsafelyUnwrapped, $2.unsafelyUnwrapped) + } + let source = CFRunLoopSourceCreate(nil, 0, &context)! + ob.source = source + let bag:CFBag + do { +// CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) +// +// CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + let k2 = Unmanaged.passUnretained(source).toOpaque().assumingMemoryBound(to: CFRunLoopSourceOpaqeueValue.self) + CFRunLoopSourceOpaqeueValue.lock(&k2.pointee.mutex) + if let mutBag = k2.pointee.mutableBag?.takeUnretainedValue() { + bag = CFBagCreateCopy(nil, mutBag) + } else { + bag = withUnsafePointer(to: kCFTypeBagCallBacks) { + CFBagCreate(nil, nil, 0, $0) + } + } + CFRunLoopSourceOpaqeueValue.unlock(&k2.pointee.mutex) + + } + //AutoreleasingUnsafeMutablePointer + let t = withUnsafeTemporaryAllocation(of: UnsafeRawPointer?.self, capacity: CFBagGetCount(bag)) { + CFBagGetValues(bag, $0.baseAddress!) + let count = CFBagGetCount(bag) + let buffer = UnsafeMutableBufferPointer(rebasing: $0[...init(unsafeUninitializedCapacity: buffer.count) { + for i in buffer.indices { + $0.initializeElement(at: i, to: Unmanaged.fromOpaque(buffer[i]!).takeUnretainedValue()) + } + $1 = buffer.count + } + +// return withUnsafePointer(to: kCFTypeArrayCallBacks) { CFArrayCreate(nil, buffer.baseAddress!, CFBagGetCount(bag), $0) +// +// } as! [CFRunLoop] +// return buffer.withMemoryRebound(to: CFRunLoop.self) { +// Array($0) +// } + } + print(t) + return source + } +} + +struct CFRunLoopSourceOpaqeueValue:BitwiseCopyable { + var runtime:(UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) + + var mutex:RecursiveMutex + var order:CFIndex + var signalTime:UInt64 + var mutableBag:Unmanaged! + + var context0:CFRunLoopSourceContext + + #if false && canImport(Darwin) + struct RecursiveMutex:BitwiseCopyable { + var lock:os_unfair_lock + var count:UInt32 + } + @_silgen_name("os_unfair_recursive_lock_lock_with_options") + static func lock_options(_ lock: inout RecursiveMutex, options:UInt32) + + static func lock(_ lock: inout RecursiveMutex) { + lock_options(&lock, options: 0) + } + @_silgen_name("os_unfair_recursive_lock_unlock") + static func unlock(_ lock: inout RecursiveMutex) + + #elseif canImport(WinSDK) + typealias RecursiveMutex = SWIFT_CRITICAL_SECTION + static func lock(_ lock: inout RecursiveMutex) { + EnterCriticalSection(&lock) + } + static func unlock(_ lock: inout RecursiveMutex) { + LeaveCriticalSection(&lock) + } + #elseif canImport(pthread) + + typealias RecursiveMutex = pthread_mutex_t + static func lock(_ lock: inout RecursiveMutex) { + pthread_mutex_lock(&lock) + } + static func unlock(_ lock: inout RecursiveMutex) { + pthread_mutex_unlock(&lock) + } + #else + #error("RecursiveMutex can not be inferred") + + #endif + + +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +class Backing2: Backing { + + + var taskExecutor:UnownedTaskExecutor! + + override var taskRef: Builtin.Executor { + taskExecutor!._executor + } + + +} + + +@usableFromInline +struct ForwardItemT: RawRepresentable, AtomicValue, AtomicOptionalWrappable { + + @usableFromInline + @inline(__always) + var rawValue: UnsafeMutableRawPointer + + @usableFromInline + @_transparent + @inline(__always) + func load(_ type:T.Type = T.self) -> UnsafeMutablePointer { + rawValue.assumingMemoryBound(to: type) + } + + @_transparent + @inline(__always) + @usableFromInline + init(rawValue: UnsafeMutableRawPointer) { + self.rawValue = rawValue + } + +} diff --git a/Sources/TetraRunLoopConcurrency/StackBoundRunLoopExecutor.swift b/Sources/TetraRunLoopConcurrency/StackBoundRunLoopExecutor.swift new file mode 100644 index 0000000..05d7291 --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/StackBoundRunLoopExecutor.swift @@ -0,0 +1,515 @@ +// +// StackBoundRunLoopExecutor.swift +// TetraRunLoopConcurrency +// +// The FACADE half of the kqueue/CFFileDescriptor run-loop executor. A +// `SerialExecutor` that mounts a stack-bound `KQScheduler` engine on the +// current CFRunLoop thread, buffers jobs while dormant, and delegates the run +// frame (`run`/`runUntil`/`stop`) to the engine. +// +// Ported from swift-platform-executors' `StackBoundRunLoopExecutor2` facade +// half, adapted to Tetra: +// +// * iOS 13 / macOS 10.15 (the reference's macOS 15 / iOS 18 gate is dropped; +// the `ExecutorJob` / timer surfaces keep their own finer availability). +// * `AtomicStore` (not Synchronization `Atomic`) for `producerGate` and the +// published-scheduler pointer; `createCheckedStateLock` for the mount FSM. +// * The engine (`KQScheduler`) already owns the run frame + mount lifetime, so +// this facade only publishes a freshly mounted engine and forwards to it. +// * No `TaskExecutor` conformance here — Task 5 supplies the gated task ref. +// `taskRef` stays nil (the iOS 13 `SerialExecutor` path). +// +// Concern wiring (Task 3 hand-offs resolved here + in `KQScheduler.swift`): +// 1. Predicate unwinding: `currentPredicate` is bound around the `runUntil` +// frame; the engine's `handleReadable` consults it after each drain and +// stops the run loop when it returns true. +// 2. `current()` / mount: `currentOnLocal` is bound around the run frame so a +// nested `current()` on the same thread recovers this facade. +// 3. Nested re-entry: the engine's own `enterRunLoop` installs the +// `beforeWaiting` observer + `deferredReadable` guard. +// + +#if canImport(Darwin) +import Darwin +import Dispatch +import CoreFoundation +import CriticalSection +import Builtin +// SchedulingExecutor / RunLoopExecutor / MainExecutor are SPI on `_Concurrency`. +// The SPI groups match the ones the test target imports. +@_spi(ExperimentalScheduling) @_spi(ConcurrencyExecutors) @_spi(ExperimentalCustomExecutors) import _Concurrency + +@available(macOS 10.15, iOS 13, *) +public final class StackBoundRunLoopExecutor: SerialExecutor, @unchecked Sendable { + + /// Dormant (buffering) → live (engine mounted & published) → dead (unwound). + private enum MountState { + case dormant(ContiguousArray) + case live + case dead + } + private let mount: some UnfairStateLock = + createCheckedStateLock(checkedState: MountState.dormant([])) + + /// The published live engine, dereferenced by producers while `producerGate` + /// is held. `nil` unless mounted. Stored as a raw pointer inside an + /// `AtomicStore` (Tetra has no `AtomicStore?>`). + private let liveSchedulerBits = AtomicStore?>(.none) + + /// Producers in flight. Held across every dereference of the live engine and + /// doubles as the engine's shutdown quiescence count (`finishAndDie`). + let producerGate = AtomicStore(0) + + nonisolated(unsafe) let cfRunLoop: CFRunLoop + nonisolated(unsafe) let thread: pthread_t + + // MARK: Task locals (concern #1 predicate, #2 mount recovery) + + @TaskLocal static var currentOnLocal: Unmanaged? + @TaskLocal static var currentPredicate: UnsafeBlockBox? + + struct UnsafeBlockBox: @unchecked Sendable { + nonisolated(unsafe) let block: () -> Bool + } + + #if DEBUG + /// Test-only hook, invoked with a freshly mounted engine (before the run frame + /// spins) so a lifetime test can capture it in a `weak` box and later assert it + /// deallocated once `run()` returned. Not compiled into release. + nonisolated(unsafe) static var _debugOnMountEngine: ((KQScheduler) -> Void)? + #endif + + // MARK: Init / mount lookup + + private init() { + self.thread = pthread_self() + self.cfRunLoop = CFRunLoopGetCurrent() + } + + /// Recover the facade mounted on the current thread, if any. + static func peek() -> StackBoundRunLoopExecutor? { + if let existing = currentOnLocal?.takeUnretainedValue(), + pthread_equal(existing.thread, pthread_self()) != 0 { + return existing + } + // Fallback: we may be running inside a job whose task is isolated to this + // executor without `currentOnLocal` being bound. Recover the concrete executor + // from the current task's serial-executor ref. + return withUnsafeCurrentTask { + if $0 != nil, let serial = SerialExecutorRef.peek() as? StackBoundRunLoopExecutor { + return serial + } + return nil + } + } + + /// Mount an engine on the current CFRunLoop thread (or recover the one already + /// mounted here). + public static func current() -> StackBoundRunLoopExecutor { + if let existing = peek() { return existing } + return StackBoundRunLoopExecutor() + } + + public var isMainExecutor:Bool { + false + } + + // MARK: Published-engine helpers + + private func publishedScheduler() -> Unmanaged? { + return liveSchedulerBits.load(ordering: .acquiring) + } + + private func publish(_ scheduler: KQScheduler) { +// let raw = Unmanaged.passUnretained(scheduler).toOpaque() + liveSchedulerBits.store(.passUnretained(scheduler), ordering: .releasing) + } + + /// Retire the published engine: no producer may dereference it after this + /// returns, and the mount FSM is dead. Spins until every in-flight producer + /// releases the gate. + private func retireScheduler() { + liveSchedulerBits.store(nil, ordering: .releasing) + mount.withLock { $0 = .dead } + while producerGate.load(ordering: .acquiring) != 0 { _ = sched_yield() } + } + + // MARK: producerGate counter (AtomicStore has no wrapping add/sub) + + private func gateEnter() { + while true { + let old = producerGate.load(ordering: .relaxed) + let (ok, _) = producerGate.compareExchange( + expected: old, desired: old &+ 1, ordering: .acquiring) + if ok { return } + } + } + private func gateLeave() { + while true { + let old = producerGate.load(ordering: .relaxed) + let (ok, _) = producerGate.compareExchange( + expected: old, desired: old &- 1, ordering: .releasing) + if ok { return } + } + } + + // MARK: SerialExecutor + + /// Primary `SerialExecutor` conformance method, available at iOS 13 / macOS 10.15. + public func enqueue(_ job: UnownedJob) { + gateEnter() + defer { gateLeave() } + + if let ref = publishedScheduler() { + ref._withUnsafeGuaranteedRef { $0.enqueueReady(job) } + return + } + let scheduler = mount.withLock { state -> Unmanaged? in + switch state { + case .dormant(var buffer): + state = .dormant([]) + buffer.append(job) + state = .dormant(buffer) + return nil + case .live: + return publishedScheduler() + case .dead: + preconditionFailure("StackBoundRunLoopExecutor.enqueue(_:) called after its run loop finished.") + } + } + scheduler?._withUnsafeGuaranteedRef { $0.enqueueReady(job) } + } + + /// `ExecutorJob` overload available on newer platforms; forwards to the primary path. + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, visionOS 1, *) + public func enqueue(_ job: consuming ExecutorJob) { + enqueue(UnownedJob(job)) + } + + public func asUnownedSerialExecutor() -> UnownedSerialExecutor { + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) { + return .init(complexEquality: self) + } else { + return .init(ordinary: self) + } + } + + public static func == (lhs: StackBoundRunLoopExecutor, rhs: StackBoundRunLoopExecutor) -> Bool { + pthread_equal(lhs.thread, rhs.thread) != 0 + } + + public func checkIsolated() { + precondition(isIsolatingCurrentContext() == true, + "Caller is not isolated to this executor's thread.") + } + public func isIsolatingCurrentContext() -> Bool? { + pthread_equal(thread, pthread_self()) != 0 + } + + // MARK: Timer enqueue (delegates to the engine's clock-domain heaps) + + /// `ClockIndex` is module-internal, so this timer surface is `internal` even + /// though the type is public (mirrors the reference's `SchedulingExecutor` + /// surface, reduced to the concrete Tetra domain index). +// @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + func enqueue(_ job: consuming ExecutorJob, after: Duration, + tolerance: Duration? = nil, clock: SlicedJobQueue.ClockIndex) { + let unowned = UnownedJob(job) + gateEnter() + defer { gateLeave() } + + if let ref = publishedScheduler() { + ref._withUnsafeGuaranteedRef { + $0.enqueueTimer(unowned, after: after, tolerance: tolerance, index: clock) + } + return + } + // Dormant / not-yet-published: buffer the job on the ready lanes (fires + // ASAP once mounted). The engine owns the delay heaps and is only reachable + // once live; timers scheduled before mount degrade to immediate. + let scheduler = mount.withLock { state -> Unmanaged? in + switch state { + case .dormant(var buffer): + state = .dormant([]) + buffer.append(unowned) + state = .dormant(buffer) + return nil + case .live: + return publishedScheduler() + case .dead: + preconditionFailure("StackBoundRunLoopExecutor.enqueue(_:after:...) called after its run loop finished.") + } + } + scheduler?._withUnsafeGuaranteedRef { + $0.enqueueTimer(unowned, after: after, tolerance: tolerance, index: clock) + } + } + + // MARK: Run frame (owned by the facade; drives the engine's pump) + + public func run() throws { + try enterRunLoop(stopScope: true) { engine in + Self.$currentPredicate.withValue(nil) { + while !engine._withUnsafeGuaranteedRef(\.isStopRequested) { + let result = CFRunLoopRunInMode(.defaultMode, 1.0e10, false) + if result == .finished { break } + } + } + } + } + + public func runUntil(_ condition: @escaping () -> Bool) throws { + try enterRunLoop(stopScope: false) { engine in + withoutActuallyEscaping(condition) { escaping in + Self.$currentPredicate.withValue(.init(block: escaping)) { + while !engine._withUnsafeGuaranteedRef(\.isStopRequested) { + let result = CFRunLoopRunInMode(.defaultMode, 1.0e10, false) + if result == .finished { break } + } + } + } + } + } + + public func stop() { + let onOwningThread = pthread_equal(thread, pthread_self()) != 0 + gateEnter() + publishedScheduler()?._withUnsafeGuaranteedRef { $0.requestStop() } + gateLeave() + if onOwningThread { + CFRunLoopStop(cfRunLoop) + } + } + + // MARK: Mount + install run-loop source + spin + + /// Mounts a fresh engine on the current thread (or reuses the one already mounted + /// for nested re-entry), installs the CFFileDescriptor run-loop source + a + /// `beforeWaiting` observer that re-enables the one-shot readable callback deferred + /// while inside a task context, binds the mount task-local, spins the caller's loop, + /// then tears everything down, drains to empty, and retires the engine. + private func enterRunLoop(stopScope: Bool, + _ spin: (Unmanaged) -> Void) throws { + guard pthread_equal(thread, pthread_self()) != 0 else { + preconditionFailure("StackBoundRunLoopExecutor.run()/runUntil(_:) called from a non-owning thread.") + } + guard withUnsafeCurrentTask(body: { $0 == nil }) else { + throw CancellationError() + } + + // Nested re-entry: reuse the already-mounted engine. The fd/observer are + // installed once by the outermost frame. The engine stays alive for the nested + // spin via the outer frame's keep-alive (the CFFileDescriptor context retain + + // the outer frame's strong local), so we hand the spin an unretained + // `Unmanaged` — no extra retain across the loop. + if Self.peek() === self, let ref = publishedScheduler() { + if !stopScope { + // Force one drain pass so the predicate is evaluated on entry. + ref._withUnsafeGuaranteedRef { KQueueSelector.wakeup(fileDescriptor: $0.kqueueFD) } + } + defer { + ref._withUnsafeGuaranteedRef { $0.clearStopRequested() } + } + spin(ref) + return + } + + // Outermost: mount a fresh engine bound to this thread's run loop, drain any + // buffered jobs, publish it, install the run-loop source, spin, then retire it + // (stack-bound lifetime). + // + // On the iOS-18 path this facade is a `TaskExecutor`, so hand the engine a + // task-executor ref; `processLane` then runs jobs via + // `runSynchronously(isolatedTo:taskExecutor:)`. Below iOS 18 the ref stays nil + // (the `SerialExecutor`-only `runSynchronously(on:)` path). + let taskRef: Builtin.Executor? + if #available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) { + taskRef = self.asUnownedTaskExecutor()._executor + } else { + taskRef = nil + } + // The frame keeps a plain strong local; the CFFileDescriptor context holds a + // second retain. Both drop on exit — so the engine deallocates when this frame + // returns (stack-bound lifetime; the lifetime test enforces this). + unowned(unsafe) let engine: KQScheduler + let fd: CFFileDescriptor + do { + let _engine = KQScheduler(facade: self, + serial: asUnownedSerialExecutor(), + taskRef: taskRef, + cfRunLoop: cfRunLoop) + let buffered: ContiguousArray = mount.withLock { state in + switch state { + case .dormant(let buffer): + // Publish inside the lock so a producer that observes `.live` here + // never reads a nil published pointer (and drops the job). + publish(_engine) + state = .live + return buffer + case .live, .dead: + return [] + } + } + fd = _engine.makeFileDescriptor() + engine = _engine + _engine.enqueueBatch(buffered) + + #if DEBUG + Self._debugOnMountEngine?(_engine) + #endif + } + + guard let source = CFFileDescriptorCreateRunLoopSource(nil, fd, 0) else { + // Undo the publish so no producer dereferences the about-to-be-freed + // engine, then invalidate the descriptor (closes the kqueue). + retireScheduler() + CFFileDescriptorInvalidate(fd) + throw CancellationError() + } + + let observer = CFRunLoopObserverCreateWithHandler( + nil, ([.beforeWaiting] as CFRunLoopActivity).rawValue, true, 0 + ) { [unowned(unsafe) engine, unowned(unsafe) fd] _, activity in + // Re-arm a readable that was deferred because we were inside a task-context. + // Safe: the observer is removed and invalidated in the same defer that + // precedes the engine's release, and the fd is valid for the frame. + if activity == .beforeWaiting, engine.deferredReadable, withUnsafeCurrentTask(body: { $0 == nil }) { + engine.deferredReadable = false + CFFileDescriptorEnableCallBacks(fd, kCFFileDescriptorReadCallBack) + } + }! + + Self.$currentOnLocal.withValue(Unmanaged.passUnretained(self)) { + CFRunLoopAddSource(cfRunLoop, source, .commonModes) + CFRunLoopAddObserver(cfRunLoop, observer, .commonModes) + CFFileDescriptorEnableCallBacks(fd, kCFFileDescriptorReadCallBack) + + // Initial kick: drain anything buffered before the source was installed. + KQueueSelector.wakeup(fileDescriptor: engine.kqueueFD) + + defer { + CFFileDescriptorDisableCallBacks(fd, kCFFileDescriptorReadCallBack) + CFRunLoopRemoveSource(cfRunLoop, source, .commonModes) + CFRunLoopRemoveObserver(cfRunLoop, observer, .commonModes) + CFRunLoopSourceInvalidate(source) + CFRunLoopObserverInvalidate(observer) + engine.teardown() + // Invalidate the descriptor last: closes the kqueue (closeOnInvalidate) + // and drops the context's retain; the local strong `engine` releases as + // this frame returns (stack-bound lifetime). + CFFileDescriptorInvalidate(fd) + } + + spin(.passUnretained(engine)) + engine.clearStopRequested() + engine.beginClosing() + engine.finishAndDie() + retireScheduler() + } + } +} + +// MARK: - Gated executor-protocol conformances (Task 5) +// +// All four protocols floor at iOS 16 / macOS 13, but this executor supplies a +// task executor to `processLane` via `runSynchronously(isolatedTo:taskExecutor:)`, +// which is iOS 18 / macOS 15. So the conformances are gated at that higher floor. +// `SchedulingExecutor` / `RunLoopExecutor` / `MainExecutor` are SPI (see the +// `@_spi(...) import _Concurrency` at the top of this file). + +@available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) +extension StackBoundRunLoopExecutor: TaskExecutor { + // `asUnownedTaskExecutor()` is provided by the protocol's default extension; + // no explicit member needed. The engine reads `asUnownedTaskExecutor()._executor` + // when it mounts (see `enterRunLoop`). +} + +// The SPI executor protocols `SchedulingExecutor` / `RunLoopExecutor` / `MainExecutor` +// are NOT nameable on the standard/release stdlib: even a `@_spi(...) import _Concurrency` +// cannot see them (they are `internal` on 6.2 as `SchedulableExecutor` etc., and the +// release .swiftinterface does not re-export the SPI decls — confirmed: release Swift 6.4 +// reports `cannot find type 'SchedulingExecutor'`). Their visibility depends on the +// TOOLCHAIN FLAVOR (a development snapshot's stdlib exposes them; a release one does not), +// which no `#if compiler(>=x)` can distinguish. So gate on the `SchedulingExecutorSPI` +// package trait (Package.swift): OFF by default (release/standard toolchains → excluded → +// the module compiles as a SerialExecutor + TaskExecutor, clock scheduling falling back to +// the global executor), enabled with `swift build --traits SchedulingExecutorSPI` only on a +// toolchain whose stdlib actually exposes these SPI protocols. +#if SchedulingExecutorSPI +@available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) +@_spi(ExperimentalCustomExecutors) +extension StackBoundRunLoopExecutor: RunLoopExecutor { + // `run()` / `runUntil(_:)` / `stop()` already exist on the facade. (The facade's + // `runUntil` takes an `@escaping` closure, which satisfies the protocol's + // non-escaping requirement.) +} + +@available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) +@_spi(ExperimentalScheduling) +extension StackBoundRunLoopExecutor: SchedulingExecutor { + + /// Delay-scheduling entry point. Maps the standard clocks to the engine's + /// `SlicedJobQueue.ClockIndex` domains and delegates to the internal + /// `enqueue(_:after:tolerance:clock:)` timer path (which computes the deadline in + /// the engine — this facade never recomputes timestamps). + /// + /// `ContinuousClock.Duration` and `SuspendingClock.Duration` are both + /// `Swift.Duration`, so the delay/tolerance forward unchanged. + /// + /// For any clock this executor does not model, we prefer to trampoline through the + /// global concurrent executor (so the job still fires on the *right* clock) and then + /// hop back here to run. If that SPI cast is unavailable at runtime we fall back to + /// treating the delay on the suspending (uptime) domain rather than crashing — an + /// approximation, documented, for exotic custom clocks only. + @_spi(ExperimentalScheduling) + public func enqueue( + _ job: consuming ExecutorJob, + after delay: C.Duration, + tolerance: C.Duration? = nil, + clock: C + ) { + if clock is ContinuousClock { + let d = delay as! Swift.Duration + let tol = tolerance as! Swift.Duration? + self.enqueue(job, after: d, tolerance: tol, clock: .continuous) + return + } + if clock is SuspendingClock { + let d = delay as! Swift.Duration + let tol = tolerance as! Swift.Duration? + self.enqueue(job, after: d, tolerance: tol, clock: .suspending) + return + } + // Unmodeled clock: trampoline through the global executor if it schedules, + // otherwise approximate on the suspending domain (Tetra has no WallClock type). + if let global = globalConcurrentExecutor as? (any SchedulingExecutor), !(global === self) { + let trampoline = job.createTrampoline(to: self) + global.enqueue(trampoline, after: delay, tolerance: tolerance, clock: clock) + } else { + // Fallback: run the delay on the suspending (uptime) domain. This is an + // approximation for clocks other than Continuous/Suspending; it never + // crashes on an unknown clock. + let d = (delay as? Swift.Duration) ?? .zero + let tol = tolerance as? Swift.Duration + self.enqueue(job, after: d, tolerance: tol, clock: .suspending) + } + } + + nonisolated public var asSchedulingExecutor: (any SchedulingExecutor)? { self } + + + /// Deadline-scheduling entry point. The `SchedulingExecutor` extension supplies a + /// default that converts `at:` to `after:` via `clock.now`, so no override is + /// required here; it routes through `enqueue(_:after:tolerance:clock:)` above. +} + +@available(iOS 18, macOS 15, watchOS 11, tvOS 18, visionOS 2, *) +@_spi(ExperimentalCustomExecutors) +extension StackBoundRunLoopExecutor: MainExecutor { + // MainExecutor == RunLoopExecutor + SerialExecutor; both are already satisfied. + // `isMainExecutor` is provided by the SerialExecutor default (returns false). +} +#endif // SchedulingExecutorSPI trait — SchedulingExecutor/RunLoopExecutor/MainExecutor + +#endif diff --git a/Sources/TetraRunLoopConcurrency/_MPSCQueue.swift b/Sources/TetraRunLoopConcurrency/_MPSCQueue.swift new file mode 100644 index 0000000..c87d4e3 --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/_MPSCQueue.swift @@ -0,0 +1,162 @@ +// +// BufferNode.swift +// Tetra +// +// Created by 박병관 on 2/12/26. +// + +import CriticalSection +import Atomics +//import Synchronization + + + +internal struct _MPSCQueue: ~Copyable, @unchecked Sendable { + + + @usableFromInline + struct ForwardItem: RawRepresentable, AtomicValue, AtomicOptionalWrappable { + @usableFromInline + var rawValue: UnsafeMutableRawPointer + @usableFromInline + var pointer:UnsafeMutablePointer { + _read { + yield rawValue.assumingMemoryBound(to: BufferNode.self) + } + set { + rawValue = .init(newValue) + } + @storageRestrictions(initializes: rawValue) + init(newValue) { + rawValue = .init(newValue) + } + } + + @usableFromInline + init(rawValue: UnsafeMutableRawPointer) { + self.rawValue = rawValue + } + @usableFromInline + init(_ pointer:UnsafeMutablePointer) { + self.pointer = pointer + } + } + + @usableFromInline + internal struct BufferNode: ~Copyable { + @usableFromInline + internal var data: Element? + + @usableFromInline + internal let next: AtomicStore = .init(nil) + + @inlinable + internal init(data: consuming Element?) { + self.data = data + } + } + + + @usableFromInline + internal struct Header:Copyable { + @usableFromInline + internal var capacity:Int + } + + @usableFromInline + internal let head: AtomicStore + + @usableFromInline + internal let tail: AtomicStore + + @usableFromInline + internal let cache: _SPMCBoundedQueue + + @inlinable + public var wasFull: Bool { false } + + @inlinable + public init(cacheSize: Int = 1024) { + + let node = UnsafeMutablePointer.allocate(capacity: 1) + node.initialize(to: BufferNode(data: nil)) + self.head = AtomicStore(.init(node)) + self.tail = AtomicStore(.init(node)) + self.cache = _SPMCBoundedQueue(size: cacheSize) + } + + deinit { + while let _ = dequeue() {} + while let node = cache.dequeue() { + node.pointer.deinitialize(count: 1) + node.pointer.deallocate() + } + tail.load(ordering: .relaxed).pointer.deinitialize(count: 1) + tail.load(ordering: .relaxed).pointer.deallocate() + } + + @inlinable + public func flushCache() { + while let node = cache.dequeue() { + node.pointer.deinitialize(count: 1) + node.pointer.deallocate() + } + } + + @inlinable + public func enqueue(_ value: consuming sending Element) -> sending Element? { + let bufferNode = allocateNode() + bufferNode.pointee.data = consume value + let previous = tail.exchange(.init(bufferNode), ordering: .acquiringAndReleasing).pointer + previous.pointee.next.store(.init(bufferNode), ordering: .releasing) + return nil + } + + @inlinable + public func dequeue() -> sending Element? { + let currentHead = head.load(ordering: .relaxed) + guard let next = currentHead.pointer.pointee.next.load(ordering: .acquiring)?.pointer else { + return nil + } + let result = next.pointee.data.take() + + head.store(.init(next), ordering: .releasing) + let dummy = Int(bitPattern: currentHead.rawValue) + if let dropped = cache.enqueue(.init(rawValue: .init(bitPattern: dummy)!))?.pointer { + dropped.deinitialize(count: 1) + dropped.deallocate() + } + return result + } + + @inline(__always) + public func withFirst(_ body: (borrowing Element?) throws(Failure) -> T) throws(Failure) -> T { + let currentHead = head.load(ordering: .relaxed).pointer + guard let next = currentHead.pointee.next.load(ordering: .acquiring)?.pointer else { + return try body(nil) + } + let first = next.pointee.data.take() + let result = try body(first) + next.pointee.data = first + return result + } + + @inline(__always) + public func dequeueAll(_ closure: (consuming sending Element) -> Void) { + while let element = dequeue() { + closure(element) + } + } + + @inlinable + internal func allocateNode() -> UnsafeMutablePointer { + if let node = cache.dequeue()?.pointer { + node.pointee.next.store(nil, ordering: .relaxed) + return node + } + let node: UnsafeMutablePointer = .allocate(capacity: 1) + node.initialize(to: BufferNode(data: nil)) + return node + } +} + diff --git a/Sources/TetraRunLoopConcurrency/_SPMCBoundedQueue.swift b/Sources/TetraRunLoopConcurrency/_SPMCBoundedQueue.swift new file mode 100644 index 0000000..84e81fa --- /dev/null +++ b/Sources/TetraRunLoopConcurrency/_SPMCBoundedQueue.swift @@ -0,0 +1,159 @@ +// +// _SPMCBoundedQueue.swift +// Tetra +// +// Created by 박병관 on 2/12/26. +// +import Atomics +import CriticalSection + + +internal struct _SPMCBoundedQueue: ~Copyable, @unchecked Sendable { + + @usableFromInline + internal struct Header { + + @usableFromInline + var capacity:Int + + + @usableFromInline + var tail = 0 + + } + + @usableFromInline + internal struct BufferNode: ~Copyable { + @usableFromInline + internal var data: Element? + + @usableFromInline + internal let sequence: AtomicStore = .init(0) + + @inlinable + init(data: consuming Element) { + self.data = consume data + } + + init() { + self.data = nil + } + } + + @usableFromInline + internal let mask: Int + + @usableFromInline + internal let _buffer: ManagedBuffer + + @usableFromInline + internal let head: AtomicStore = .init(0) + + public var count: Int { + let headIndex = head.load(ordering: .relaxed) + let tailIndex = _buffer.header.tail + return tailIndex < headIndex ? (_buffer.header.capacity - headIndex + tailIndex) : (tailIndex - headIndex) + } + + public var wasFull: Bool { + _buffer.header.capacity - count == 1 + } + + public init(size: Int) { + let size = size.nextPowerOf2() + self.mask = size - 1 + self._buffer = .create(minimumCapacity: size, makingHeaderWith: { ref in + .init(capacity: size, tail: 0) + }) + _buffer.withUnsafeMutablePointers { headPtr, buffPtr in + let buffer = UnsafeMutableBufferPointer(start: buffPtr, count: headPtr.pointee.capacity) + for i in 0.. sending Element? { + let pos = _buffer.header.tail + var result:Element? = consume value + _buffer.withUnsafeMutablePointers { headPtr, buffPtr in + let buffer = UnsafeMutableBufferPointer(start: buffPtr, count: headPtr.pointee.capacity) + let node: UnsafeMutablePointer = buffer.baseAddress!.advanced(by: pos & mask) + let seq = node.pointee.sequence.load(ordering: .acquiring) + let difference = seq - pos + + if difference == 0 { + headPtr.pointee.tail += 1 + } else if difference < 0 { + return + } + node.pointee.data = consume result + result = nil + node.pointee.sequence.store(pos + 1, ordering: .releasing) + return + } + return result + } + + @inlinable + public func dequeue() -> sending Element? { + return _buffer.withUnsafeMutablePointers { headPtr, buffPtr in + let buffer = UnsafeMutableBufferPointer(start: buffPtr, count: headPtr.pointee.capacity) + var node: UnsafeMutablePointer! + var pos = head.load(ordering: .relaxed) + + while true { + + node = buffer.baseAddress!.advanced(by: pos & mask) + let seq = node.pointee.sequence.load(ordering: .acquiring) + let difference = seq - (pos + 1) + + if difference == 0 { + if head.weakCompareExchange(expected: pos, desired: pos + 1, successOrdering: .relaxed, failureOrdering: .relaxed).exchanged { + break + } + } else if difference < 0 { + return nil + } else { + pos = head.load(ordering: .relaxed) + } + } + var result:Element? = nil + swap(&result, &node.pointee.data) + node.pointee.sequence.store(pos + mask + 1, ordering: .releasing) + return result + } + + } + + @inline(__always) + public func dequeueAll(_ closure: (consuming sending Element) -> Void) { + while let element = dequeue() { + closure(element) + } + } + +} +extension FixedWidthInteger { + /// Returns the next power of two. + @inlinable + @_transparent + func nextPowerOf2() -> Self { + guard self != 0 else { + return 1 + } + return 1 << (Self.bitWidth - (self - 1).leadingZeroBitCount) + } +} diff --git a/Tests/TetraRunLoopConcurrencyTests/DrainSyscallDisciplineTests.swift b/Tests/TetraRunLoopConcurrencyTests/DrainSyscallDisciplineTests.swift new file mode 100644 index 0000000..12f4ce7 --- /dev/null +++ b/Tests/TetraRunLoopConcurrencyTests/DrainSyscallDisciplineTests.swift @@ -0,0 +1,189 @@ +// +// DrainSyscallDisciplineTests.swift +// TetraRunLoopConcurrencyTests +// +// Characterization tests pinning the behaviors at stake in the drain-path +// syscall optimizations: +// +// * QoS discipline (platform convention): `runBatch` never alters the owning +// thread's own QoS — no per-lane demotion while a job runs, and the entry +// class AND relative priority are intact after any drain. Effective priority +// is thread base + producer-installed overrides, as in libdispatch's +// runloop queues and the reference engine. +// * A strictly-earlier timer enqueued from INSIDE a job (owning thread, mid-pump) +// must be armed by the end of that pump pass — it may not wait for the +// previously-armed later deadline. This is what allows `enqueueTimer` to skip +// the producer self-wake on the owning-thread-in-pump path. +// + +#if canImport(Darwin) +import Testing +import Foundation +import CoreFoundation +import Dispatch +import HeapModule +@testable import TetraRunLoopConcurrency +import CriticalSection + +private final class WeakRef: @unchecked Sendable { + weak var value: T? +} +private final class Canary {} + +@Suite +struct DrainSyscallDisciplineTests { + + /// After a mixed-lane drain the owning thread still has its entry QoS class AND + /// relative priority — the drain leaves the thread's own QoS untouched. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func threadQoSClassAndRelativePriorityIntactAfterMixedLaneDrain() { + let restored = ManagedUnfairLock<(UInt32, Int32)?>(initialState: nil) + onThread { + pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, -4) + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + // .medium -> DEFAULT lane, .low -> UTILITY lane: both differ from the + // entry class, exercising the lanes that used to demote the thread. + executor.enqueue(makeJob(priority: .medium) { }) + executor.enqueue(makeJob(priority: .low) { executor.stop() }) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + var qos = QOS_CLASS_UNSPECIFIED + var relative = Int32(0) + pthread_get_qos_class_np(pthread_self(), &qos, &relative) + restored.withLockUnchecked { $0 = (qos.rawValue, relative) } + } + let result = restored.withLockUnchecked { $0 } + #expect(result?.0 == QOS_CLASS_USER_INITIATED.rawValue) + #expect(result?.1 == -4) + } + + /// Platform-convention QoS discipline (libdispatch runloop queues, the reference + /// engine): the drain never demotes the owning thread below its base QoS — a + /// background-lane job observes the thread's own requested QoS class, not + /// QOS_CLASS_BACKGROUND. Priority-inversion avoidance is the overrides' job. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func jobRunsAtThreadBaseQoSNotLaneQoS() { + let observed = ManagedUnfairLock(initialState: nil) + onThread { + pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .background) { + var qos = QOS_CLASS_UNSPECIFIED + var relative = Int32(0) + pthread_get_qos_class_np(pthread_self(), &qos, &relative) + observed.withLockUnchecked { $0 = qos.rawValue } + executor.stop() + }) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(observed.withLockUnchecked { $0 } == QOS_CLASS_USER_INITIATED.rawValue) + } + + /// A 30ms timer enqueued from inside a job while a 200ms timer is already armed + /// fires near its own deadline (not at the stale 200ms arming), proving the pump + /// re-arms the earlier deadline by the end of the pass without a producer wake. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func earlierTimerEnqueuedMidPumpRearmsWithoutProducerWake() { + let order = ManagedUnfairLock<[Int]>(initialState: []) + let earlyElapsed = ManagedUnfairLock(initialState: nil) + onThread { + let executor = StackBoundRunLoopExecutor.current() + let start = ContinuousClock.now + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .medium) { + order.withLockUnchecked { $0.append(200) } + executor.stop() + }, after: .milliseconds(200), clock: .continuous) + executor.enqueue(makeJob(priority: .medium) { + // Mid-pump, owning thread: this becomes the strictly-earlier min. + executor.enqueue(makeJob(priority: .medium) { + order.withLockUnchecked { $0.append(30) } + earlyElapsed.withLockUnchecked { $0 = start.duration(to: ContinuousClock.now) } + }, after: .milliseconds(30), clock: .continuous) + }) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(order.withLockUnchecked { $0 } == [30, 200]) + if let actual = earlyElapsed.withLockUnchecked({ $0 }) { + #expect(actual >= .milliseconds(30)) + // Firing anywhere near the stale 200ms arming means the re-arm was lost. + #expect(actual < .milliseconds(150)) + } else { + Issue.record("early timer never fired") + } + } + + /// Objects autoreleased by a job must be released by the end of that pump pass — + /// the drain runs inside its own autorelease pool (a bare-thread CFRunLoop pushes + /// none of its own, so without one they pile up until the thread exits). + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func autoreleasedObjectsDrainBetweenPumpPasses() { + let stillAlive = ManagedUnfairLock(initialState: nil) + let ref = WeakRef() + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .medium) { + let canary = Canary() + ref.value = canary + Unmanaged.passRetained(canary).autorelease() + // A later, separate pump pass observes whether the pool drained. + executor.enqueue(makeJob(priority: .medium) { + stillAlive.withLockUnchecked { $0 = ref.value != nil } + executor.stop() + }, after: .milliseconds(50), clock: .continuous) + }) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(stillAlive.withLockUnchecked { $0 } == false) + } + + /// Delayed jobs with IDENTICAL deadlines pop in enqueue (FIFO) order — the heap + /// ordering must carry a sequence tie-break, not leave equal keys unordered. + @Test @available(macOS 9999, *) + func equalDeadlineTimerJobsPopInFifoOrder() { + var heap = Heap() + let stamp = Timestamp(target: 1_000, leeway: 0) + var insertion: [UnsafeRawPointer] = [] + for i in 0..<8 { + // Identity-only jobs: never run, deliberately leaked (test process only). + let job = UnownedJob(makeJob(priority: .medium) { }) + insertion.append(unsafeBitCast(job, to: UnsafeRawPointer.self)) + heap.insert(TimestampJob(job: job, sequence: UInt64(i), timestamp: stamp)) + } + var popped: [UnsafeRawPointer] = [] + while let min = heap.popMin() { + popped.append(unsafeBitCast(min.job, to: UnsafeRawPointer.self)) + } + #expect(popped == insertion) + } + + /// Priority→lane mapping uses the reference engine's `>=` band boundaries: a raw + /// priority maps to the lane of the highest named priority it meets or exceeds + /// (33/25/21/17, else background). Named priorities land where they always did; + /// this pins the in-between raw values. + @Test func priorityLaneMappingUsesInclusiveBandBoundaries() { + let expected: [(UInt8, Int)] = [ + (33, 0), // userInteractive + (26, 1), (25, 1), // (high, userInteractive) band + high itself + (24, 2), (21, 2), // (medium, high) band + medium itself + (20, 3), (17, 3), // (low, medium) band + low itself + (16, 4), (10, 4), (9, 4), (1, 4), // below low -> background lane + ] + for (raw, lane) in expected { + #expect(TaskPriority(rawValue: raw).jobQueueIndex == lane, + "rawValue \(raw) should map to lane \(lane)") + } + } +} + +#endif // canImport(Darwin) diff --git a/Tests/TetraRunLoopConcurrencyTests/KQueueSelectorTests.swift b/Tests/TetraRunLoopConcurrencyTests/KQueueSelectorTests.swift new file mode 100644 index 0000000..920fe96 --- /dev/null +++ b/Tests/TetraRunLoopConcurrencyTests/KQueueSelectorTests.swift @@ -0,0 +1,20 @@ +import Testing +import Darwin +@testable import TetraRunLoopConcurrency + +@Suite struct KQueueSelectorTests { + @Test func wakeupMakesKqueueReadable() { + let fd = KQueueSelector.makeKQueue(); defer { close(fd) } + KQueueSelector.wakeup(fileDescriptor: fd) + let fired = KQueueSelector.drainEvents(fileDescriptor: fd) // consumes the EVFILT_USER + #expect(!fired.continuous && !fired.suspending && !fired.wall) // user wake reports no timer domain + } + @Test func suspendingTimerFires() { + let fd = KQueueSelector.makeKQueue(); defer { close(fd) } + let now = KQueueSelector.now(index: .suspending) + KQueueSelector.armTimer(fileDescriptor: fd, index: .suspending, target: now, leeway: 0, now: now) // due immediately + var pollFd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + #expect(poll(&pollFd, 1, 500) == 1) // becomes readable within 500ms + #expect(KQueueSelector.drainEvents(fileDescriptor: fd).suspending) + } +} diff --git a/Tests/TetraRunLoopConcurrencyTests/StackBoundRunLoopExecutorLifetimeTests.swift b/Tests/TetraRunLoopConcurrencyTests/StackBoundRunLoopExecutorLifetimeTests.swift new file mode 100644 index 0000000..97d8636 --- /dev/null +++ b/Tests/TetraRunLoopConcurrencyTests/StackBoundRunLoopExecutorLifetimeTests.swift @@ -0,0 +1,93 @@ +// +// StackBoundRunLoopExecutorLifetimeTests.swift +// TetraRunLoopConcurrencyTests +// +// Task C lifetime verification: after `run()`/`runUntil()` returns, the +// stack-bound `KQScheduler` engine (and, transitively, its `SlicedJobQueue`, +// CFFileDescriptor, and run-loop observer) must deallocate promptly — the run +// loop carries no extra retain on the engine. +// +// Observed by capturing the freshly mounted engine through the DEBUG-only +// `_debugOnMountEngine` hook into a `weak` box, then asserting the box is nil +// after `run()` returns. +// +// The hook is a PROCESS-GLOBAL, so while it is installed, engines mounted by +// *other* (parallel) test suites' `current()` calls fire it too. We therefore +// guard the capture on the test's own worker thread (`expectedThread`): only the +// engine mounted on this test's thread is recorded, so a concurrently-running +// suite can never contaminate `box.engine`. `.serialized` keeps this suite's own +// two tests from overlapping on the shared hook. +// + +#if canImport(Darwin) && DEBUG +import Testing +import Foundation +import CoreFoundation +import Dispatch +import Darwin +@testable import TetraRunLoopConcurrency +import CriticalSection + +@Suite(.serialized) +struct StackBoundRunLoopExecutorLifetimeTests { + + /// Holds a weak reference to the engine mounted on `expectedThread`, so the test + /// thread can inspect it after the worker thread's `run()` returns. Test-only; + /// `expectedThread` is written once (on the worker, before mount) and read by the + /// hook on the mounting thread — a benign test-only cross-thread read of a + /// pointer-sized value. + final class WeakEngineBox: @unchecked Sendable { + weak var engine: KQScheduler? + var expectedThread: pthread_t? + } + + /// After a mounted engine's `run()` returns, its `KQScheduler` deallocates: + /// the run loop held no extra retain, so the weak reference reads nil. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func engineDeallocatesAfterRunReturns() { + let box = WeakEngineBox() + StackBoundRunLoopExecutor._debugOnMountEngine = { eng in + if let t = box.expectedThread, pthread_equal(eng.thread, t) != 0 { box.engine = eng } + } + defer { StackBoundRunLoopExecutor._debugOnMountEngine = nil } + + onThread { + box.expectedThread = pthread_self() + let executor = StackBoundRunLoopExecutor.current() + executor.enqueue(makeJob(priority: .medium) { executor.stop() }) + try! executor.run() + } + + // `run()` returned on the worker thread: the frame's strong `engine` local + // dropped and the CFFileDescriptor was invalidated, dropping its context + // retain. Nothing else holds the engine, so it must be gone. + #expect(box.engine == nil) + } + + /// The facade can be re-mounted and re-run on a fresh thread cleanly, and that + /// second engine also deallocates — proving no live engine leaked from the first. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func reMountAndReRunDeallocatesEachEngine() { + for _ in 0..<2 { + let box = WeakEngineBox() + let ran = ManagedUnfairLock(initialState: false) + StackBoundRunLoopExecutor._debugOnMountEngine = { eng in + if let t = box.expectedThread, pthread_equal(eng.thread, t) != 0 { box.engine = eng } + } + onThread { + box.expectedThread = pthread_self() + let executor = StackBoundRunLoopExecutor.current() + executor.enqueue(makeJob(priority: .medium) { + ran.withLockUnchecked { $0 = true } + executor.stop() + }) + try! executor.run() + } + StackBoundRunLoopExecutor._debugOnMountEngine = nil + #expect(ran.withLockUnchecked { $0 }) + #expect(box.engine == nil) + } + } +} + +#endif // canImport(Darwin) && DEBUG diff --git a/Tests/TetraRunLoopConcurrencyTests/StackBoundRunLoopExecutorTests.swift b/Tests/TetraRunLoopConcurrencyTests/StackBoundRunLoopExecutorTests.swift new file mode 100644 index 0000000..3ea1d2e --- /dev/null +++ b/Tests/TetraRunLoopConcurrencyTests/StackBoundRunLoopExecutorTests.swift @@ -0,0 +1,260 @@ +// +// StackBoundRunLoopExecutorTests.swift +// TetraRunLoopConcurrencyTests +// +// Behavioral-verification suite for StackBoundRunLoopExecutor. +// Ported from swift-platform-executors' +// DarwinRunLoopExecutorTests/StackBoundRunLoopExecutor2Tests.swift (10 tests). +// +// Adaptations: +// * Type: StackBoundRunLoopExecutor (from @testable import TetraRunLoopConcurrency) +// * makeJob / onThread: from TestSupport.swift (Task 1). Not redefined here. +// * Synchronization.Mutex → ManagedUnfairLock (iOS 13+ compatible). +// * Timer clock arg: SlicedJobQueue.ClockIndex (.continuous / .suspending / .walltime). +// * Tests are NOT async: onThread(_:) is synchronous-blocking. +// * Timer tests gated @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) +// in addition to the @available(macOS 9999, *) makeJob gate. +// + +#if canImport(Darwin) +import Testing +import Foundation +import CoreFoundation +import Dispatch +@testable import TetraRunLoopConcurrency +// CriticalSection is an internal dependency; ManagedUnfairLock is accessible +// because the test target imports TetraRunLoopConcurrency @testable. +import CriticalSection + +@Suite +struct StackBoundRunLoopExecutorTests { + + // MARK: Non-timer tests + + /// Jobs enqueued while the executor is dormant buffer and flush once run() mounts. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func dormantEnqueueBuffersUntilRun() { + let order = ManagedUnfairLock<[Int]>(initialState: []) + onThread { + let executor = StackBoundRunLoopExecutor.current() + executor.enqueue(makeJob(priority: .medium) { order.withLockUnchecked { $0.append(1) } }) + executor.enqueue(makeJob(priority: .medium) { order.withLockUnchecked { $0.append(2) } }) + executor.enqueue(makeJob(priority: .medium) { + order.withLockUnchecked { $0.append(3) } + executor.stop() + }) + try! executor.run() + } + #expect(order.withLockUnchecked { $0 } == [1, 2, 3]) + } + + /// Stress: many threads enqueue concurrently; every job runs exactly once. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func concurrentEnqueueFromManyThreadsRunsEveryJob() { + let producers = 6 + let perProducer = 200 + let target = producers * perProducer + let ran = ManagedUnfairLock(initialState: 0) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + let priorities: [TaskPriority] = [.high, .medium, .low, .background, .high, .medium] + DispatchQueue.global(qos: .userInteractive).async { + DispatchQueue.concurrentPerform(iterations: producers) { p in + let priority = priorities[p % priorities.count] + for _ in 0..(initialState: 0) + onThread { + let executor = StackBoundRunLoopExecutor.current() + func hop() { + var n = 0 + count.withLockUnchecked { n = $0 + 1; $0 = n } + if n < hops { + executor.enqueue(makeJob(priority: .medium) { hop() }) + } else { + executor.stop() + } + } + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .medium) { hop() }) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(count.withLockUnchecked { $0 } == hops) + } + + /// Jobs drain highest-priority-first (FIFO within a priority); `stop()` unwinds. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func serialPriorityOrderingAndStop() { + let order = ManagedUnfairLock<[Int]>(initialState: []) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + func job(_ n: Int, _ priority: TaskPriority) -> ExecutorJob { + makeJob(priority: priority) { order.withLockUnchecked { $0.append(n) } } + } + executor.enqueue(job(3, .medium)) + executor.enqueue(job(5, .low)) + executor.enqueue(job(1, .high)) + executor.enqueue(job(6, .low)) + executor.enqueue(job(4, .medium)) + executor.enqueue(job(2, .high)) + executor.enqueue(makeJob(priority: .low) { + order.withLockUnchecked { $0.append(7) } + executor.stop() + }) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(order.withLockUnchecked { $0 } == [1, 2, 3, 4, 5, 6, 7]) + } + + /// `runUntil` unwinds as soon as its predicate turns true after a drain. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) + func runUntilPredicateUnwinds() { + let counter = ManagedUnfairLock(initialState: 0) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + for _ in 0..<5 { + executor.enqueue(makeJob(priority: .medium) { + counter.withLockUnchecked { $0 += 1 } + }) + } + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.runUntil { counter.withLockUnchecked { $0 } >= 5 } + } + #expect(counter.withLockUnchecked { $0 } == 5) + } + + /// After quiescence the executor re-arms for a fresh timer correctly. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func rearmAfterQuiescence() { + let fires = ManagedUnfairLock(initialState: 0) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .medium) { + fires.withLockUnchecked { $0 += 1 } + executor.enqueue(makeJob(priority: .medium) { + fires.withLockUnchecked { $0 += 1 } + executor.stop() + }, after: .milliseconds(30), clock: .continuous) + }, after: .milliseconds(30), clock: .continuous) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(fires.withLockUnchecked { $0 } == 2) + } + + // MARK: Timer tests + + /// A continuous-clock delayed job fires no earlier than its deadline. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func scheduledJobFiresAfterDelay() { + let fired = ManagedUnfairLock(initialState: false) + let elapsed = ManagedUnfairLock(initialState: nil) + onThread { + let executor = StackBoundRunLoopExecutor.current() + let start = ContinuousClock.now + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + let job = makeJob(priority: .medium) { + fired.withLockUnchecked { $0 = true } + elapsed.withLockUnchecked { $0 = start.duration(to: ContinuousClock.now) } + executor.stop() + } + executor.enqueue(job, after: .milliseconds(50), clock: .continuous) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(fired.withLockUnchecked { $0 }) + if let actual = elapsed.withLockUnchecked({ $0 }) { + #expect(actual >= .milliseconds(50)) + #expect(actual < .milliseconds(650)) + } + } + + /// Timers inserted out of deadline order fire in deadline order (re-arm path). + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func multipleTimersFireInDeadlineOrder() { + let order = ManagedUnfairLock<[Int]>(initialState: []) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + func fire(_ n: Int, last: Bool = false) -> ExecutorJob { + makeJob(priority: .medium) { + order.withLockUnchecked { $0.append(n) } + if last { executor.stop() } + } + } + executor.enqueue(fire(90, last: true), after: .milliseconds(90), clock: .continuous) + executor.enqueue(fire(30), after: .milliseconds(30), clock: .continuous) + executor.enqueue(fire(60), after: .milliseconds(60), clock: .continuous) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(order.withLockUnchecked { $0 } == [30, 60, 90]) + } + + /// The suspending-clock domain works (uptime deadline). + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func suspendingClockTimerFires() { + let fired = ManagedUnfairLock(initialState: false) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .medium) { + fired.withLockUnchecked { $0 = true } + executor.stop() + }, after: .milliseconds(30), clock: .suspending) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(fired.withLockUnchecked { $0 }) + } + + /// The wall-clock domain works. + @Test(.timeLimit(.minutes(1))) @available(macOS 9999, *) @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) + func wallClockTimerFires() { + let fired = ManagedUnfairLock(initialState: false) + onThread { + let executor = StackBoundRunLoopExecutor.current() + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) { + executor.enqueue(makeJob(priority: .medium) { + fired.withLockUnchecked { $0 = true } + executor.stop() + }, after: .milliseconds(30), clock: .walltime) + } + CFRunLoopWakeUp(CFRunLoopGetCurrent()) + try! executor.run() + } + #expect(fired.withLockUnchecked { $0 }) + } +} + +#endif // canImport(Darwin) diff --git a/Tests/TetraRunLoopConcurrencyTests/TestSupport.swift b/Tests/TetraRunLoopConcurrencyTests/TestSupport.swift new file mode 100644 index 0000000..b927989 --- /dev/null +++ b/Tests/TetraRunLoopConcurrencyTests/TestSupport.swift @@ -0,0 +1,15 @@ +import Darwin +import Dispatch +import Foundation +@testable import TetraRunLoopConcurrency +@_spi(ExperimentalScheduling) @_spi(ConcurrencyExecutors) @_spi(ExperimentalCustomExecutors) import _Concurrency + +@available(macOS 9999, *) +func makeJob(priority: TaskPriority = .medium, _ body: @escaping () -> Void) -> ExecutorJob { + _swift_createJobForTestingOnly(priority: priority, body) +} + +func onThread(_ body: @escaping @Sendable () -> Void) { + let done = DispatchSemaphore(value: 0) + let t = Thread { body(); done.signal() }; t.stackSize = 1 << 22; t.start(); done.wait() +} diff --git a/Tests/TetraTests/AnyEncodableTests.swift b/Tests/TetraTests/AnyEncodableTests.swift index 917f682..98412c5 100644 --- a/Tests/TetraTests/AnyEncodableTests.swift +++ b/Tests/TetraTests/AnyEncodableTests.swift @@ -4,62 +4,51 @@ // // Created by pbk on 2023/01/27. // - -import XCTest +import Foundation @testable import Tetra +import Testing -final class AnyEncodableTests: XCTestCase { +@Suite +struct AnyEncodableTests { + @Test func testURLEncoding() throws { let targetURL = FileManager.default.temporaryDirectory - - XCTAssertEqual( - try JSONEncoder().encode(AnyEncodable(targetURL)), - try JSONEncoder().encode(targetURL) - ) - - try XCTExpectFailure { - XCTAssertEqual( - try JSONEncoder().encode(AnyErasedEncodable(value: targetURL)), - try JSONEncoder().encode(targetURL) - ) - } - - + let defaultValue = try JSONEncoder().encode(targetURL) + let anyValue = try JSONEncoder().encode(AnyEncodable(targetURL)) + let erasedValue = try JSONEncoder().encode(AnyErasedEncodable(value: targetURL)) + #expect(defaultValue == anyValue) + #expect(defaultValue != erasedValue) } - + + @Test func testURLObjectEncoding() throws { let targetURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let objectForm = ["A": targetURL, "B": targetURL] let wrappedFrom = AnyEncodable(objectForm) - XCTAssertEqual( - try JSONSerialization.jsonObject(with: JSONEncoder().encode(objectForm)) as! NSDictionary, - try JSONSerialization.jsonObject(with: JSONEncoder().encode(wrappedFrom)) as! NSDictionary - ) - - XCTAssertEqual( - try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(objectForm), format: nil) as! NSDictionary, - try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(wrappedFrom), format: nil) as! NSDictionary - ) + var wrappedObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(wrappedFrom)) as! NSDictionary + var rawObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(objectForm)) as! NSDictionary + #expect(wrappedObject == rawObject) + rawObject = try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(objectForm), format: nil) as! NSDictionary + wrappedObject = try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(wrappedFrom), format: nil) as! NSDictionary + #expect(rawObject == wrappedObject) } + @Test func testURLArrayEncoding() throws { let targetURL = FileManager.default.temporaryDirectory let arrayForm = (0..<10).map{ _ in targetURL.appendingPathComponent(UUID().uuidString) } let wrappedFrom = AnyEncodable(arrayForm) - XCTAssertEqual( - try JSONSerialization.jsonObject(with: JSONEncoder().encode(arrayForm)) as! NSArray, - try JSONSerialization.jsonObject(with: JSONEncoder().encode(wrappedFrom)) as! NSArray - ) - - XCTAssertEqual( - try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(arrayForm), format: nil) as! NSArray, - try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(wrappedFrom), format: nil) as! NSArray - ) + var rawArray = try JSONSerialization.jsonObject(with: JSONEncoder().encode(arrayForm)) as! NSArray + var wrappedArray = try JSONSerialization.jsonObject(with: JSONEncoder().encode(wrappedFrom)) as! NSArray + #expect(rawArray == wrappedArray) + rawArray = try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(arrayForm), format: nil) as! NSArray + wrappedArray = try PropertyListSerialization.propertyList(from: PropertyListEncoder().encode(wrappedFrom), format: nil) as! NSArray + #expect(rawArray == wrappedArray) } } diff --git a/Tests/TetraTests/AsyncFlatMapTests.swift b/Tests/TetraTests/AsyncFlatMapTests.swift new file mode 100644 index 0000000..fa82066 --- /dev/null +++ b/Tests/TetraTests/AsyncFlatMapTests.swift @@ -0,0 +1,280 @@ +// +// AsyncFlatMapTests.swift +// +// +// Created by 박병관 on 6/7/24. +// + +import XCTest +import Combine +@testable import Tetra +@testable import BackPortAsyncSequence + +final class AsyncFlatMapTests: XCTestCase { + + + func testOrderd() throws { + let completion = expectation(description: "complete") + var array = [Int]() + let sample = Array((0..<5)) + let bag = [0,1].publisher + .handleEvents( + receiveRequest: { + XCTAssertEqual($0, .max(1)) + } + ) + .asyncFlatMap(maxTasks: .max(1)) { value in + return AsyncStream{ continuation in + sample.forEach{ + let result = continuation.yield($0) + if case .enqueued(_) = result { + + } else { + XCTFail("should not reach") + } + } + continuation.finish() + }.tetra.bridge() + } + .handleEvents( + receiveSubscription: { + XCTAssertEqual("\($0)", "AsyncFlatMap") + } + ) + // ensure downstream do not request unlimited + .buffer(size: 1, prefetch: .keepFull, whenFull: .customError{ fatalError() }) + //.prefix(10) + .sink { _ in + completion.fulfill() + } receiveValue: { + array.append($0) + } + wait(for: [completion], timeout: 0.5) + bag.cancel() + XCTAssertEqual(array, sample + sample) + } + + func testUnOrderd() throws { + let completion = expectation(description: "complete") + var array = [Int]() + let sample = Array((0..<5)) + let lock = NSRecursiveLock() + let bag = [0,1].publisher + .handleEvents( + receiveRequest: { + XCTAssertEqual($0, .max(2)) + } + ) + .asyncFlatMap(maxTasks: .max(2)) { value in + let base = AsyncTypedStream(base: AsyncStream{ continuation in + sample.forEach{ + continuation.yield($0 + value * 10) + } + continuation.finish() + }) + return BackPort.AsyncMapSequence(base, transform: { + await Task.yield() + return $0 + }) + }.handleEvents( + receiveSubscription: { + XCTAssertEqual("\($0)", "AsyncFlatMap") + } + ) + .sink { _ in + completion.fulfill() + } receiveValue: { value in + array.append(value) + + } + wait(for: [completion]) + bag.cancel() + XCTAssertEqual(Set(array), [0,1,2,3,4,10,11,12,13,14]) + } + + func testCancelInTranform() throws { + let holder = UnsafeCancellableHolder() + let completion = expectation(description: "cancellation") + let lock = NSRecursiveLock() + lock.withLock { + (0..<5).publisher + .asyncFlatMap(maxTasks: .unlimited) { value in + lock.withLock{ + holder.bag = [] + } + return AsyncTypedStream(base: AsyncStream{ + $0.yield(value) + $0.finish() + }) + } + .handleEvents( + receiveCancel: { + completion.fulfill() + } + ).sink { _ in + XCTFail("should not reach here") + } receiveValue: { _ in + XCTFail("should not reach here") + }.store(in: &holder.bag) + } + wait(for: [completion], timeout: 0.2) + } + + func testCancelInSegment() throws { + let holder = UnsafeCancellableHolder() + let completion = expectation(description: "cancellation") + let lock = NSRecursiveLock() + lock.withLock { + (0..<5).publisher +// .setFailureType(to: Error.self) + .asyncFlatMap(maxTasks: .unlimited) { value throws(Never) in + let stream = AsyncStream{ + $0.yield(value) + $0.finish() + } + let source = AsyncTypedStream(base: stream) + return BackPort.AsyncMapSequence(source) { + lock.withLock{ + holder.bag = [] + } + return $0 + } + } + .handleEvents( + receiveCancel: { + completion.fulfill() + } + ).sink { _ in + XCTFail("should not reach here") + } receiveValue: { _ in + XCTFail("should not reach here") + }.store(in: &holder.bag) + } + wait(for: [completion], timeout: 0.2) + } + + func testCancelInSubscriber() throws { + let holder = UnsafeCancellableHolder() + let completion = expectation(description: "cancellation") + let lock = NSRecursiveLock() + lock.withLock { + (0..<5).publisher +// .setFailureType(to: Error.self) + .asyncFlatMap(maxTasks: .unlimited) { @Sendable value in + let stream = AsyncStream{ @Sendable in + $0.yield(value) + $0.finish() + } + return AsyncTypedStream(base: stream) + }.handleEvents( + receiveCancel: { @Sendable in + completion.fulfill() + } + ).sink { _ in + XCTFail("should not reach here") + } receiveValue: { _ in + holder.bag = [] + }.store(in: &holder.bag) + } + wait(for: [completion], timeout: 0.2) + } + + + func testThrowInTransformer() throws { + let holder = UnsafeCancellableHolder() + let completion = expectation(description: "cancellation") + (0..<5).publisher + .setFailureType(to: CancellationError.self) + .asyncFlatMap(maxTasks: .max(1)) { value throws(CancellationError) in + if value == 3 { + throw CancellationError() + } + let base = AsyncTypedStream(base: AsyncStream{ + $0.yield(value) + $0.finish() + }) + return AsyncMapErrorSequence(base: base, failure: CancellationError.self) + }.sink { + switch $0 { + case .finished: + break + case .failure(let error): + completion.fulfill() + } + } receiveValue: { + XCTAssertLessThan($0, 3) + }.store(in: &holder.bag) + wait(for: [completion], timeout: 0.2) + } + + func testThrowInSegment() throws { + let holder = UnsafeCancellableHolder() + let completion = expectation(description: "cancellation") + (0..<5).publisher + .setFailureType(to: CancellationError.self) + .asyncFlatMap(maxTasks: .max(1)) { value throws(CancellationError) in + let base = AsyncTypedStream(base: AsyncStream{ + $0.yield(value) + $0.finish() + }) + return BackPort.AsyncMapSequence(base, CancellationError.self, transform: { value2 throws(CancellationError) in + if value2 == 3 { + throw CancellationError() + } + return value2 + }) + } + .sink { + switch $0 { + case .finished: + break + case .failure(let error): + completion.fulfill() + XCTAssertTrue(error is CancellationError) + } + } receiveValue: { + XCTAssertLessThan($0, 3) + }.store(in: &holder.bag) + wait(for: [completion], timeout: 0.2) + } + + func testTransformUnsafeCancel() throws { + // If Task cancellation occur in transformer, the returned Segment is responsible to handle the cancellation, + // created Segment and transformer runs in the same task + // UnsafeCurrentTask cancellation has no effect on root task + let holder = UnsafeCancellableHolder() + let completion = expectation(description: "complete") + var buffer = [Int]() + (0..<5).publisher + .asyncFlatMap(maxTasks: .max(1)) { value in + if value == 0 { + withUnsafeCurrentTask{ + $0?.cancel() + } + } + let transformTask = withUnsafeCurrentTask{ $0 }?.hashValue + // every segment runs in separate child task + let stream = AsyncStream{ + await Task.yield() + withUnsafeCurrentTask { + XCTAssertEqual(transformTask, $0?.hashValue) + } + if Task.isCancelled { + return nil + } + withUnsafeCurrentTask{$0?.cancel()} + return value + } + return AsyncTypedStream(base: stream) + }.sink { _ in + completion.fulfill() + } receiveValue: { + buffer.append($0) + }.store(in: &holder.bag) + wait(for: [completion], timeout: 200) + XCTAssertEqual(buffer, [1,2,3,4]) + } + + + +} diff --git a/Tests/TetraTests/AsyncSequencePublisherTests.swift b/Tests/TetraTests/AsyncSequencePublisherTests.swift index 19f1890..d51c809 100644 --- a/Tests/TetraTests/AsyncSequencePublisherTests.swift +++ b/Tests/TetraTests/AsyncSequencePublisherTests.swift @@ -9,6 +9,8 @@ import Foundation import XCTest @testable import Tetra import Combine +import BackPortAsyncSequence +import Namespace class AsyncSequencePublisherTests: XCTestCase { @@ -22,7 +24,8 @@ class AsyncSequencePublisherTests: XCTestCase { source.forEach{ continuation.yield($0) } continuation.finish() } - let cancellable = AsyncSequencePublisher(base: stream) + let cancellable = AsyncTypedStream(base: stream) + .tetra.toPublisher() .catch{ _ in XCTFail() return Empty() @@ -59,7 +62,7 @@ class AsyncSequencePublisherTests: XCTestCase { } return value } - let pub = AsyncSequencePublisher(base: asyncSequence) + let pub = AsyncSequencePublisher(base: LegacyTypedAsyncSequence(base: asyncSequence)) .handleEvents( receiveCancel: { expect.fulfill() } ) @@ -88,7 +91,7 @@ class AsyncSequencePublisherTests: XCTestCase { } return value } - let cancellable = AsyncSequencePublisher(base: asyncSequence) + let cancellable = AsyncSequencePublisher(base: LegacyTypedAsyncSequence(base: asyncSequence)) .mapError{ $0 as! CancellationError } diff --git a/Tests/TetraTests/MapTaskTests.swift b/Tests/TetraTests/MapTaskTests.swift index 627923f..60e48a6 100644 --- a/Tests/TetraTests/MapTaskTests.swift +++ b/Tests/TetraTests/MapTaskTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import Tetra import Combine - +import Testing final class MapTaskTests: XCTestCase { @@ -118,8 +118,8 @@ final class MapTaskTests: XCTestCase { } .handleEvents( receiveSubscription: { subscription in - warmup.fulfill() XCTAssertEqual("\(subscription)", "MapTask") + warmup.fulfill() }, receiveOutput: { value in outputHandle(value) @@ -182,3 +182,7 @@ final class MapTaskTests: XCTestCase { } } + +func asdfasdf() { + +} diff --git a/Tests/TetraTests/MultiMapTaskTests.swift b/Tests/TetraTests/MultiMapTaskTests.swift index 9fbf485..bc4f648 100644 --- a/Tests/TetraTests/MultiMapTaskTests.swift +++ b/Tests/TetraTests/MultiMapTaskTests.swift @@ -22,7 +22,7 @@ final class MultiMapTaskTests: XCTestCase { ) let cancellable = MultiMapTask(maxTasks: .max(1), upstream: publisher) { await Task.yield() - return .success($0) + return $0 }.sink { _ in expect.fulfill() } receiveValue: { @@ -43,16 +43,19 @@ final class MultiMapTaskTests: XCTestCase { let pub = MultiMapTask(maxTasks: .max(1), upstream: input.publisher) { value in if value == target { await withUnsafeContinuation { - lock.withLock { +// lock.withLock { holder.bag.removeAll() - } +// } $0.resume() } XCTAssertTrue(Task.isCancelled) } - return .success(value) + return value }.handleEvents( - receiveCancel: { expect.fulfill() } + receiveCancel: { + print("CAll") + expect.fulfill() + } ) lock.withLock { pub.sink { _ in @@ -63,7 +66,7 @@ final class MultiMapTaskTests: XCTestCase { } - wait(for: [expect], timeout: 0.5) + wait(for: [expect]) } func testSerialFailure() throws { @@ -71,12 +74,14 @@ final class MultiMapTaskTests: XCTestCase { let target = try XCTUnwrap(sequence.randomElement()) let upstream = sequence.publisher.setFailureType(to: CancellationError.self) let expect = expectation(description: "task failure") - let pub = MultiMapTask(maxTasks: .max(1), upstream: upstream, transform: { value in - if value == target { - return .failure(CancellationError()) as Result + let block:@Sendable (Int) async throws(CancellationError) -> sending Int = { + if $0 == target { + try Result.failure(CancellationError()).get() } - return .success(value) as Result - }) + await Task.yield() + return $0 + } + let pub = MultiMapTask(maxTasks: .max(1), upstream: upstream, transform: block) var bag = Set() pub.sink { completion in switch completion { @@ -121,7 +126,7 @@ final class MultiMapTaskTests: XCTestCase { ) .multiMapTask(maxTasks: .max(3)) { await Task.yield() - return .success($0) + return $0 }.subscribe(subscriber) wait(for: [warmup]) let subscription = try XCTUnwrap(_subscription) @@ -167,7 +172,7 @@ final class MultiMapTaskTests: XCTestCase { ) .multiMapTask(maxTasks: .unlimited) { try? await Task.sleep(nanoseconds: 1_000) - return .success($0) + return $0 } .handleEvents( receiveSubscription: { _ in diff --git a/Tests/TetraTests/NSManagedObjectContextTests.swift b/Tests/TetraTests/NSManagedObjectContextTests.swift index 20f5e5c..0420029 100644 --- a/Tests/TetraTests/NSManagedObjectContextTests.swift +++ b/Tests/TetraTests/NSManagedObjectContextTests.swift @@ -5,41 +5,44 @@ // Created by 박병관 on 6/5/24. // -import XCTest +import Testing #if canImport(CoreData) import CoreData @testable import Tetra +internal import NamespaceExtension -final class NSManagedObjectContextTests: XCTestCase { +@Suite +struct NSManagedObjectContextTests { + @Test func testBlocking() throws { let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let uuid = UUID() let returnValue = context.tetra._performAndWait { return uuid } - XCTAssertEqual(uuid, returnValue) + #expect(uuid == returnValue) let result = Result { try context.tetra._performAndWait { throw CancellationError() } } - XCTAssertThrowsError(try result.get()) { - XCTAssertTrue($0 is CancellationError, "\($0) is not \(CancellationError.self)") - - } + #expect(throws: CancellationError.self, performing: { + try result.get() + }) } + @Test func testMainAsync() async throws { let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) let uuid = UUID() let returnValue = await context.tetra._performEnqueue { - XCTAssertTrue(Thread.isMainThread) + #expect(Thread.isMainThread) return uuid } - XCTAssertEqual(uuid, returnValue) + #expect(uuid == returnValue) let result:Result do { try await context.tetra._performEnqueue{ @@ -49,19 +52,20 @@ final class NSManagedObjectContextTests: XCTestCase { } catch { result = .failure(error) } - XCTAssertThrowsError(try result.get()) { - XCTAssertTrue($0 is CancellationError, "\($0) is not \(CancellationError.self)") - } + #expect(throws: CancellationError.self, performing: { + try result.get() + }) } + @Test func testBackgroundAsync() async throws { let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let uuid = UUID() let returnValue = await context.tetra._performEnqueue { - XCTAssertFalse(Thread.isMainThread) + #expect(!Thread.isMainThread) return uuid } - XCTAssertEqual(uuid, returnValue) + #expect(uuid == returnValue) let result:Result do { try await context.tetra._performEnqueue{ @@ -71,41 +75,39 @@ final class NSManagedObjectContextTests: XCTestCase { } catch { result = .failure(error) } - XCTAssertThrowsError(try result.get()) { - XCTAssertTrue($0 is CancellationError, "\($0) is not \(CancellationError.self)") + #expect(throws: CancellationError.self) { + try result.get() } } - - func testImmediate() throws { + @Test + func testImmediate() async throws { let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - let completion = expectation(description: "completion") - context.perform { - defer { completion.fulfill() } - let uuid = UUID() - let expectValue = context.tetra._performImmediate { - return uuid - } - XCTAssertEqual(expectValue, .success(uuid)) - let expectThrow = Result { - try context.tetra._performImmediate { - throw CancellationError() + let _:Void = await withUnsafeContinuation{ continuation in + context.perform { + defer { + continuation.resume() } - } - func checkError() { - XCTAssertThrowsError(try expectThrow.get()) { - XCTAssertTrue($0 is CancellationError, "\($0) is not \(CancellationError.self)") + let uuid = UUID() + let expectValue = context.tetra._performImmediate { + return uuid + } + #expect(expectValue == .success(uuid)) + let expectThrow = Result { + try context.tetra._performImmediate { + throw CancellationError() + } + } + #expect(throws: CancellationError.self) { + try expectThrow.get() } } - checkError() - } - - let expectNil = context.tetra._performImmediate { + let expectNil = context.tetra._performImmediate { + + } + #expect(expectNil == nil) } - XCTAssertNil(expectNil) - - wait(for: [completion], timeout: 0.1) } diff --git a/Tests/TetraTests/NotificationSequenceTests.swift b/Tests/TetraTests/NotificationSequenceTests.swift index d4c6253..78a02db 100644 --- a/Tests/TetraTests/NotificationSequenceTests.swift +++ b/Tests/TetraTests/NotificationSequenceTests.swift @@ -5,11 +5,14 @@ // Created by pbk on 2023/01/27. // -import XCTest +import Testing +import Foundation @testable import Tetra -final class NotificationSequenceTests: XCTestCase { +@Suite +struct NotificationSequenceTests { + @Test func testNotificationSequence() async throws { let name = Notification.Name(UUID().uuidString) let object = NSObject() @@ -34,9 +37,10 @@ final class NotificationSequenceTests: XCTestCase { task.cancel() NotificationCenter.default.post(name: name, object: object, userInfo: ["":""]) let count = await task.value - XCTAssertEqual(count, 2) + #expect(count == 2) } + @Test func testAlreadyCancelled() async throws { let name = Notification.Name(UUID().uuidString) let object = NSObject() @@ -63,7 +67,7 @@ final class NotificationSequenceTests: XCTestCase { } task.cancel() let count = await task.value - XCTAssertEqual(count, 0) + #expect(count == 0) } } diff --git a/Tests/TetraTests/PlistWrapperTests.swift b/Tests/TetraTests/PlistWrapperTests.swift index 25ad5f2..1028b59 100644 --- a/Tests/TetraTests/PlistWrapperTests.swift +++ b/Tests/TetraTests/PlistWrapperTests.swift @@ -5,13 +5,15 @@ // Created by pbk on 2023/05/30. // -import XCTest +import Testing +import Foundation @testable import Tetra -final class PlistWrapperTests: XCTestCase { - +@Suite +struct PlistWrapperTests { - func testDataArraySerialization() throws { + @Test + func dataArraySerialization() throws { let data = Data(UUID().uuidString.utf8) let wrapper:PlistWrapper = [.data(data)] let actual = NSArray(object: (data as NSData)) @@ -19,13 +21,16 @@ final class PlistWrapperTests: XCTestCase { pEncoder.outputFormat = .xml let serializedXMLData = try PropertyListSerialization.data(fromPropertyList: actual, format: .xml, options: 0) let encodedXMLData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedXMLData, encodedXMLData) + #expect(serializedXMLData == encodedXMLData) +// XCTAssertEqual(serializedXMLData, encodedXMLData) pEncoder.outputFormat = .binary let serializedBinaryData = try PropertyListSerialization.data(fromPropertyList: actual, format: .binary, options: 0) let encodedBinaryData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedBinaryData, encodedBinaryData) + #expect(serializedBinaryData == encodedBinaryData) +// XCTAssertEqual(serializedBinaryData, encodedBinaryData) } + @Test func testDataDictionarySerialization() throws { let object = ["A": Data(UUID().uuidString.utf8)] let wrapper:PlistWrapper = .object(object.mapValues{ .data($0) }) @@ -34,13 +39,14 @@ final class PlistWrapperTests: XCTestCase { pEncoder.outputFormat = .xml let serializedXMLData = try PropertyListSerialization.data(fromPropertyList: actual, format: .xml, options: 0) let encodedXMLData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedXMLData, encodedXMLData) + #expect(serializedXMLData == encodedXMLData) pEncoder.outputFormat = .binary let serializedBinaryData = try PropertyListSerialization.data(fromPropertyList: actual, format: .binary, options: 0) let encodedBinaryData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedBinaryData, encodedBinaryData) + #expect(serializedBinaryData == encodedBinaryData) } + @Test func testDateArraySerialization() throws { let date = Date() let wrapper:PlistWrapper = [.date(date)] @@ -49,13 +55,14 @@ final class PlistWrapperTests: XCTestCase { pEncoder.outputFormat = .xml let serializedXMLData = try PropertyListSerialization.data(fromPropertyList: actual, format: .xml, options: 0) let encodedXMLData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedXMLData, encodedXMLData) + #expect(serializedXMLData == encodedXMLData) pEncoder.outputFormat = .binary let serializedBinaryData = try PropertyListSerialization.data(fromPropertyList: actual, format: .binary, options: 0) let encodedBinaryData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedBinaryData, encodedBinaryData) + #expect(serializedBinaryData == encodedBinaryData) } + @Test func testDateDictionarySerialization() throws { let object = [ UUID().uuidString: Date() @@ -66,35 +73,38 @@ final class PlistWrapperTests: XCTestCase { pEncoder.outputFormat = .xml let serializedXMLData = try PropertyListSerialization.data(fromPropertyList: actual, format: .xml, options: 0) let encodedXMLData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedXMLData, encodedXMLData) + #expect(serializedXMLData == encodedXMLData) pEncoder.outputFormat = .binary let serializedBinaryData = try PropertyListSerialization.data(fromPropertyList: actual, format: .binary, options: 0) let encodedBinaryData = try pEncoder.encode(wrapper) - XCTAssertEqual(serializedBinaryData, encodedBinaryData) + #expect(serializedBinaryData == encodedBinaryData) } + @Test func testCustomDecoder1() throws { try runCustomDecoder( JsonSample1Model.self, - url: XCTUnwrap( + url: #require( Bundle.module.url(forResource: "PlistSample1", withExtension: "plist") ) ) } + @Test func testCustomDecoder2() throws { try runCustomDecoder( JsonSample2Model.self, - url: XCTUnwrap( + url: #require( Bundle.module.url(forResource: "PlistSample2", withExtension: "plist") ) ) } + @Test func testCustomDecoder3() throws { try runCustomDecoder( JsonSample3Model.self, - url: XCTUnwrap( + url: #require( Bundle.module.url(forResource: "PlistSample3", withExtension: "plist") ) ) @@ -105,33 +115,36 @@ final class PlistWrapperTests: XCTestCase { let model = try PropertyListDecoder().decode(type, from: data) let jsonWrapper = try PlistWrapper(from: data) let model2 = try PlistWrapperDecoder().decode(type, from: jsonWrapper) - XCTAssertEqual(model, model2) + #expect(model == model2) } private func runCustomEncoder(_ value:T) throws { let data = try PropertyListEncoder().encode(value) let jsonWrapper = try PropertyListSerialization.propertyList(from: data, format: nil) as! NSObject let model = try PlistWrapperEncoder().encode(value).propertyObject as! NSObject - XCTAssertEqual(model, jsonWrapper) + #expect(model == jsonWrapper) } + @Test func testCustomEncoder1() throws { - let url = try XCTUnwrap(Bundle.module.url(forResource: "PlistSample1", withExtension: "plist")) + let url = try #require(Bundle.module.url(forResource: "PlistSample1", withExtension: "plist")) let model = try PropertyListDecoder().decode(JsonSample1Model.self, from: Data(contentsOf: url)) try runCustomEncoder(model) } + @Test func testCustomEncoder2() throws { - let url = try XCTUnwrap(Bundle.module.url(forResource: "PlistSample2", withExtension: "plist")) + let url = try #require(Bundle.module.url(forResource: "PlistSample2", withExtension: "plist")) let model = try PropertyListDecoder().decode(JsonSample2Model.self, from: Data(contentsOf: url)) try runCustomEncoder(model) } + @Test func testCustomEncoder3() throws { - let url = try XCTUnwrap(Bundle.module.url(forResource: "PlistSample3", withExtension: "plist")) + let url = try #require(Bundle.module.url(forResource: "PlistSample3", withExtension: "plist")) let model = try PropertyListDecoder().decode(JsonSample3Model.self, from: Data(contentsOf: url)) try runCustomEncoder(model) } - + } diff --git a/Tests/TetraTests/RunLoopSchedulerTests.swift b/Tests/TetraTests/RunLoopSchedulerTests.swift deleted file mode 100644 index 3bf9796..0000000 --- a/Tests/TetraTests/RunLoopSchedulerTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// RunLoopSchedulerTests.swift -// -// -// Created by pbk on 2023/01/27. -// - -import XCTest -@testable import Tetra - -final class RunLoopSchedulerTests: XCTestCase { - - func testBlockingInitializerPerformance() { - measure { - let _ = RunLoopScheduler(sync: ()) - } - } - - func testRunLoopBasic() async { - let scheduler = await RunLoopScheduler(async: (), config: .init(qos: .background)) - await withUnsafeContinuation{ continuation in - scheduler.schedule { - XCTAssertEqual(CFRunLoopGetCurrent(), scheduler.cfRunLoop) - continuation.resume() - } - } - await withUnsafeContinuation{ continuation in - let date = Date().addingTimeInterval(0.5) - scheduler.schedule(after: .init(date)) { - XCTAssertEqual( - date.timeIntervalSinceReferenceDate, - Date().timeIntervalSinceReferenceDate, - accuracy: 0.05 - ) - XCTAssertEqual(CFRunLoopGetCurrent(), scheduler.cfRunLoop) - - continuation.resume() - } - } - } - - func testRunLoopNotificationQueue() async { - let scheduler = await RunLoopScheduler(async: (), config: .init(qos: .background)) - let name = Notification.Name(UUID().uuidString) - let object = NSObject() - let expect1 = expectation(forNotification: name, object: object, notificationCenter: .default) { notification in - XCTAssertTrue(notification.userInfo?["A"] as? String == "B") - return true - } - - scheduler.schedule { - NotificationQueue.default - .enqueue(.init(name: name, object: object, userInfo: ["A":"B"]), postingStyle: .whenIdle, coalesceMask: [.onName, .onSender], forModes: nil) - NotificationQueue.default - .enqueue(.init(name: name, object: object, userInfo: ["A":"1"]), postingStyle: .whenIdle, coalesceMask: [.onName, .onSender], forModes: nil) - NotificationQueue.default - .enqueue(.init(name: name, object: object, userInfo: ["A":"2"]), postingStyle: .whenIdle, coalesceMask: [.onName, .onSender], forModes: nil) - NotificationQueue.default - .enqueue(.init(name: name, object: object, userInfo: ["A":"3"]), postingStyle: .whenIdle, coalesceMask: [.onName, .onSender], forModes: nil) - - } - await fulfillment(of: [expect1], timeout: 1) - let expect2 = expectation(forNotification: name, object: object, notificationCenter: .default) { - XCTAssertTrue($0.userInfo?["A"] as? String == "C") - return true - } - - scheduler.schedule { - NotificationQueue.default - .enqueue(.init(name: name, object: object, userInfo: ["A":"C"]), postingStyle: .whenIdle, coalesceMask: [.onName, .onSender], forModes: nil) - } - await fulfillment(of: [expect2], timeout: 1) - } - -} diff --git a/Tests/TetraTests/TetraTests.swift b/Tests/TetraTests/TetraTests.swift index 4296f08..c9302a7 100644 --- a/Tests/TetraTests/TetraTests.swift +++ b/Tests/TetraTests/TetraTests.swift @@ -5,29 +5,16 @@ // Created by iquest1127 on 2022/12/19. // -import XCTest +import Testing import os @testable import Tetra import Combine +import CriticalSection -final class TetraTests: XCTestCase { - - override func setUp() async throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() async throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } +@Suite +struct TetraTests { + @Test func testUnfairLockPrecondition() throws { if #available(iOS 16.0, tvOS 16.0, macCatalyst 16.0, macOS 13.0, watchOS 9.0, *) { let lock = OSAllocatedUnfairLock() @@ -44,5 +31,7 @@ final class TetraTests: XCTestCase { lock.precondition(.notOwner) } } - + } + + diff --git a/Tests/TetraTests/TryMapTaskTests.swift b/Tests/TetraTests/TryMapTaskTests.swift index 1ac5d33..5058aae 100644 --- a/Tests/TetraTests/TryMapTaskTests.swift +++ b/Tests/TetraTests/TryMapTaskTests.swift @@ -39,13 +39,10 @@ final class TryMapTaskTests: XCTestCase { let target = try XCTUnwrap(input.randomElement()) let ref = UnsafeCancellableHolder() let expect = expectation(description: "task cancellation") - let lock = NSRecursiveLock() let pub = TryMapTask(upstream: input.publisher) { value in if value == target { await withUnsafeContinuation{ continuation in - lock.withLock { - ref.bag.removeAll() - } + ref.bag.removeAll() continuation.resume() } XCTAssertTrue(Task.isCancelled) @@ -56,13 +53,11 @@ final class TryMapTaskTests: XCTestCase { expect.fulfill() } ) - lock.withLock { - pub.sink { _ in - XCTFail() - } receiveValue: { - XCTAssertLessThanOrEqual($0, target) - }.store(in: &ref.bag) - } + pub.sink { _ in + XCTFail() + } receiveValue: { + XCTAssertLessThanOrEqual($0, target) + }.store(in: &ref.bag) wait(for: [expect], timeout: 0.5) } diff --git a/Tests/TetraTests/URLSessionDownloadTests.swift b/Tests/TetraTests/URLSessionDownloadTests.swift index a460ec3..b0dc082 100644 --- a/Tests/TetraTests/URLSessionDownloadTests.swift +++ b/Tests/TetraTests/URLSessionDownloadTests.swift @@ -7,12 +7,14 @@ import XCTest @testable import Tetra +import Namespace +@preconcurrency final class URLSessionDownloadTests: XCTestCase { private static let text = UUID().uuidString - private static var webserver:Result? = nil + nonisolated(unsafe) private static var webserver:Result? = nil private var url: URL { get throws { diff --git a/Tests/TetraTests/Utlitity/JsonSampleModel.swift b/Tests/TetraTests/Utlitity/JsonSampleModel.swift index 9d46951..bad2a26 100644 --- a/Tests/TetraTests/Utlitity/JsonSampleModel.swift +++ b/Tests/TetraTests/Utlitity/JsonSampleModel.swift @@ -12,7 +12,7 @@ struct JsonSample1Model: Codable, Hashable { var imaging:[LocationItem] var labs:[LocationItem] - + var medications:[Medical] struct LocationItem: Codable, Hashable { @@ -34,7 +34,7 @@ struct JsonSample1Model: Codable, Hashable { struct MetaData: Codable, Hashable { - var does:String + var dose:String var name:String var pillCount:String var refills:String diff --git a/Tests/TetraTests/Utlitity/SimpleHTTPServer.swift b/Tests/TetraTests/Utlitity/SimpleHTTPServer.swift index 19fe8e8..c086020 100644 --- a/Tests/TetraTests/Utlitity/SimpleHTTPServer.swift +++ b/Tests/TetraTests/Utlitity/SimpleHTTPServer.swift @@ -8,7 +8,7 @@ import Foundation import Network -class SimpleHTTPServer { +class SimpleHTTPServer: @unchecked Sendable { var port: NWEndpoint.Port? { listener.port @@ -16,12 +16,12 @@ class SimpleHTTPServer { let queue:DispatchQueue let listener: NWListener - let errorHandler: (any Error) -> Void + let errorHandler: @Sendable (any Error) -> Void var response:String = "Hello, World!" init( queue:DispatchQueue, port: NWEndpoint.Port, - errorHandle: @escaping (any Error) -> Void + errorHandle: @escaping @Sendable (any Error) -> Void ) throws { self.listener = try NWListener(using: .tcp, on: port) self.queue = queue diff --git a/Tests/TetraTests/Utlitity/UnsafeCancellableHolder.swift b/Tests/TetraTests/Utlitity/UnsafeCancellableHolder.swift index 79812c9..8c19023 100644 --- a/Tests/TetraTests/Utlitity/UnsafeCancellableHolder.swift +++ b/Tests/TetraTests/Utlitity/UnsafeCancellableHolder.swift @@ -6,7 +6,7 @@ // import Combine import Foundation - +@preconcurrency class UnsafeCancellableHolder { var bag = Set()