Skip to content

Commit dcbe617

Browse files
Implement memory management pattern similar to ably-cocoa
We adopt a pattern whereby the LiveObjects SDK functions correctly as long as the user is holding a reference to any object vended by the public API of the SDK. We do this by copying the ably-cocoa approach of having separate public and internal versions of objects. All written by me, except for getting Cursor to help with updating the tests in response to the new way of injecting CoreSDK. Resolves #9.
1 parent ed08223 commit dcbe617

26 files changed

Lines changed: 1137 additions & 578 deletions

CONTRIBUTING.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with `
4545
- The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`.
4646
- `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyLiveObjects_mapValuesWithTypedThrow` which does; use this.
4747

48+
### Memory management
49+
50+
We follow an approach to memory management that is broadly similar to that of ably-cocoa: we keep all of the internal components of the SDK alive as long as the user is holding a strong reference to any object vended by the public API of the SDK. This means that, for example, a user can use LiveObjects functionality by maintaining only a reference to the root `LiveMap`; even if they relinquish their references to, say, the `ARTRealtime` or `ARTRealtimeChannel` instance, they will continue to receive events from the `LiveMap` and they will be able to use its methods.
51+
52+
We achieve this by vending a set of public types which maintain strong references to all of the internal components of the SDK which are needed in order for the public type to function correctly. For example, the public `PublicDefaultLiveMap` type wraps an `InternalDefaultLiveMap`, and holds strong references to the `CoreSDK` object, which in turn holds the following sequence of strong references: `CoreSDK``RealtimeClient``RealtimeChannel``InternalDefaultRealtimeObjects`, thus ensuring that:
53+
54+
1. the `InternalDefaultLiveMap` can always perform actions on these dependencies in response to a user action
55+
2. these dependencies, which deliver events to the `InternalDefaultLiveMap`, remain alive and thus remain delivering events
56+
57+
The key rules that must be followed in order to avoid a strong reference cycle are that the SDK's _internal_ classes (that is `InternalDefaultLiveMap` etc) _must never hold a strong reference to_:
58+
59+
- any of the corresponding public types (e.g. `PublicDefaultLiveMap`)
60+
- any of the ably-cocoa components that hold a strong reference to these internal components (that is, the realtime client or channel); thus, they must never hold a strong reference to a `CoreSDK` object
61+
62+
Note that, unlike ably-cocoa, the internal components do not even hold weak references to their dependencies; rather, these dependencies are passed in by the public object when the user performs an action that requires one of these dependencies. (There may turn out to be limitations to this approach, but haven't found them yet.)
63+
64+
Also note that, unlike ably-cocoa, we aim to provide a stable pointer identity for the public objects vended by the SDK, instead of creating a new object each time the user requests one. See the `PublicObjectsStore` class for more details.
65+
66+
The `Public…` classes all follow the same pattern and are not very interesting; the business logic should be in the `Internal…` classes and those should be where we focus our unit testing effort.
67+
68+
`ObjectLifetimesTests.swift` contains tests of the behaviour described in this section.
69+
4870
### Testing guidelines
4971

5072
#### Attributing tests to a spec point

Sources/AblyLiveObjects/Internal/CoreSDK.swift

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,20 @@ internal protocol CoreSDK: AnyObject, Sendable {
1212
}
1313

1414
internal final class DefaultCoreSDK: CoreSDK {
15-
// 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.
16-
private let weakChannel: WeakRef<AblyPlugin.RealtimeChannel>
15+
private let channel: AblyPlugin.RealtimeChannel
16+
private let client: AblyPlugin.RealtimeClient
1717
private let pluginAPI: PluginAPIProtocol
1818

1919
internal init(
2020
channel: AblyPlugin.RealtimeChannel,
21+
client: AblyPlugin.RealtimeClient,
2122
pluginAPI: PluginAPIProtocol
2223
) {
23-
weakChannel = .init(referenced: channel)
24+
self.channel = channel
25+
self.client = client
2426
self.pluginAPI = pluginAPI
2527
}
2628

27-
// MARK: - Fetching channel
28-
29-
private var channel: AblyPlugin.RealtimeChannel {
30-
guard let channel = weakChannel.referenced else {
31-
// 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.
32-
preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK")
33-
}
34-
35-
return channel
36-
}
37-
3829
// MARK: - CoreSDK conformance
3930

4031
internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) {

Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,17 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte
1717
/// The `pluginDataValue(forKey:channel:)` key that we use to store the value of the `ARTRealtimeChannel.objects` property.
1818
private static let pluginDataKey = "LiveObjects"
1919

20-
/// Retrieves the value that should be returned by `ARTRealtimeChannel.objects`.
21-
///
22-
/// We expect this value to have been previously set by ``prepare(_:)``.
23-
internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects {
24-
let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: channel)
25-
return realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI)
26-
}
27-
2820
/// Retrieves the `RealtimeObjects` for this channel.
2921
///
3022
/// We expect this value to have been previously set by ``prepare(_:)``.
31-
private static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects {
23+
internal static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> InternalDefaultRealtimeObjects {
3224
guard let pluginData = pluginAPI.pluginDataValue(forKey: pluginDataKey, channel: channel) else {
3325
// InternalPlugin.prepare was not called
3426
fatalError("To access LiveObjects functionality, you must pass the LiveObjects plugin in the client options when creating the ARTRealtime instance: `clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]`")
3527
}
3628

3729
// swiftlint:disable:next force_cast
38-
return pluginData as! DefaultRealtimeObjects
30+
return pluginData as! InternalDefaultRealtimeObjects
3931
}
4032

4133
// MARK: - LiveObjectsInternalPluginProtocol
@@ -45,13 +37,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte
4537
let logger = pluginAPI.logger(for: channel)
4638

4739
logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug)
48-
let coreSDK = DefaultCoreSDK(channel: channel, pluginAPI: pluginAPI)
49-
let liveObjects = DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger)
40+
let liveObjects = InternalDefaultRealtimeObjects(logger: logger)
5041
pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel)
5142
}
5243

