diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index f96cb0e6..68ded6a6 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -2,10 +2,12 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal class DefaultRealtimeObjects: RealtimeObjects { - private weak var channel: ARTRealtimeChannel? +internal final class DefaultRealtimeObjects: RealtimeObjects { + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger - private let pluginAPI: AblyPlugin.PluginAPIProtocol // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -13,10 +15,9 @@ internal class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { - self.channel = channel + internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { + self.coreSDK = coreSDK self.logger = logger - self.pluginAPI = pluginAPI (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() } @@ -27,7 +28,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func createMap(entries _: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap { + internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { notYetImplemented() } @@ -35,7 +36,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func createCounter(count _: Int) async throws(ARTErrorInfo) -> any LiveCounter { + internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter { notYetImplemented() } @@ -43,7 +44,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func batch(callback _: (any BatchContext) -> Void) async throws { + internal func batch(callback _: sending (sending any BatchContext) -> Void) async throws { notYetImplemented() } @@ -57,9 +58,17 @@ internal class DefaultRealtimeObjects: RealtimeObjects { // MARK: Handling channel events - internal private(set) var testsOnly_onChannelAttachedHasObjects: Bool? + private nonisolated(unsafe) var onChannelAttachedHasObjects: Bool? + internal var testsOnly_onChannelAttachedHasObjects: Bool? { + mutex.withLock { + onChannelAttachedHasObjects + } + } + internal func onChannelAttached(hasObjects: Bool) { - testsOnly_onChannelAttachedHasObjects = hasObjects + mutex.withLock { + onChannelAttachedHasObjects = hasObjects + } } internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { @@ -74,7 +83,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String) { + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String?) { receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) } @@ -82,14 +91,6 @@ internal class DefaultRealtimeObjects: RealtimeObjects { // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - guard let channel else { - return - } - - try await DefaultInternalPlugin.sendObject( - objectMessages: objectMessages, - channel: channel, - pluginAPI: pluginAPI, - ) + try await coreSDK.sendObject(objectMessages: objectMessages) } } diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift new file mode 100644 index 00000000..8a63d3c0 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -0,0 +1,44 @@ +import Ably +internal import AblyPlugin + +/// The API that the internal components of the SDK (that is, `DefaultLiveObjects` and down) use to interact with our core SDK (i.e. ably-cocoa). +/// +/// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to AblyPlugin's Objective-C API (i.e. boxing). +internal protocol CoreSDK: AnyObject, Sendable { + func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) +} + +internal final class DefaultCoreSDK: CoreSDK { + // We hold a weak reference to the channel so that `DefaultLiveObjects` can hold a strong reference to us without causing a strong reference cycle. We'll revisit this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9. + private let weakChannel: WeakRef + private let pluginAPI: PluginAPIProtocol + + internal init( + channel: ARTRealtimeChannel, + pluginAPI: PluginAPIProtocol + ) { + weakChannel = .init(referenced: channel) + self.pluginAPI = pluginAPI + } + + // MARK: - Fetching channel + + private var channel: ARTRealtimeChannel { + guard let channel = weakChannel.referenced else { + // It's currently completely possible that the channel _does_ become deallocated during the usage of the LiveObjects SDK; in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 we'll figure out how to prevent this. + preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK") + } + + return channel + } + + // MARK: - CoreSDK conformance + + internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await DefaultInternalPlugin.sendObject( + objectMessages: objectMessages, + channel: channel, + pluginAPI: pluginAPI, + ) + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 61b316a4..5cfd4616 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -37,7 +37,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = DefaultRealtimeObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) + let coreSDK = DefaultCoreSDK(channel: channel, pluginAPI: pluginAPI) + let liveObjects = DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } @@ -109,7 +110,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte ) } - internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { + internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: ARTRealtimeChannel) { guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 149536b2..ed77e33e 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -3,7 +3,7 @@ import Ably /// A callback used in ``LiveObject`` to listen for updates to the object. /// /// - Parameter update: The update object describing the changes made to the object. -public typealias LiveObjectUpdateCallback = (_ update: T) -> Void +public typealias LiveObjectUpdateCallback = (_ update: sending T) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. public typealias ObjectsEventCallback = () -> Void @@ -14,10 +14,10 @@ public typealias LiveObjectLifecycleEventCallback = () -> Void /// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. /// /// - Parameter batchContext: A ``BatchContext`` object that allows grouping Objects operations for this batch. -public typealias BatchCallback = (_ batchContext: BatchContext) -> Void +public typealias BatchCallback = (_ batchContext: sending BatchContext) -> Void /// Describes the events emitted by an ``RealtimeObjects`` object. -public enum ObjectsEvent { +public enum ObjectsEvent: Sendable { /// The local copy of Objects on a channel is currently being synchronized with the Ably service. case syncing /// The local copy of Objects on a channel has been synchronized with the Ably service. @@ -25,20 +25,20 @@ public enum ObjectsEvent { } /// Describes the events emitted by a ``LiveObject`` object. -public enum LiveObjectLifecycleEvent { +public enum LiveObjectLifecycleEvent: Sendable { /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. case deleted } /// Enables the Objects to be read, modified and subscribed to for a channel. -public protocol RealtimeObjects { +public protocol RealtimeObjects: Sendable { /// Retrieves the root ``LiveMap`` object for Objects on a channel. func getRoot() async throws(ARTErrorInfo) -> any LiveMap /// Creates a new ``LiveMap`` object instance with the provided entries. /// /// - Parameter entries: The initial entries for the new ``LiveMap`` object. - func createMap(entries: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap + func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap /// Creates a new empty ``LiveMap`` object instance. func createMap() async throws(ARTErrorInfo) -> any LiveMap @@ -46,7 +46,7 @@ public protocol RealtimeObjects { /// Creates a new ``LiveCounter`` object instance with the provided `count` value. /// /// - Parameter count: The initial value for the new ``LiveCounter`` object. - func createCounter(count: Int) async throws(ARTErrorInfo) -> any LiveCounter + func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter /// Creates a new ``LiveCounter`` object instance with a value of zero. func createCounter() async throws(ARTErrorInfo) -> any LiveCounter @@ -61,7 +61,7 @@ public protocol RealtimeObjects { /// when the batched operations are applied by the Ably service and echoed back to the client. /// /// - Parameter callback: A batch callback function used to group operations together. - func batch(callback: BatchCallback) async throws + func batch(callback: sending BatchCallback) async throws /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. /// @@ -69,7 +69,8 @@ public protocol RealtimeObjects { /// - event: The named event to listen for. /// - callback: The event listener. /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. - func on(event: ObjectsEvent, callback: ObjectsEventCallback) -> OnObjectsEventResponse + @discardableResult + func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -77,20 +78,20 @@ public protocol RealtimeObjects { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (``PrimitiveObjectValue``), or another ``LiveObject``. -public enum LiveMapValue { +public enum LiveMapValue: Sendable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnObjectsEventResponse { +public protocol OnObjectsEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } /// Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. -public protocol BatchContext { +public protocol BatchContext: Sendable { /// Mirrors the ``RealtimeObjects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. /// /// - Returns: A ``BatchContextLiveMap`` object. @@ -98,7 +99,7 @@ public protocol BatchContext { } /// A wrapper around the ``LiveMap`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveMap: AnyObject { +public protocol BatchContextLiveMap: AnyObject, Sendable { /// Mirrors the ``LiveMap/get(key:)`` method and returns the value associated with a key in the map. /// /// - Parameter key: The key to retrieve the value for. @@ -130,9 +131,9 @@ public protocol BatchContextLiveMap: AnyObject { } /// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveCounter: AnyObject { +public protocol BatchContextLiveCounter: AnyObject, Sendable { /// Returns the current value of the counter. - var value: Int { get } + var value: Double { get } /// Similar to the ``LiveCounter/increment(amount:)`` method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. /// @@ -141,12 +142,12 @@ public protocol BatchContextLiveCounter: AnyObject { /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. /// /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Int) + func increment(amount: Double) /// An alias for calling [`increment(-amount)`](doc:BatchContextLiveCounter/increment(amount:)). /// /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Int) + func decrement(amount: Double) } /// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. @@ -197,7 +198,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { } /// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. -public enum LiveMapUpdateAction { +public enum LiveMapUpdateAction: Sendable { /// The value of a key in the map was updated. case updated /// The value of a key in the map was removed. @@ -205,7 +206,7 @@ public enum LiveMapUpdateAction { } /// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. -public protocol LiveMapUpdate { +public protocol LiveMapUpdate: Sendable { /// An object containing keys from a `LiveMap` that have changed, along with their change status: /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. /// - ``LiveMapUpdateAction/removed`` - the key was removed from the map. @@ -213,7 +214,7 @@ public protocol LiveMapUpdate { } /// Represents a primitive value that can be stored in a ``LiveMap``. -public enum PrimitiveObjectValue { +public enum PrimitiveObjectValue: Sendable { case string(String) case number(Double) case bool(Bool) @@ -223,7 +224,7 @@ public enum PrimitiveObjectValue { /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. - var value: Int { get } + var value: Double { get } /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. /// @@ -232,22 +233,22 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. /// /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Int) async throws(ARTErrorInfo) + func increment(amount: Double) async throws(ARTErrorInfo) /// An alias for calling [`increment(-amount)`](doc:LiveCounter/increment(amount:)). /// /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Int) async throws(ARTErrorInfo) + func decrement(amount: Double) async throws(ARTErrorInfo) } /// Represents an update to a ``LiveCounter`` object. -public protocol LiveCounterUpdate { +public protocol LiveCounterUpdate: Sendable { /// Holds the numerical change to the counter value. - var amount: Int { get } + var amount: Double { get } } /// Describes the common interface for all conflict-free data structures supported by the Objects. -public protocol LiveObject: AnyObject { +public protocol LiveObject: AnyObject, Sendable { /// The type of update event that this object emits. associatedtype Update @@ -255,7 +256,8 @@ public protocol LiveObject: AnyObject { /// /// - Parameter listener: An event listener function that is called with an update object whenever this LiveObject is updated. /// - Returns: A ``SubscribeResponse`` object that allows the provided listener to be deregistered from future updates. - func subscribe(listener: LiveObjectUpdateCallback) -> SubscribeResponse + @discardableResult + func subscribe(listener: sending LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -266,27 +268,21 @@ public protocol LiveObject: AnyObject { /// - event: The named event to listen for. /// - callback: The event listener. /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. - func on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse - - /// Removes all registrations that match both the specified listener and the specified event. - /// - /// - Parameters: - /// - event: The named event. - /// - callback: The event listener. - func off(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) + @discardableResult + func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() } /// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. -public protocol SubscribeResponse { +public protocol SubscribeResponse: Sendable { /// Deregisters the listener passed to the `subscribe` call. func unsubscribe() } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnLiveObjectLifecycleEventResponse { +public protocol OnLiveObjectLifecycleEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift new file mode 100644 index 00000000..c7175a7e --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -0,0 +1,8 @@ +/// A struct that holds a weak reference to an object. +/// +/// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) +internal struct WeakRef { + internal weak var referenced: Referenced? +} + +extension WeakRef: Sendable where Referenced: Sendable {} diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift new file mode 100644 index 00000000..59c51e17 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -0,0 +1,40 @@ +import AblyPlugin +import os + +/// An implementation of `AblyPlugin.Logger` to use when testing internal components of the LiveObjects plugin. +final class TestLogger: NSObject, AblyPlugin.Logger { + // By default, we don’t log in tests to keep the test logs easy to read. You can set this property to `true` to temporarily turn logging on if you want to debug a test. + static let loggingEnabled = false + + private let underlyingLogger = os.Logger() + + func log(_ message: String, with level: ARTLogLevel, file fileName: UnsafePointer, line: Int) { + guard Self.loggingEnabled else { + return + } + + underlyingLogger.log(level: level.toOSLogType, "(\(String(cString: fileName)):\(line)): \(message)") + } +} + +private extension ARTLogLevel { + var toOSLogType: OSLogType { + // Not much thought has gone into this conversion + switch self { + case .verbose: + .debug + case .debug: + .debug + case .info: + .info + case .warn: + .error + case .error: + .error + case .none: + .debug + @unknown default: + .debug + } + } +} diff --git a/ably-cocoa b/ably-cocoa index 9b2bfe48..4a21e87f 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 9b2bfe48b4c8c070404957758869b8918e8eade3 +Subproject commit 4a21e87f988be4741f19d74f13cc4c040eb92121