├── Sources ├── Relux │ ├── Models │ │ ├── Seconds.swift │ │ └── Relux+ExecutionType.swift │ ├── Relux │ │ ├── Relux+Dispatcher │ │ │ ├── Relux+Dispatcher+Global.swift │ │ │ ├── Relux+Dispatcher.swift │ │ │ └── Relux+Dispatcher+Interface.swift │ │ ├── Relux+Action │ │ │ ├── Relux.Effect.swift │ │ │ ├── Relux+Action.swift │ │ │ ├── Relux+ActionResultBuilder.swift │ │ │ ├── Relux+ActionResult+Reduced.swift │ │ │ └── Relux+ActionResult.swift │ │ ├── Relux+Module │ │ │ └── Relux+Module.swift │ │ ├── Relux+Saga │ │ │ ├── Relux+Flow.swift │ │ │ ├── Relux+RootSaga.swift │ │ │ └── Relux+Saga.swift │ │ ├── Relux+Store │ │ │ ├── Relux+State.swift │ │ │ └── Relux+Store.swift │ │ ├── Relux+Navigation │ │ │ └── Relux+Navigation.swift │ │ ├── Relux+Logger │ │ │ ├── Relux+Logger.swift │ │ │ └── Relux+Logger+EnumReflectible.swift │ │ └── Relux.swift │ └── Internal │ │ ├── Sequence+AsSet.swift │ │ ├── Utils │ │ ├── Optional+isNil.swift │ │ ├── TypeKeyable.swift │ │ ├── AsyncLock.swift │ │ ├── timestamp │ │ │ └── timestamp.swift │ │ └── AsyncUtils.swift │ │ └── Relux+Subscriber │ │ └── Relux+Subscriber.swift └── TestUtils │ ├── Relux+Testing.swift │ ├── Relux+Testing+MockModule+Saga.swift │ ├── Relux+Testing+MockModule+State.swift │ ├── Relux+Testing+MockModule.swift │ ├── Relux+Testing+Logger.swift │ └── Internal │ └── LockedState.swift ├── .gitignore ├── Package.swift ├── LICENSE ├── Tests ├── TimestampTests.swift └── LockedStateTests.swift └── README.md /Sources/Relux/Models/Seconds.swift: -------------------------------------------------------------------------------- 1 | public typealias Seconds = Double 2 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Dispatcher/Relux+Dispatcher+Global.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Sources/TestUtils/Relux+Testing.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public enum Testing {} 3 | } 4 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Action/Relux.Effect.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public protocol Effect: Relux.Action {} 3 | } 4 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Action/Relux+Action.swift: -------------------------------------------------------------------------------- 1 | public extension Relux { 2 | protocol Action: Sendable, Relux.EnumReflectable {} 3 | } 4 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Sequence+AsSet.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Sequence where Element: Equatable & Hashable { 3 | var asSet: Set { 4 | Set(self) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Relux/Models/Relux+ExecutionType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Relux { 4 | public enum ExecutionType: Sendable { 5 | case serially 6 | case concurrently 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Module/Relux+Module.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public protocol Module: Sendable { 3 | var states: [any Relux.AnyState] { get } 4 | var sagas: [any Relux.Saga] { get } 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Utils/Optional+isNil.swift: -------------------------------------------------------------------------------- 1 | extension Optional where Wrapped: Any { 2 | var isNil: Bool { 3 | return self == nil 4 | } 5 | var isNotNil: Bool { 6 | return isNil.not 7 | } 8 | } 9 | 10 | extension Bool { 11 | var not: Bool { 12 | return !self 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/TestUtils/Relux+Testing+MockModule+Saga.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Relux.Testing.MockModule { 3 | public actor Saga: Relux.Saga { 4 | public var effects: [Relux.Effect] = [] 5 | 6 | public func apply(_ effect: any Relux.Effect) async { 7 | effects.append(effect) 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Action/Relux+ActionResultBuilder.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | @resultBuilder 3 | public struct ActionResultBuilder { 4 | public static func buildBlock() -> [any Relux.Action] { [] } 5 | 6 | public static func buildBlock(_ actions: any Relux.Action...) -> [any Relux.Action] { 7 | actions 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Utils/TypeKeyable.swift: -------------------------------------------------------------------------------- 1 | 2 | public protocol TypeKeyable: AnyObject { 3 | typealias Key = ObjectIdentifier 4 | nonisolated var key: Key { get } 5 | nonisolated static var key: Key { get } 6 | } 7 | 8 | public extension TypeKeyable { 9 | nonisolated var key: ObjectIdentifier { .init(type(of: self)) } 10 | nonisolated static var key: ObjectIdentifier { .init(self) } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Saga/Relux+Flow.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public protocol Flow: Saga { 3 | typealias Result = ActionResult 4 | 5 | @discardableResult 6 | func apply(_ effect: any Relux.Effect) async -> Self.Result 7 | } 8 | } 9 | 10 | extension Relux.Flow { 11 | public func apply(_ effect: any Relux.Effect) async { 12 | let _: Relux.ActionResult = await self.apply(effect) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Relux+Subscriber/Relux+Subscriber.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | protocol Subscriber: AnyObject, Sendable { 3 | func perform(_ action: Relux.Action) async -> ActionResult? 4 | } 5 | } 6 | 7 | extension Relux { 8 | internal struct SubscriberRef: Sendable { 9 | private(set) weak var subscriber: (any Relux.Subscriber)? 10 | 11 | init(subscriber: (any Relux.Subscriber)?) { 12 | self.subscriber = subscriber 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## User Data 2 | 3 | *.xcuserdatad 4 | 5 | # notes 6 | notes 7 | 8 | ## OS X 9 | *.DS_Store 10 | .DS_Store 11 | 12 | # CocoaPods 13 | Pods/ 14 | Podfile.lock 15 | 16 | # Carthage 17 | Carthage/Build 18 | 19 | # fastlane 20 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 21 | 22 | .build 23 | .swiftpm 24 | .idea 25 | *.resolved 26 | *.xcworkspace 27 | *.framework 28 | *.xcframework 29 | .gitrelease 30 | *.pbxproj 31 | *.xcscheme 32 | *.entitlements 33 | *.xcodeproj/ 34 | gitrelease 35 | 36 | DerivedData/ -------------------------------------------------------------------------------- /Sources/TestUtils/Relux+Testing+MockModule+State.swift: -------------------------------------------------------------------------------- 1 | #if canImport(FoundationEssentials) 2 | @_exported import FoundationEssentials 3 | #else 4 | @_exported import Foundation 5 | #endif 6 | 7 | extension Relux.Testing.MockModule { 8 | public actor State: Relux.BusinessState { 9 | public var actions: [Relux.Action] = [] 10 | public var cleanupCalledAt: Date? 11 | 12 | public init() {} 13 | 14 | public func reduce(with action: any Relux.Action) async { 15 | self.actions.append(action) 16 | } 17 | 18 | public func cleanup() async { 19 | self.cleanupCalledAt = Date() 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Store/Relux+State.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public protocol AnyState: AnyObject, TypeKeyable, Sendable {} 3 | 4 | public protocol BusinessState: AnyState { 5 | func reduce(with action: any Relux.Action) async 6 | func cleanup() async 7 | } 8 | 9 | @MainActor 10 | public protocol UIState: AnyState {} 11 | 12 | @MainActor 13 | public protocol HybridState: BusinessState, UIState { 14 | func reduce(with action: any Relux.Action) async 15 | func cleanup() async 16 | } 17 | } 18 | 19 | extension Relux { 20 | struct StateRef: Sendable { 21 | weak private(set) var objectRef: (any Relux.HybridState)? 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/TestUtils/Relux+Testing+MockModule.swift: -------------------------------------------------------------------------------- 1 | extension Relux.Testing { 2 | public actor MockModule: Relux.Module { 3 | public let actionsLogger: State 4 | public let effectsLogger: Saga 5 | 6 | public let states: [any Relux.AnyState] 7 | public let sagas: [any Relux.Saga] 8 | 9 | public init() async { 10 | let actionsLogger = State() 11 | self.actionsLogger = actionsLogger 12 | self.states = [actionsLogger] 13 | 14 | let effectsLogger = Saga() 15 | self.effectsLogger = effectsLogger 16 | self.sagas = [effectsLogger] 17 | } 18 | } 19 | } 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "darwin-relux", 6 | platforms: [ 7 | .iOS(.v13), 8 | .macOS(.v10_15), 9 | .tvOS(.v13), 10 | .watchOS(.v6) 11 | ], 12 | products: [ 13 | .library( 14 | name: "Relux", 15 | targets: ["Relux"] 16 | ), 17 | ], 18 | dependencies: [ 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Relux", 23 | dependencies: [], 24 | path: "Sources" 25 | ), 26 | .testTarget( 27 | name: "ReluxTests", 28 | dependencies: ["Relux"], 29 | path: "Tests" 30 | ), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Navigation/Relux+Navigation.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Relux { 3 | public enum Navigation {} 4 | } 5 | 6 | extension Relux.Navigation { 7 | public protocol RouterProtocol: Relux.HybridState { } 8 | public protocol PathComponent: Equatable, Hashable, Sendable {} 9 | public protocol PathCodableComponent: PathComponent, Codable {} 10 | 11 | public protocol ModalComponent: PathComponent, Identifiable {} 12 | public protocol ModalCodableComponent: ModalComponent, Codable, Identifiable {} 13 | } 14 | 15 | public extension Relux.Navigation.ModalComponent { 16 | var id: Int { self.hashValue } 17 | } 18 | 19 | 20 | public extension Relux.Navigation.ModalCodableComponent { 21 | var id: Int { self.hashValue } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Logger/Relux+Logger.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public protocol Logger: Sendable { 3 | func logAction( 4 | _ action: Relux.EnumReflectable, 5 | result: Relux.ActionResult?, 6 | startTimeInMillis: Int, 7 | privacy: Relux.OSLogPrivacy, 8 | fileID: String, 9 | functionName: String, 10 | lineNumber: Int 11 | ) 12 | } 13 | } 14 | 15 | extension Relux { 16 | /// A proxy type to work around apple os log [limitations](https://stackoverflow.com/questions/62675874/xcode-12-and-oslog-os-log-wrapping-oslogmessage-causes-compile-error-argumen#63036815). 17 | public enum OSLogPrivacy: Equatable { 18 | case auto, `public`, `private`, sensitive 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/TestUtils/Relux+Testing+Logger.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Relux.Testing { 3 | public final class Logger: Relux.Logger, @unchecked Sendable { 4 | private let lock = LockedState() 5 | 6 | nonisolated(unsafe) 7 | public private(set) var actions: [Relux.Action] = [] 8 | 9 | nonisolated(unsafe) 10 | public private(set) var effects: [Relux.Effect] = [] 11 | 12 | public init() {} 13 | 14 | public func logAction( 15 | _ action: Relux.EnumReflectable, 16 | result: Relux.ActionResult?, 17 | startTimeInMillis: Int, 18 | privacy: Relux.OSLogPrivacy, 19 | fileID: String, 20 | functionName: String, 21 | lineNumber: Int 22 | ) { 23 | lock.withLock { 24 | switch action { 25 | case let effect as Relux.Effect: effects.append(effect) 26 | case let action as Relux.Action: actions.append(action) 27 | default: break 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Alexey Grigorev, Ivan Oparin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Action/Relux+ActionResult+Reduced.swift: -------------------------------------------------------------------------------- 1 | extension Sequence where Element == Relux.ActionResult { 2 | var fails: [Relux.ActionResult.ErrPayload] { 3 | self.compactMap { $0.errPayload } 4 | } 5 | 6 | var successes: [Relux.ActionResult.Payload] { 7 | self.compactMap { $0.payload } 8 | } 9 | 10 | var reducedResult: Relux.ActionResult { 11 | switch self.fails.isEmpty { 12 | case true: .success(payload: successes.merge) 13 | case false: .failure(payload: fails.merge) 14 | } 15 | } 16 | } 17 | 18 | extension Sequence where Element == Relux.ActionResult.Payload { 19 | var merge: Relux.ActionResult.Payload { 20 | self.reduce(into: .init()) { result, payload in 21 | result = .init( 22 | data: result.data.merging(payload.data, uniquingKeysWith: {_, new in new }) 23 | ) 24 | } 25 | } 26 | } 27 | 28 | extension Sequence where Element == Relux.ActionResult.ErrPayload { 29 | var merge: Relux.ActionResult.ErrPayload { 30 | self.reduce(into: .init()) { result, payload in 31 | result = .init( 32 | data: result.data.merging(payload.data, uniquingKeysWith: {_, new in new }) 33 | ) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Saga/Relux+RootSaga.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | @MainActor 3 | public final class RootSaga { 4 | private var sagas: [TypeKeyable.Key: any Relux.Saga] = [:] 5 | 6 | public init() { 7 | } 8 | } 9 | } 10 | 11 | extension Relux.RootSaga: Relux.Subscriber { 12 | internal func perform(_ action: any Relux.Action) async -> Relux.ActionResult? { 13 | guard let effect = action as? Relux.Effect else { 14 | return .none 15 | } 16 | 17 | return await notifyFlows(with: effect) 18 | } 19 | 20 | private func notifyFlows(with effect: Relux.Effect) async -> Relux.ActionResult? { 21 | await sagas 22 | .concurrentMap { 23 | switch $0.value { 24 | case let flow as Relux.Flow: await flow.apply(effect) 25 | default: await $0.value.apply(effect) 26 | } 27 | } 28 | .reducedResult 29 | } 30 | } 31 | 32 | // sagas management 33 | extension Relux.RootSaga { 34 | public func connectSaga(saga: any Relux.Saga) { 35 | guard sagas[saga.key].isNil else { 36 | fatalError("failed to add saga, already exists: \(saga)") 37 | } 38 | sagas[saga.key] = saga 39 | } 40 | 41 | public func disconnect(saga: any Relux.Saga) { 42 | sagas.removeValue(forKey: saga.key) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/TimestampTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import Relux 3 | 4 | #if canImport(FoundationEssentials) 5 | import FoundationEssentials 6 | #else 7 | import Foundation 8 | #endif 9 | 10 | @Suite("timestamp invariants") 11 | struct TimestampTests { 12 | 13 | @Test("seconds is close to wall-clock now") 14 | func secondsCloseToNow() { 15 | let nowSec = Int(Date().timeIntervalSince1970) 16 | let s = timestamp.seconds 17 | // Allow a few seconds of skew so CI/virtualized clocks don't flake. 18 | #expect(abs(s - nowSec) <= 5) 19 | } 20 | 21 | @Test("milliseconds is consistent with seconds") 22 | func millisecondsConsistent() { 23 | let s = timestamp.seconds 24 | let ms = timestamp.milliseconds 25 | 26 | // ms should be within the current or next second in ms 27 | #expect(ms >= s * 1_000) 28 | #expect(ms <= (s + 1) * 1_000) 29 | } 30 | 31 | @Test("microseconds is consistent with milliseconds (round-half-up)") 32 | func microsecondsConsistent() { 33 | let ms = timestamp.milliseconds 34 | let us = timestamp.microseconds 35 | 36 | // 1 ms = 1000 µs; rounding differences should keep them within < 1000 µs. 37 | #expect(abs(us - (ms * 1_000)) < 1_000) 38 | } 39 | 40 | @Test("nondecreasing within a short window (tolerant)") 41 | func nondecreasingTolerant() async throws { 42 | // Real-time clocks can jump backwards slightly; we only assert a very tolerant bound. 43 | let s1 = timestamp.seconds 44 | try await Task.sleep(nanoseconds: 50_000_000) // ~50ms 45 | let s2 = timestamp.seconds 46 | #expect(s2 >= s1 || (s1 - s2) <= 1) // allow tiny backward step 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Utils/AsyncLock.swift: -------------------------------------------------------------------------------- 1 | /// An async lock implemented using Swift Actor. 2 | /// 3 | /// This lock allows you to create critical sections in your asynchronous code. 4 | @usableFromInline 5 | internal actor AsyncLock: Sendable { 6 | /// Indicates whether the lock is currently held. 7 | private var isLocked = false 8 | 9 | /// The queue of tasks waiting to acquire the lock. 10 | private var waitingTasks: [CheckedContinuation] = [] 11 | 12 | /// Acquires the lock, suspending the current task until the lock becomes available. 13 | func lock() async { 14 | if isLocked { 15 | await withCheckedContinuation { continuation in 16 | waitingTasks.append(continuation) 17 | } 18 | } else { 19 | isLocked = true 20 | } 21 | } 22 | 23 | /// Releases the lock, allowing other tasks to acquire it. 24 | func unlock() { 25 | if waitingTasks.isEmpty { 26 | isLocked = false 27 | } else { 28 | let continuation = waitingTasks.removeFirst() 29 | continuation.resume() 30 | } 31 | } 32 | 33 | internal init() {} 34 | 35 | /// Executes a given closure while holding the lock. 36 | /// 37 | /// This method ensures that the lock is released when the closure completes, even if the closure throws an error. 38 | /// 39 | /// - Parameter work: A closure to execute while holding the lock. 40 | /// - Returns: The result of the closure. 41 | internal func withLock(_ work: () async throws -> T) async rethrows -> T { 42 | await lock() 43 | defer { unlock() } 44 | return try await work() 45 | } 46 | 47 | internal func withLock(_ work: () async -> T) async -> T { 48 | await lock() 49 | defer { unlock() } 50 | return await work() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Saga/Relux+Saga.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public protocol Saga: Actor, TypeKeyable{ 3 | var dispatcher: Relux.Dispatcher { get async } 4 | func apply(_ effect: any Relux.Effect) async 5 | } 6 | } 7 | 8 | extension Relux.Saga { 9 | internal func apply(_ effect: any Relux.Effect) async -> Relux.ActionResult { 10 | await self.apply(effect) 11 | return .success 12 | } 13 | } 14 | 15 | extension Relux.Saga { 16 | public var dispatcher: Relux.Dispatcher { 17 | get async { await Relux.shared.dispatcher } 18 | } 19 | 20 | public static var defaultDispatcher: Relux.Dispatcher { 21 | get async { await Relux.shared.dispatcher } 22 | } 23 | 24 | @inlinable 25 | @discardableResult 26 | public func action( 27 | delay: Seconds? = nil, 28 | fileID: String = #fileID, 29 | functionName: String = #function, 30 | lineNumber: Int = #line, 31 | action: @Sendable () -> Relux.Action, 32 | label: (@Sendable () -> String)? = nil 33 | ) async -> Relux.ActionResult { 34 | await self.dispatcher._actions( 35 | .serially, 36 | delay: delay, 37 | fileID: fileID, 38 | functionName: functionName, 39 | lineNumber: lineNumber, 40 | actions: { action() }, 41 | label: label 42 | ) 43 | } 44 | 45 | @inlinable 46 | @discardableResult 47 | public func actions( 48 | _ executionType: Relux.ExecutionType = .serially, 49 | delay: Seconds? = nil, 50 | fileID: String = #fileID, 51 | functionName: String = #function, 52 | lineNumber: Int = #line, 53 | @Relux.ActionResultBuilder actions: @Sendable () -> [Relux.Action], 54 | label: (@Sendable () -> String)? = nil 55 | ) async -> Relux.ActionResult { 56 | await self.dispatcher._actions( 57 | executionType, 58 | delay: delay, 59 | fileID: fileID, 60 | functionName: functionName, 61 | lineNumber: lineNumber, 62 | actions: actions, 63 | label: label 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux.swift: -------------------------------------------------------------------------------- 1 | @MainActor 2 | public final class Relux: Sendable { 3 | public let store: Store 4 | public let rootSaga: RootSaga 5 | public let dispatcher: Dispatcher 6 | 7 | public static var shared: Relux! 8 | 9 | public init( 10 | logger: (any Relux.Logger), 11 | appStore: Store = .init(), 12 | rootSaga: RootSaga = .init() 13 | ) async { 14 | self.store = appStore 15 | self.rootSaga = rootSaga 16 | self.dispatcher = .init( 17 | subscribers: [appStore, rootSaga], 18 | logger: logger 19 | ) 20 | 21 | guard Self.shared.isNil 22 | else { fatalError("only one instance of Relux is allowed") } 23 | Self.shared = self 24 | } 25 | } 26 | 27 | // register 28 | extension Relux { 29 | @discardableResult 30 | public func register(_ module: Module) -> Relux { 31 | module 32 | .states 33 | .forEach { self.store.connect(state: $0) } 34 | 35 | module 36 | .sagas 37 | .forEach { self.rootSaga.connectSaga(saga: $0) } 38 | 39 | return self 40 | } 41 | 42 | @discardableResult 43 | public func register(@Relux.ModuleResultBuilder _ modules: @Sendable () async -> [Relux.Module]) async -> Relux { 44 | await modules() 45 | .forEach { register($0) } 46 | 47 | return self 48 | } 49 | } 50 | 51 | // unregister 52 | extension Relux { 53 | @discardableResult 54 | public func unregister(_ module: Module) async -> Relux { 55 | await module 56 | .states 57 | .asyncForEach { 58 | await self.store.disconnect(state: $0) 59 | } 60 | 61 | module 62 | .sagas 63 | .forEach { 64 | self.rootSaga.disconnect(saga: $0) 65 | } 66 | 67 | return self 68 | } 69 | 70 | } 71 | 72 | // modules builder 73 | extension Relux { 74 | @resultBuilder 75 | public struct ModuleResultBuilder { 76 | public static func buildBlock() -> [any Module] { [] } 77 | 78 | public static func buildBlock(_ modules: any Module...) -> [any Module] { 79 | modules 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Logger/Relux+Logger+EnumReflectible.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | /// Provides caseName and associatedValues custom mirrors for enumerations. 3 | public protocol EnumReflectable: CaseNameReflectable, AssociatedValuesReflectable, Sendable { 4 | var subsystem: String { get } 5 | var category: String { get } 6 | } 7 | } 8 | 9 | extension Relux.EnumReflectable { 10 | public var subsystem: String { "Relux" } 11 | public var category: String { "Logger" } 12 | } 13 | 14 | extension Relux { 15 | // reflecting enum cases 16 | public protocol CaseNameReflectable { 17 | var caseName: String { get } 18 | } 19 | } 20 | 21 | extension Relux.CaseNameReflectable { 22 | public var caseName: String { 23 | let mirror = Mirror(reflecting: self) 24 | guard let caseName = mirror.children.first?.label else { 25 | return "\(mirror.subjectType).\(self)" 26 | } 27 | return "\(mirror.subjectType).\(caseName)" 28 | } 29 | } 30 | 31 | // reflecting enum associated values 32 | extension Relux { 33 | public protocol AssociatedValuesReflectable { 34 | var associatedValues: [String] { get } 35 | } 36 | } 37 | 38 | extension Relux.AssociatedValuesReflectable { 39 | public var associatedValues: [String] { 40 | var values = [String]() 41 | guard let associated = Mirror(reflecting: self).children.first else { 42 | return values 43 | } 44 | 45 | let valueMirror = Mirror(reflecting: associated.value) 46 | switch valueMirror.displayStyle { 47 | case .tuple: 48 | // Handle tuples (multiple parameters) with labels (e.g., ".0" → "0") 49 | for child in valueMirror.children { 50 | let label = child.label?.replacingOccurrences( 51 | of: "^\\.", 52 | with: "", 53 | options: .regularExpression 54 | ) 55 | let value = String(describing: child.value) 56 | values.append(label != nil ? "\(label!): \(value)" : value) 57 | } 58 | default: 59 | // For single values, append the value WITHOUT any label 60 | let value = String(describing: associated.value) 61 | values.append(value) 62 | } 63 | return values 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Action/Relux+ActionResult.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public enum ActionResult: Sendable { 3 | case success(payload: Payload = .init()) 4 | case failure(payload: ErrPayload = .init()) 5 | } 6 | } 7 | 8 | extension Relux.ActionResult { 9 | public static func success(_ data: Value, from function: String = #function) -> Self { 10 | success(data, for: function) 11 | } 12 | 13 | public static func success(_ data: Value, for key: AnyHashable) -> Self { 14 | .success(payload: .init(data: [key: data])) 15 | } 16 | 17 | public static func failure(_ error: Error, from function: String = #function) -> Self { 18 | failure(error, for: function) 19 | } 20 | 21 | public static func failure(_ error: Error, for key: AnyHashable) -> Self { 22 | .failure( 23 | payload: .init(data: [key: error]) 24 | ) 25 | } 26 | } 27 | 28 | extension Relux.ActionResult { 29 | public struct ErrPayload: @unchecked Sendable { 30 | public let data: [AnyHashable: Error] 31 | public var errors: [Error] { data.lazy.elements.map { $0.value } } 32 | public init( 33 | data: [AnyHashable: Error] = [:] 34 | ) { 35 | self.data = data 36 | } 37 | } 38 | 39 | public struct Payload: @unchecked Sendable { 40 | public let data: [AnyHashable: Sendable] 41 | public init( 42 | data: [AnyHashable: Sendable] = [:] 43 | ) { 44 | self.data = data 45 | } 46 | 47 | public func data(for key: AnyHashable) -> Data? { 48 | switch self.data[key] { 49 | case let .some(val): val as? Data 50 | case .none: .none 51 | } 52 | } 53 | } 54 | } 55 | 56 | extension Relux.ActionResult { 57 | public static let success: Self = .success() 58 | public static let failure: Self = .failure() 59 | } 60 | 61 | extension Relux.ActionResult { 62 | var errPayload: ErrPayload? { 63 | switch self { 64 | case .success: .none 65 | case let .failure(payload): payload 66 | } 67 | } 68 | 69 | var payload: Payload? { 70 | switch self { 71 | case let .success(payload): payload 72 | case .failure: .none 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Utils/timestamp/timestamp.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Darwin) 2 | import Darwin 3 | #elseif os(Linux) 4 | @preconcurrency import Glibc 5 | #elseif canImport(Bionic) 6 | @preconcurrency import Bionic 7 | #elseif canImport(Musl) 8 | @preconcurrency import Musl 9 | #elseif canImport(WinSDK) 10 | import WinSDK 11 | #elseif os(Windows) 12 | import ucrt 13 | #else 14 | #error("Unsupported platform") 15 | #endif 16 | 17 | @usableFromInline 18 | struct timestamp { 19 | 20 | @usableFromInline 21 | static var seconds: Int { 22 | let ts = _nowTimespec() 23 | return Int(ts.tv_sec) 24 | } 25 | 26 | @usableFromInline 27 | static var milliseconds: Int { 28 | let ts = _nowTimespec() 29 | var millis = Int(ts.tv_sec) * 1_000 30 | let nsec = Int(ts.tv_nsec) 31 | millis += nsec / 1_000_000 32 | // round half up 33 | if (nsec % 1_000_000) >= 500_000 { 34 | millis += 1 35 | } 36 | return millis 37 | } 38 | 39 | @usableFromInline 40 | static var microseconds: Int { 41 | let ts = _nowTimespec() 42 | var micros = Int(ts.tv_sec) * 1_000_000 43 | let nsec = Int(ts.tv_nsec) 44 | micros += nsec / 1_000 45 | // round half up 46 | if (nsec % 1_000) >= 500 { 47 | micros += 1 48 | } 49 | return micros 50 | } 51 | } 52 | 53 | 54 | @inline(__always) 55 | private func _nowTimespec() -> timespec { 56 | #if canImport(WinSDK) 57 | // Use FILETIME (100ns ticks since 1601-01-01) → Unix epoch 58 | var ft = FILETIME() 59 | GetSystemTimePreciseAsFileTime(&ft) 60 | let hi = UInt64(ft.dwHighDateTime) 61 | let lo = UInt64(ft.dwLowDateTime) 62 | // 100ns units 63 | let ticks100ns = (hi << 32) | lo 64 | // Convert to nanoseconds 65 | let nanosTotal = ticks100ns * 100 66 | // Difference between 1601-01-01 and 1970-01-01 in seconds 67 | let secBetweenEpochs: UInt64 = 11_644_473_600 68 | let sec = nanosTotal / 1_000_000_000 69 | let nsec = nanosTotal % 1_000_000_000 70 | var ts = timespec() 71 | // Guard underflow if clock is weird (shouldn’t happen) 72 | if sec >= secBetweenEpochs { 73 | ts.tv_sec = Int(sec - secBetweenEpochs) 74 | ts.tv_nsec = Int(nsec) 75 | } else { 76 | ts.tv_sec = 0 77 | ts.tv_nsec = 0 78 | } 79 | return ts 80 | #else 81 | var ts = timespec() 82 | // `clock_gettime` is available on Darwin, glibc, musl, bionic 83 | _ = clock_gettime(CLOCK_REALTIME, &ts) 84 | return ts 85 | #endif 86 | } 87 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Dispatcher/Relux+Dispatcher.swift: -------------------------------------------------------------------------------- 1 | extension Relux { 2 | public actor Dispatcher { 3 | private var subscribers: [Relux.SubscriberRef] = [] 4 | private var logger: any Relux.Logger 5 | 6 | internal init( 7 | subscribers: [Relux.Subscriber], 8 | logger: any Relux.Logger 9 | ) { 10 | self.subscribers = subscribers.map {.init(subscriber: $0) } 11 | self.logger = logger 12 | } 13 | 14 | public init( 15 | logger: any Relux.Logger 16 | ) { 17 | self.init(subscribers: [], logger: logger) 18 | } 19 | 20 | @inline(__always) 21 | internal func sequentialPerform( 22 | _ actions: [Relux.Action], 23 | delay: Seconds?, 24 | fileID: String, 25 | functionName: String, 26 | lineNumber: Int, 27 | label: (@Sendable () -> String)? = nil 28 | ) async -> Relux.ActionResult { 29 | let execStartTime = timestamp.milliseconds 30 | 31 | if let delay { 32 | let delay = UInt64(delay * 1_000_000_000) 33 | try? await Task.sleep(nanoseconds: delay) 34 | } 35 | 36 | return await actions 37 | .asyncFlatMap { action in 38 | let results = await self.subscribers 39 | .lazy 40 | .compactMap { $0.subscriber } 41 | .concurrentCompactMap { await $0.perform(action) } 42 | 43 | await self.logger.logAction( 44 | action, result: results.reducedResult, startTimeInMillis: execStartTime, privacy: .private, fileID: fileID, functionName: functionName, lineNumber: lineNumber) 45 | 46 | return results 47 | } 48 | .reducedResult 49 | } 50 | 51 | 52 | @inline(__always) 53 | internal func concurrentPerform( 54 | _ actions: [Relux.Action], 55 | delay: Seconds?, 56 | fileID: String, 57 | functionName: String, 58 | lineNumber: Int, 59 | label: (@Sendable () -> String)? = nil 60 | ) async -> Relux.ActionResult { 61 | let execStartTime = timestamp.milliseconds 62 | 63 | if let delay { 64 | let delay = UInt64(delay * 1_000_000_000) 65 | try? await Task.sleep(nanoseconds: delay) 66 | } 67 | 68 | return await actions 69 | .concurrentFlatMap { action in 70 | let results = await self.subscribers 71 | .lazy 72 | .compactMap { $0.subscriber } 73 | .concurrentCompactMap { 74 | await $0.perform(action) 75 | } 76 | 77 | await self.logger.logAction( 78 | action, result: results.reducedResult, startTimeInMillis: execStartTime, privacy: .private, fileID: fileID, functionName: functionName, lineNumber: lineNumber) 79 | 80 | return results 81 | } 82 | .reducedResult 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Store/Relux+Store.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Relux { 4 | @MainActor 5 | public final class Store: Sendable { 6 | internal private(set) var businessStates: [TypeKeyable.Key: any Relux.BusinessState] = [:] 7 | internal private(set) var tempStates: [TypeKeyable.Key: StateRef] = [:] 8 | 9 | public private(set) var uiStates: [TypeKeyable.Key: any Relux.UIState] = [:] 10 | 11 | public init() { 12 | } 13 | } 14 | } 15 | 16 | // actions propagation 17 | extension Relux.Store { 18 | internal func notify(_ action: Relux.Action) async { 19 | async let notifyBusiness: () = businessStates 20 | .concurrentForEach { pair in 21 | await pair.value.reduce(with: action) 22 | } 23 | 24 | async let notifyTemporals: () = tempStates 25 | .concurrentForEach { pair in 26 | await pair.value.objectRef?.reduce(with: action) 27 | } 28 | 29 | _ = await (notifyBusiness, notifyTemporals) 30 | } 31 | } 32 | 33 | extension Relux.Store: Relux.Subscriber { 34 | internal func perform(_ action: Relux.Action) async -> Relux.ActionResult? { 35 | await self.notify(action) 36 | return .none 37 | } 38 | } 39 | 40 | // getters 41 | extension Relux.Store { 42 | @MainActor 43 | public func getState(_ type: T.Type) -> T { 44 | let state = businessStates[T.key] 45 | return state as! T 46 | } 47 | 48 | @MainActor 49 | public func getState(_ type: T.Type) -> T { 50 | let state = uiStates[T.key] 51 | return state as! T 52 | } 53 | 54 | @MainActor 55 | public func getState(_ type: T.Type) -> T { 56 | let state = businessStates[T.key] 57 | return state as! T 58 | } 59 | } 60 | 61 | // connectors 62 | extension Relux.Store { 63 | public func connect(state: some Relux.AnyState) { 64 | var typeDefined = false 65 | if let state = state as? Relux.BusinessState { 66 | connect(state: state) 67 | typeDefined = true 68 | } 69 | if let state = state as? Relux.UIState { 70 | connect(state: state) 71 | typeDefined = true 72 | } 73 | 74 | guard typeDefined else { 75 | fatalError("unsupported state type: \(type(of: state))") 76 | } 77 | } 78 | 79 | public func connectTemporally(state: TS) -> TS { 80 | tempStates[state.key] = .init(objectRef: state) 81 | return state 82 | } 83 | 84 | internal func connect(state: some Relux.BusinessState) { 85 | guard businessStates[state.key].isNil else { 86 | fatalError("failed to add state, already exists: \(state)") 87 | } 88 | businessStates[state.key] = state 89 | } 90 | 91 | internal func connect(state: some Relux.UIState) { 92 | guard uiStates[state.key].isNil else { 93 | fatalError("failed to add state, already exists: \(state)") 94 | } 95 | uiStates[state.key] = state 96 | } 97 | } 98 | 99 | // disconnect state 100 | extension Relux.Store { 101 | public func disconnect(state: some Relux.AnyState) async { 102 | guard 103 | let businessState = state as? Relux.BusinessState, 104 | let state = businessStates[businessState.key] 105 | else { return } 106 | 107 | await state.cleanup() 108 | businessStates.removeValue(forKey: state.key) 109 | } 110 | } 111 | 112 | // cleanup 113 | extension Relux.Store { 114 | public func cleanup( 115 | exclusions: [Relux.BusinessState.Type] = [] 116 | ) async { 117 | let excludedKeys = exclusions 118 | .map { $0.key } 119 | .asSet 120 | 121 | await businessStates 122 | .concurrentForEach { 123 | guard excludedKeys.contains($0.key).not else { return } 124 | await $0.value.cleanup() 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Relux 2 | 3 | [![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20Linux%20%7C%20Android-blue)](#) 4 | [![Swift](https://img.shields.io/badge/Swift-6.2%20%7C%206.1%20%7C%206.0%20%7C%205.10-orange)](#) 5 | ![License](https://img.shields.io/badge/License-MIT-blue.svg) 6 | ![SPM](https://img.shields.io/badge/SPM-compatible-brightgreen.svg) 7 | 8 |
9 |

Relux /rēˈlʌks/ n.

10 |
    11 |
  1. 12 | Architecture pattern: Redux's Swift-y cousin who went to actor school and came back async. 13 |
  2. 14 |
  3. 15 | Framework: Your app's state management therapist - keeps data flowing in one direction, prevents race conditions, and plays nice with SwiftUI. 16 |
  4. 17 |
18 |

19 | Etymology: Redux + Relax = Just let the actors handle it™ 20 |

21 |
22 | 23 | ## Overview 24 | 25 | Relux is a Swift package that re-imagines the popular Redux pattern using Swift concurrency. The library embraces the unidirectional data flow (UDF) style while taking advantage of actors and structured concurrency to keep state management safe and predictable. 26 | 27 | It can be gradually adopted in existing projects, works seamlessly with SwiftUI, and scales from simple applications to complex modular architectures. 28 | 29 | 30 | ## Core Concepts 31 | 32 | ### Understanding UDF 33 | 34 | UDF stands for *Unidirectional Data Flow*. All changes in the application are triggered by **actions** that are dispatched through a single channel. Each action updates the application module's state, and views observe that state. This one-way flow of information keeps behavior easy to reason about. 35 | 36 | ### Why Relux? 37 | 38 | Relux follows the same principles as Redux but introduces several features tailored for Swift on Apple platforms: 39 | 40 | - **Actor-based state and sagas** – every `BusinessState` and `Saga` is an actor. This ensures updates run without data races and enables usage from async contexts. 41 | - **Serial or concurrent dispatch** – actions can be executed sequentially or concurrently using built-in helpers. 42 | - **Modular registration** – a `Module` groups states and sagas and can be registered or removed at runtime, enabling progressive adoption. 43 | - **Effects and flows** – asynchronous work is modeled as `Effect` objects handled by `Saga` or `Flow` actors, separating side effects from pure actions. 44 | - **Enum reflection for logging** – the optional logging interface introspects action enums to print meaningful messages for all effects and actions without manual boilerplate. 45 | - **Reducer inside state** – reducers are instance methods that mutate the state's properties directly. This avoids constant state recreation and keeps logic close to the data it updates. 46 | 47 | ### State Types 48 | 49 | Relux provides three state types, each designed for specific use cases: 50 | 51 | **HybridState** – Start here! Combines business logic and UI reactivity in one place. Runs on the main actor, perfect for SwiftUI views. Use this until you need more complexity. 52 | 53 | **BusinessState + UIState** – When your app grows, split concerns: 54 | - `BusinessState`: Actor-based, holds your core data and business logic. Not directly observable by SwiftUI. 55 | - `UIState`: Observable wrapper that subscribes to BusinessState changes and transforms data for the UI. 56 | 57 | **When to use what:** 58 | - Simple features -> `HybridState` 59 | - Complex features with shared data -> `BusinessState` + `UIState` 60 | - Need to aggregate and map data from multiple domains -> multiple `BusinessState`'s' + `UIState` instance to subscribe and aggregate 61 | 62 | Think of it like cooking: HybridState is your all-in-one pressure cooker, while BusinessState + UIState is your professional kitchen with separate prep and plating stations. 63 | 64 | ### Modules and Sagas 65 | 66 | Relux encourages dividing your codebase into feature modules. A `Module` bundles states, sagas or flows, and supporting services. Sagas orchestrate effects such as network requests, while services encapsulate integrations with APIs, databases, sensors etc. Modules can be registered at runtime and expose states ready for consumption by the UI or by other modules. 67 | 68 | ## Documentation 69 | 70 | - [Sample App](https://github.com/ivalx1s/relux-sample) - Full-featured example application 71 | - [API Reference](https://github.com/ivalx1s/darwin-relux/wiki) - Coming soon 72 | - [Architecture Guide](https://github.com/ivalx1s/darwin-relux/wiki) - Coming soon 73 | 74 | ## Requirements 75 | 76 | - Swift 5.10+ 77 | - iOS 13.0+, macOS 10.15+, tvOS 13.0+, watchOS 6.0+ 78 | 79 | ## Diagram 80 | 81 | redux-architecture 82 | 83 | 84 | ## License 85 | 86 | Relux is released under the [MIT License](LICENSE). 87 | -------------------------------------------------------------------------------- /Tests/LockedStateTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 10 | // 11 | //===----------------------------------------------------------------------===// 12 | 13 | import Testing 14 | 15 | @testable import Relux 16 | 17 | @Suite("LockedState") 18 | private struct LockedStateTests { 19 | final class TestObject { 20 | var deinitBlock: () -> Void = {} 21 | 22 | deinit { 23 | deinitBlock() 24 | } 25 | } 26 | 27 | struct TestError: Error {} 28 | 29 | @Test func withLockDoesNotExtendLifetimeOfState() { 30 | weak var state: TestObject? 31 | let lockedState: LockedState 32 | 33 | (state, lockedState) = { 34 | let state = TestObject() 35 | return (state, LockedState(initialState: state)) 36 | }() 37 | 38 | lockedState.withLock { state in 39 | weak var oldState = state 40 | state = TestObject() 41 | #expect(oldState == nil, "State object lifetime was extended after reassignment within body") 42 | } 43 | 44 | #expect(state == nil, "State object lifetime was extended beyond end of call") 45 | } 46 | 47 | @Test func withLockExtendingLifetimeExtendsLifetimeOfStatePastReassignment() { 48 | let lockedState = LockedState(initialState: TestObject()) 49 | 50 | lockedState.withLockExtendingLifetimeOfState { state in 51 | weak var oldState = state 52 | state = TestObject() 53 | #expect(oldState != nil, "State object lifetime was not extended after reassignment within body") 54 | } 55 | } 56 | 57 | @Test func withLockExtendingLifetimeExtendsLifetimeOfStatePastEndOfLockedScope() { 58 | let lockedState: LockedState = { 59 | let state = TestObject() 60 | let lockedState = LockedState(initialState: state) 61 | 62 | // `withLockExtendingLifetimeOfState()` should extend the lifetime of the state until after the lock is 63 | // released. By asserting that the lock is not held when the state object is deinit-ed, we can confirm 64 | // that the lifetime was extended past the end of the locked scope. 65 | state.deinitBlock = { 66 | assertLockNotHeld(lockedState, "State object lifetime was not extended to end of locked scope") 67 | } 68 | 69 | return lockedState 70 | }() 71 | 72 | lockedState.withLockExtendingLifetimeOfState { state in 73 | state = TestObject() 74 | } 75 | } 76 | 77 | @Test func withLockExtendingLifetimeDoesNotExtendLifetimeOfStatePastEndOfCall() { 78 | weak var state: TestObject? 79 | let lockedState: LockedState 80 | 81 | (state, lockedState) = { 82 | let state = TestObject() 83 | return (state, LockedState(initialState: state)) 84 | }() 85 | 86 | lockedState.withLockExtendingLifetimeOfState { state in 87 | state = TestObject() 88 | } 89 | 90 | #expect(state == nil, "State object lifetime was extended beyond end of call") 91 | } 92 | 93 | @Test func withLockExtendingLifetimeReleasesLockWhenBodyThrows() { 94 | let lockedState = LockedState(initialState: TestObject()) 95 | 96 | #expect(throws: TestError.self, "The body was expected to throw an error, but it did not.") { 97 | try lockedState.withLockExtendingLifetimeOfState { _ in 98 | throw TestError() 99 | } 100 | } 101 | 102 | assertLockNotHeld(lockedState, "Lock was not properly released by withLockExtendingLifetimeOfState()") 103 | } 104 | 105 | @Test func withLockExtendingLifespanDoesExtendLifetimeOfState() { 106 | weak var state: TestObject? 107 | let lockedState: LockedState 108 | 109 | (state, lockedState) = { 110 | let state = TestObject() 111 | return (state, LockedState(initialState: state)) 112 | }() 113 | 114 | lockedState.withLockExtendingLifetimeOfState { state in 115 | weak var oldState = state 116 | state = TestObject() 117 | #expect(oldState != nil, "State object lifetime was not extended after reassignment within body") 118 | } 119 | 120 | #expect(state == nil, "State object lifetime was extended beyond end of call") 121 | } 122 | } 123 | 124 | /// Assert that the locked state is not currently locked. 125 | /// 126 | /// ⚠️ This assertion fails by crashing. If the lock is currently held, the `withLock()` call will abort the program. 127 | private func assertLockNotHeld(_ lockedState: LockedState, _ message: @autoclosure () -> String) { 128 | // Note: Since the assertion fails by crashing, `message` is never logged. 129 | lockedState.withLock { _ in 130 | // PASS 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/Relux/Internal/Utils/AsyncUtils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // John Sundell (c) 4 | // https://github.com/JohnSundell/CollectionConcurrencyKit/blob/main/Sources/CollectionConcurrencyKit.swift 5 | 6 | extension Sequence where Element: Sendable { 7 | 8 | @inlinable @inline(__always) 9 | func asyncForEach( 10 | _ operation: @escaping @Sendable (Element) async throws -> Void 11 | ) async rethrows { 12 | for element in self { 13 | try await operation(element) 14 | } 15 | } 16 | 17 | @inlinable @inline(__always) 18 | func concurrentForEach( 19 | withPriority priority: TaskPriority? = nil, 20 | _ operation: @escaping @Sendable (Element) async -> Void 21 | ) async { 22 | await withTaskGroup(of: Void.self) { group in 23 | for element in self { 24 | group.addTask(priority: priority) { 25 | await operation(element) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | extension Sequence where Element: Sendable { 33 | @inline(__always) 34 | func asyncMap( 35 | _ transform: @escaping @Sendable (Element) async throws -> T 36 | ) async rethrows -> [T] { 37 | var values = [T]() 38 | 39 | for element in self { 40 | try await values.append(transform(element)) 41 | } 42 | 43 | return values 44 | } 45 | 46 | @inline(__always) 47 | func concurrentMap( 48 | withPriority priority: TaskPriority? = nil, 49 | _ transform: @escaping @Sendable (Element) async -> T 50 | ) async -> [T] { 51 | let tasks = map { element in 52 | Task(priority: priority) { 53 | await transform(element) 54 | } 55 | } 56 | 57 | return await tasks.asyncMap { task in 58 | await task.value 59 | } 60 | } 61 | 62 | @inline(__always) 63 | func concurrentMap( 64 | withPriority priority: TaskPriority? = nil, 65 | _ transform: @escaping @Sendable (Element) async throws -> T 66 | ) async rethrows -> [T] { 67 | let tasks = map { element in 68 | Task(priority: priority) { 69 | try await transform(element) 70 | } 71 | } 72 | 73 | return try await tasks.asyncMap { task in 74 | try await task.value 75 | } 76 | } 77 | } 78 | 79 | extension Sequence where Element: Sendable { 80 | @inline(__always) 81 | func asyncCompactMap( 82 | _ transform: @escaping @Sendable (Element) async throws -> T? 83 | ) async rethrows -> [T] { 84 | var values = [T]() 85 | 86 | for element in self { 87 | guard let value = try await transform(element) else { 88 | continue 89 | } 90 | 91 | values.append(value) 92 | } 93 | 94 | return values 95 | } 96 | 97 | @inline(__always) 98 | func concurrentCompactMap( 99 | withPriority priority: TaskPriority? = nil, 100 | _ transform: @escaping @Sendable (Element) async -> T? 101 | ) async -> [T] { 102 | let tasks = map { element in 103 | Task(priority: priority) { 104 | await transform(element) 105 | } 106 | } 107 | 108 | return await tasks.asyncCompactMap { task in 109 | await task.value 110 | } 111 | } 112 | 113 | @inline(__always) 114 | func concurrentCompactMap( 115 | withPriority priority: TaskPriority? = nil, 116 | _ transform: @escaping @Sendable (Element) async throws -> T? 117 | ) async rethrows -> [T] { 118 | let tasks = map { element in 119 | Task(priority: priority) { 120 | try await transform(element) 121 | } 122 | } 123 | 124 | return try await tasks.asyncCompactMap { task in 125 | try await task.value 126 | } 127 | } 128 | } 129 | 130 | extension Sequence where Element: Sendable { 131 | @inline(__always) 132 | func asyncFlatMap( 133 | _ transform: @escaping @Sendable (Element) async throws -> T 134 | ) async rethrows -> [T.Element] { 135 | var values = [T.Element]() 136 | 137 | for element in self { 138 | try await values.append(contentsOf: transform(element)) 139 | } 140 | 141 | return values 142 | } 143 | 144 | @inline(__always) 145 | func concurrentFlatMap( 146 | withPriority priority: TaskPriority? = nil, 147 | _ transform: @escaping @Sendable (Element) async -> T 148 | ) async -> [T.Element] { 149 | let tasks = map { element in 150 | Task(priority: priority) { 151 | await transform(element) 152 | } 153 | } 154 | 155 | return await tasks.asyncFlatMap { task in 156 | await task.value 157 | } 158 | } 159 | 160 | @inline(__always) 161 | func concurrentFlatMap( 162 | withPriority priority: TaskPriority? = nil, 163 | _ transform: @escaping @Sendable (Element) async throws -> T 164 | ) async rethrows -> [T.Element] { 165 | let tasks = map { element in 166 | Task(priority: priority) { 167 | try await transform(element) 168 | } 169 | } 170 | 171 | return try await tasks.asyncFlatMap { task in 172 | try await task.value 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Sources/Relux/Relux/Relux+Dispatcher/Relux+Dispatcher+Interface.swift: -------------------------------------------------------------------------------- 1 | //extension Relux { 2 | // @DispatcherActor 3 | // public protocol IDispatcher: Sendable { 4 | // func actions( 5 | // _ executionType: Relux.ExecutionType, 6 | // delay: Seconds?, 7 | // fileID: String, 8 | // functionName: String, 9 | // lineNumber: Int, 10 | // @Relux.ActionResultBuilder actions: @Sendable () -> [Relux.Action], 11 | // label: (@Sendable () -> String)? 12 | // ) async -> Relux.ActionResult 13 | // 14 | // func action( 15 | // delay: Seconds?, 16 | // fileID: String, 17 | // functionName: String, 18 | // lineNumber: Int, 19 | // action: @Sendable () -> Relux.Action, 20 | // label: (@Sendable () -> String)? 21 | // ) async -> Relux.ActionResult 22 | // } 23 | //} 24 | 25 | extension Relux.Dispatcher { 26 | @inlinable 27 | @discardableResult 28 | public func actions( 29 | _ executionType: Relux.ExecutionType = .serially, 30 | delay: Seconds? = nil, 31 | fileID: String = #fileID, 32 | functionName: String = #function, 33 | lineNumber: Int = #line, 34 | @Relux.ActionResultBuilder actions: @Sendable () -> [Relux.Action], 35 | label: (@Sendable () -> String)? = nil 36 | ) async -> Relux.ActionResult { 37 | await _actions( 38 | executionType, 39 | delay: delay, 40 | fileID: fileID, 41 | functionName: functionName, 42 | lineNumber: lineNumber, 43 | actions: actions, 44 | label: label 45 | ) 46 | } 47 | 48 | @inlinable 49 | @discardableResult 50 | public func action( 51 | delay: Seconds? = nil, 52 | fileID: String = #fileID, 53 | functionName: String = #function, 54 | lineNumber: Int = #line, 55 | action: @Sendable () -> Relux.Action, 56 | label: (@Sendable () -> String)? = nil 57 | ) async -> Relux.ActionResult { 58 | await _actions( 59 | .serially, 60 | delay: delay, 61 | fileID: fileID, 62 | functionName: functionName, 63 | lineNumber: lineNumber, 64 | actions: { action() }, 65 | label: label 66 | ) 67 | } 68 | 69 | @usableFromInline @inline(__always) 70 | @discardableResult 71 | internal func _actions( 72 | _ executionType: Relux.ExecutionType = .serially, 73 | delay: Seconds? = nil, 74 | fileID: String = #fileID, 75 | functionName: String = #function, 76 | lineNumber: Int = #line, 77 | @Relux.ActionResultBuilder actions: @Sendable () -> [Relux.Action], 78 | label: (@Sendable () -> String)? = nil 79 | ) async -> Relux.ActionResult { 80 | switch executionType { 81 | case .serially: 82 | await self.sequentialPerform(actions(), delay: delay, fileID: fileID, functionName: functionName, lineNumber: lineNumber, label: label) 83 | case .concurrently: 84 | await self.concurrentPerform(actions(), delay: delay, fileID: fileID, functionName: functionName, lineNumber: lineNumber, label: label) 85 | } 86 | } 87 | } 88 | 89 | 90 | @inlinable 91 | @discardableResult 92 | public func actions( 93 | _ executionType: Relux.ExecutionType = .serially, 94 | delay: Seconds? = nil, 95 | fileID: String = #fileID, 96 | functionName: String = #function, 97 | lineNumber: Int = #line, 98 | @Relux.ActionResultBuilder actions: @Sendable () -> [Relux.Action], 99 | label: (@Sendable () -> String)? = nil 100 | ) async -> Relux.ActionResult { 101 | await Relux.shared.dispatcher._actions( 102 | executionType, 103 | delay: delay, 104 | fileID: fileID, 105 | functionName: functionName, 106 | lineNumber: lineNumber, 107 | actions: actions, 108 | label: label 109 | ) 110 | } 111 | 112 | @inlinable 113 | @discardableResult 114 | public func action( 115 | delay: Seconds? = nil, 116 | fileID: String = #fileID, 117 | functionName: String = #function, 118 | lineNumber: Int = #line, 119 | action: @Sendable () -> Relux.Action, 120 | label: (@Sendable () -> String)? = nil 121 | ) async -> Relux.ActionResult { 122 | await Relux.shared.dispatcher._actions( 123 | .serially, 124 | delay: delay, 125 | fileID: fileID, 126 | functionName: functionName, 127 | lineNumber: lineNumber, 128 | actions: { action() }, 129 | label: label 130 | ) 131 | } 132 | 133 | @inlinable 134 | public func performAsync( 135 | _ executionType: Relux.ExecutionType = .serially, 136 | withPriority taskPriority: TaskPriority? = nil, 137 | delay: Seconds? = nil, 138 | fileID: String = #fileID, 139 | functionName: String = #function, 140 | lineNumber: Int = #line, 141 | @Relux.ActionResultBuilder actions: @Sendable @escaping () -> [Relux.Action], 142 | completion: (@Sendable (Relux.ActionResult) -> ())? = nil, 143 | label: (@Sendable () -> String)? = nil 144 | ) { 145 | Task(priority: taskPriority) { 146 | let result = await Relux.shared.dispatcher._actions( 147 | executionType, 148 | delay: delay, 149 | fileID: fileID, 150 | functionName: functionName, 151 | lineNumber: lineNumber, 152 | actions: actions, 153 | label: label 154 | ) 155 | completion?(result) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/TestUtils/Internal/LockedState.swift: -------------------------------------------------------------------------------- 1 | // LockedState.swift 2 | // https://github.com/swiftlang/swift-foundation/blob/main/Sources/FoundationEssentials/LockedState.swift 3 | // 4 | //===----------------------------------------------------------------------===// 5 | // 6 | // This source file is part of the Swift.org open source project 7 | // 8 | // Copyright (c) 2022 Apple Inc. and the Swift project authors 9 | // Licensed under Apache License v2.0 with Runtime Library Exception 10 | // 11 | // See https://swift.org/LICENSE.txt for license information 12 | // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 13 | // 14 | //===----------------------------------------------------------------------===// 15 | 16 | 17 | #if canImport(os) 18 | internal import os 19 | #if FOUNDATION_FRAMEWORK && canImport(C.os.lock) 20 | internal import C.os.lock 21 | #endif 22 | #elseif canImport(Bionic) 23 | @preconcurrency import Bionic 24 | #elseif canImport(Glibc) 25 | @preconcurrency import Glibc 26 | #elseif canImport(Musl) 27 | @preconcurrency import Musl 28 | #elseif canImport(WinSDK) 29 | import WinSDK 30 | #endif 31 | 32 | package struct LockedState { 33 | 34 | // Internal implementation for a cheap lock to aid sharing code across platforms 35 | private struct _Lock { 36 | #if canImport(os) 37 | typealias Primitive = os_unfair_lock 38 | #elseif os(FreeBSD) || os(OpenBSD) 39 | typealias Primitive = pthread_mutex_t? 40 | #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) 41 | typealias Primitive = pthread_mutex_t 42 | #elseif canImport(WinSDK) 43 | typealias Primitive = SRWLOCK 44 | #elseif os(WASI) 45 | // WASI is single-threaded, so we don't need a lock. 46 | typealias Primitive = Void 47 | #endif 48 | 49 | typealias PlatformLock = UnsafeMutablePointer 50 | var _platformLock: PlatformLock 51 | 52 | fileprivate static func initialize(_ platformLock: PlatformLock) { 53 | #if canImport(os) 54 | platformLock.initialize(to: os_unfair_lock()) 55 | #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) 56 | pthread_mutex_init(platformLock, nil) 57 | #elseif canImport(WinSDK) 58 | InitializeSRWLock(platformLock) 59 | #elseif os(WASI) 60 | // no-op 61 | #else 62 | #error("LockedState._Lock.initialize is unimplemented on this platform") 63 | #endif 64 | } 65 | 66 | fileprivate static func deinitialize(_ platformLock: PlatformLock) { 67 | #if canImport(Bionic) || canImport(Glibc) || canImport(Musl) 68 | pthread_mutex_destroy(platformLock) 69 | #endif 70 | platformLock.deinitialize(count: 1) 71 | } 72 | 73 | static fileprivate func lock(_ platformLock: PlatformLock) { 74 | #if canImport(os) 75 | os_unfair_lock_lock(platformLock) 76 | #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) 77 | pthread_mutex_lock(platformLock) 78 | #elseif canImport(WinSDK) 79 | AcquireSRWLockExclusive(platformLock) 80 | #elseif os(WASI) 81 | // no-op 82 | #else 83 | #error("LockedState._Lock.lock is unimplemented on this platform") 84 | #endif 85 | } 86 | 87 | static fileprivate func unlock(_ platformLock: PlatformLock) { 88 | #if canImport(os) 89 | os_unfair_lock_unlock(platformLock) 90 | #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) 91 | pthread_mutex_unlock(platformLock) 92 | #elseif canImport(WinSDK) 93 | ReleaseSRWLockExclusive(platformLock) 94 | #elseif os(WASI) 95 | // no-op 96 | #else 97 | #error("LockedState._Lock.unlock is unimplemented on this platform") 98 | #endif 99 | } 100 | } 101 | 102 | private class _Buffer: ManagedBuffer { 103 | deinit { 104 | withUnsafeMutablePointerToElements { 105 | _Lock.deinitialize($0) 106 | } 107 | } 108 | } 109 | 110 | private let _buffer: ManagedBuffer 111 | 112 | package init(initialState: State) { 113 | _buffer = _Buffer.create(minimumCapacity: 1, makingHeaderWith: { buf in 114 | buf.withUnsafeMutablePointerToElements { 115 | _Lock.initialize($0) 116 | } 117 | return initialState 118 | }) 119 | } 120 | 121 | package func withLock(_ body: @Sendable (inout State) throws -> T) rethrows -> T { 122 | try withLockUnchecked(body) 123 | } 124 | 125 | package func withLockUnchecked(_ body: (inout State) throws -> T) rethrows -> T { 126 | try _buffer.withUnsafeMutablePointers { state, lock in 127 | _Lock.lock(lock) 128 | defer { _Lock.unlock(lock) } 129 | return try body(&state.pointee) 130 | } 131 | } 132 | 133 | // Ensures the managed state outlives the locked scope. 134 | package func withLockExtendingLifetimeOfState(_ body: @Sendable (inout State) throws -> T) rethrows -> T { 135 | try _buffer.withUnsafeMutablePointers { state, lock in 136 | _Lock.lock(lock) 137 | return try withExtendedLifetime(state.pointee) { 138 | defer { _Lock.unlock(lock) } 139 | return try body(&state.pointee) 140 | } 141 | } 142 | } 143 | } 144 | 145 | extension LockedState where State == Void { 146 | package init() { 147 | self.init(initialState: ()) 148 | } 149 | 150 | package func withLock(_ body: @Sendable () throws -> R) rethrows -> R { 151 | return try withLock { _ in 152 | try body() 153 | } 154 | } 155 | 156 | package func lock() { 157 | _buffer.withUnsafeMutablePointerToElements { lock in 158 | _Lock.lock(lock) 159 | } 160 | } 161 | 162 | package func unlock() { 163 | _buffer.withUnsafeMutablePointerToElements { lock in 164 | _Lock.unlock(lock) 165 | } 166 | } 167 | } 168 | 169 | extension LockedState: @unchecked Sendable where State: Sendable {} 170 | --------------------------------------------------------------------------------