5344
/// Retrieves the internally-typed `objects` property for the channel.
54-
private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> DefaultRealtimeObjects {
45+
private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> InternalDefaultRealtimeObjects {
5546
Self.realtimeObjects(for: channel, pluginAPI: pluginAPI)
5647
}
5748

Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift renamed to Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import Ably
22
internal import AblyPlugin
33
import Foundation
44

5-
/// Our default implementation of ``LiveCounter``.
6-
internal final class DefaultLiveCounter: LiveCounter {
5+
/// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API.
6+
internal final class InternalDefaultLiveCounter: Sendable {
77
// 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.
88
private let mutex = NSLock()
99

@@ -27,28 +27,24 @@ internal final class DefaultLiveCounter: LiveCounter {
2727
}
2828
}
2929

30-
private let coreSDK: CoreSDK
3130
private let logger: AblyPlugin.Logger
3231

3332
// MARK: - Initialization
3433

3534
internal convenience init(
3635
testsOnly_data data: Double,
3736
objectID: String,
38-
coreSDK: CoreSDK,
3937
logger: AblyPlugin.Logger
4038
) {
41-
self.init(data: data, objectID: objectID, coreSDK: coreSDK, logger: logger)
39+
self.init(data: data, objectID: objectID, logger: logger)
4240
}
4341

4442
private init(
4543
data: Double,
4644
objectID: String,
47-
coreSDK: CoreSDK,
4845
logger: AblyPlugin.Logger
4946
) {
5047
mutableState = .init(liveObject: .init(objectID: objectID), data: data)
51-
self.coreSDK = coreSDK
5248
self.logger = logger
5349
}
5450

@@ -58,35 +54,31 @@ internal final class DefaultLiveCounter: LiveCounter {
5854
/// - objectID: The value for the "private objectId field" of RTO5c1b1a.
5955
internal static func createZeroValued(
6056
objectID: String,
61-
coreSDK: CoreSDK,
6257
logger: AblyPlugin.Logger,
6358
) -> Self {
6459
.init(
6560
data: 0,
6661
objectID: objectID,
67-
coreSDK: coreSDK,
6862
logger: logger,
6963
)
7064
}
7165

72-
// MARK: - LiveCounter conformance
66+
// MARK: - Internal methods that back LiveCounter conformance
7367

74-
internal var value: Double {
75-
get throws(ARTErrorInfo) {
76-
// RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001
77-
let currentChannelState = coreSDK.channelState
78-
if currentChannelState == .detached || currentChannelState == .failed {
79-
throw LiveObjectsError.objectsOperationFailedInvalidChannelState(
80-
operationDescription: "LiveCounter.value",
81-
channelState: currentChannelState,
82-
)
83-
.toARTErrorInfo()
84-
}
68+
internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double {
69+
// RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001
70+
let currentChannelState = coreSDK.channelState
71+
if currentChannelState == .detached || currentChannelState == .failed {
72+
throw LiveObjectsError.objectsOperationFailedInvalidChannelState(
73+
operationDescription: "LiveCounter.value",
74+
channelState: currentChannelState,
75+
)
76+
.toARTErrorInfo()
77+
}
8578

86-
return mutex.withLock {
87-
// RTLC5c
88-
mutableState.data
89-
}
79+
return mutex.withLock {
80+
// RTLC5c
81+
mutableState.data
9082
}
9183
}
9284

0 commit comments

Comments
 (0)