Conversation
WalkthroughImplements publish-and-apply for live objects: introduces InternalRealtimeObjectsProtocol, PublishResult, operation source tracking, waiter/drain sync infrastructure, siteCode and applied-on-ACK tracking, and threads realtimeObjects through publish/apply call paths and public proxies. Tests and mocks updated accordingly. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client Code
participant PublicAPI as Public API / Proxy
participant InternalRT as InternalRealtimeObjects
participant CoreSDK as CoreSDK
participant Channel as Realtime Channel
participant LocalPool as Local Object Pool
Client->>PublicAPI: set()/increment()
PublicAPI->>InternalRT: nosync_publishAndApply(objectMessages, coreSDK, callback)
par publish path
InternalRT->>CoreSDK: nosync_publish(objectMessages, callback)
CoreSDK->>Channel: send OBJECT messages
and waiter path
InternalRT->>InternalRT: enqueue PublishAndApplySyncWaiter
end
Channel-->>CoreSDK: ACK (PublishResult)
CoreSDK-->>InternalRT: callback(.success(PublishResult))
rect rgba(100,200,100,0.5)
InternalRT->>InternalRT: create synthetic InboundObjectMessage(s)
InternalRT->>LocalPool: apply(syntheticMessage, source: .local)
LocalPool-->>InternalRT: applied? (Bool)
InternalRT->>InternalRT: record appliedOnAckSerials
end
InternalRT->>InternalRT: nosync_drainPublishAndApplySyncWaiters(outcome: .synced)
InternalRT-->>PublicAPI: callback(.success)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
8afca5a to
e183c0e
Compare
Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift
Outdated
Show resolved
Hide resolved
- Package.swift: restore dependency URLs (remove .git suffix), add TODO comments to unpin before release - PublishResult: remove explicit Sendable (inferred by Swift 6) - Remove @discardableResult from nosync_apply methods; update tests to capture and assert on the return value - siteCode: change from pull to push pattern — remove nosync_siteCode() from CoreSDK, push from DefaultInternalPlugin.nosync_onConnected via nosync_setSiteCode - Replace NSSelectorFromString with #selector - Introduce InternalDefaultRealtimeObjectsProtocol extending LiveMapObjectsPoolDelegate, consolidating the separate delegate and realtimeObjects fields in public proxy objects; create MockRealtimeObjects to simplify map/counter publish tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
779d380 to
f1510fc
Compare
f1510fc to
52ecf38
Compare
52ecf38 to
aa84a72
Compare
fa85697 to
d012aa6
Compare
d012aa6 to
18a80fa
Compare
Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift
Outdated
Show resolved
Hide resolved
18a80fa to
63d3c6a
Compare
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift
Show resolved
Hide resolved
Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (5)
Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift (1)
407-407: Use implicit.initfor inferredARTConnectionDetailsconstruction.Type inference is available for the
connectionDetailsproperty assignment on line 407. The codebase already uses this pattern in similar contexts (see ObjectsIntegrationTests.swift lines 3913 and 3941).Suggested change
- msg.connectionDetails = ARTConnectionDetails(clientId: clientId, connectionKey: "a8c10!t-3D0O4ejwTdvLkl-b33a8c10", maxMessageSize: 16384, maxFrameSize: 262_144, maxInboundRate: 250, connectionStateTtl: 60, serverId: "testServerId", maxIdleInterval: 15000, objectsGCGracePeriod: 86_400_000, siteCode: nil) + msg.connectionDetails = .init(clientId: clientId, connectionKey: "a8c10!t-3D0O4ejwTdvLkl-b33a8c10", maxMessageSize: 16384, maxFrameSize: 262_144, maxInboundRate: 250, connectionStateTtl: 60, serverId: "testServerId", maxIdleInterval: 15000, objectsGCGracePeriod: 86_400_000, siteCode: nil)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/AblyLiveObjectsTests/JS` Integration Tests/TestProxyTransport.swift at line 407, The assignment to msg.connectionDetails uses an explicit ARTConnectionDetails(...) constructor; change it to use type-inferred shorthand by calling .init(...) so it matches the project's style (e.g., replace ARTConnectionDetails(clientId:..., ...) with .init(clientId:..., ...)) at the msg.connectionDetails assignment site to keep consistency with other tests using inferred initialization.Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift (2)
1797-1801: Prefer value-based assertions over reference identity for thrown errors.At Line 1800,
===can become brittle if error objects are bridged/reboxed. Comparing stable fields (code,statusCode,message) is more robust.Suggested assertion update
- `#expect`(thrownError === publishError) + `#expect`(thrownError.code == publishError.code) + `#expect`(thrownError.statusCode == publishError.statusCode) + `#expect`(thrownError.message == publishError.message)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift` around lines 1797 - 1801, The test currently asserts thrownError === publishError (reference identity) which is brittle; change the assertion to compare ARTErrorInfo fields instead: extract the thrownError and publishError (both ARTErrorInfo) returned by realtimeObjects.createMap and assert their .code, .statusCode and .message (or equivalent stable properties) are equal to each other so the test uses value-based equality rather than reference identity; locate the check around thrownError, publishError and createMap to update the assertion accordingly.
1334-1338: Consider extracting repeated “transition to synced state” setup.The same prelude is repeated across multiple tests; a small helper would reduce drift and make intent clearer.
Also applies to: 1382-1386, 1417-1421, 1487-1491, 1528-1532, 1564-1568
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift` around lines 1334 - 1338, Extract the repeated "transition to synced state" setup into a small helper function (e.g., transitionToSyncedState or makeRealtimeObjectsSynced) that calls internalQueue.ably_syncNoDeadlock { realtimeObjects.nosync_onChannelAttached(hasObjects: false); realtimeObjects.nosync_setSiteCode("site1") }, then replace the duplicated blocks in the tests (locations referencing internalQueue.ably_syncNoDeadlock and realtimeObjects.nosync_onChannelAttached / nosync_setSiteCode) with a single call to that helper to reduce duplication and clarify intent.Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift (1)
4507-4553: Usedeferfor interceptor restoration in these tests.These tests restore interceptors manually. If the test throws before restore, hooks can leak for the rest of the test body. Use
defer { interceptor.restore() }consistently after creation.Also applies to: 4587-4599
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/AblyLiveObjectsTests/JS` Integration Tests/ObjectsIntegrationTests.swift around lines 4507 - 4553, After creating an EchoInterceptor instance (symbol: EchoInterceptor and variable echoInterceptor), add a defer { echoInterceptor.restore() } immediately so the interceptor is always restored even if the test throws; remove or keep the later manual restore calls (restore() at the end) as redundant but ensure restore happens after any explicit releaseAll() call (method: releaseAll) so behavior is unchanged. Apply the same change to the other test where EchoInterceptor is created (the second block referenced) to avoid leaking hooks.Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift (1)
11-15: Add explicit ACL on the protocol requirement.
nosync_publishAndApplyshould declare its access level explicitly to satisfy the project ACL rule.💡 Proposed fix
internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { @@ - func nosync_publishAndApply( + internal func nosync_publishAndApply( objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK, callback: `@escaping` `@Sendable` (Result<Void, ARTErrorInfo>) -> Void, ) }As per coding guidelines, "Specify an explicit access control level (SwiftLint explicit_acl) for all declarations in Swift code (tests are exempt)".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift` around lines 11 - 15, The protocol requirement nosync_publishAndApply currently lacks an explicit access control level; update its declaration to include the appropriate ACL (for example `internal` or `public` depending on the protocol's intended visibility) so it satisfies SwiftLint explicit_acl. Locate the nosync_publishAndApply requirement in InternalDefaultRealtimeObjects (and the corresponding protocol declaration if separate) and prepend the chosen access modifier to the function signature, ensuring all matching implementations/signatures across the codebase use the same access level.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift`:
- Around line 749-753: The no-`HAS_OBJECTS` sync-completion path currently calls
nosync_drainPublishAndApplySyncWaiters(outcome: .synced) but does not clear the
appliedOnAckSerials set, leaving stale ACK serials that can suppress later
OBJECTs; update the RTO4b no-HAS_OBJECTS branch to reset/clear
appliedOnAckSerials before or immediately after calling
nosync_drainPublishAndApplySyncWaiters (and make the identical change in the
other occurrence around the 861-863 region) so that ACK-tracking state is
cleared on this sync-complete path.
In `@Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift`:
- Around line 410-421: The comment is contradictory: it says the operation "will
be applied" though the test asserts it is discarded (applied == false). Update
the comment near the internalQueue.ably_syncNoDeadlock call and the
counter.nosync_apply invocation to state that the operation will be discarded
(not applied) because its serial "ts1" is lexicographically less than the
existing "ts2" and thus should be ignored in this discard-path case; ensure the
comment references the discard expectation so it matches the assertion
(!applied) and unchanged state checks.
In `@Tests/AblyLiveObjectsTests/JS` Integration
Tests/ObjectsIntegrationTests.swift:
- Around line 4461-4468: The test uses a fixed Task.sleep(2_000_000_000) to wait
for the ACK/publish-and-apply sequence, which is flaky; replace the hard sleep
with a deterministic wait for the actual event/state change (e.g., register an
interceptor or callback that signals when publishAndApply completes, or await an
AsyncContinuation/AsyncStream or XCTestExpectation fulfilled by the ACK handler)
so that the code calling counter.increment(5) proceeds only after the real
ACK/publishAndApply event is observed; locate the sleep call and the surrounding
usage of counter.increment and publishAndApply and replace the sleep with
awaiting the explicit signal from that interceptor/handler.
- Around line 4234-4702: Several new `@Test` functions in the Apply-on-ACK section
(applyOnAckScenarios, echoAfterAckDoesNotDoubleApply,
ackAfterEchoDoesNotDoubleApply, applyOnAckDoesNotUpdateSiteTimeserials,
operationBufferedDuringSyncIsAppliedAfterSyncCompletes,
appliedOnAckSerialsIsClearedOnSync,
publishAndApplyRejectsOnChannelStateChangeDuringSync,
subscriptionCallbacksFireForBothLocallyAppliedAndRealtimeReceivedOperations)
lack the required spec attribution comments; add the appropriate single-line
spec comment above each test using the exact format from CONTRIBUTING.md (use
`@spec` or `@specPartial` as appropriate), ensure you do not duplicate the same
`@spec` on multiple tests for the same spec point, and place the comment
immediately above the corresponding test declaration so metadata tooling picks
it up.
---
Nitpick comments:
In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift`:
- Around line 11-15: The protocol requirement nosync_publishAndApply currently
lacks an explicit access control level; update its declaration to include the
appropriate ACL (for example `internal` or `public` depending on the protocol's
intended visibility) so it satisfies SwiftLint explicit_acl. Locate the
nosync_publishAndApply requirement in InternalDefaultRealtimeObjects (and the
corresponding protocol declaration if separate) and prepend the chosen access
modifier to the function signature, ensuring all matching
implementations/signatures across the codebase use the same access level.
In `@Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift`:
- Around line 1797-1801: The test currently asserts thrownError === publishError
(reference identity) which is brittle; change the assertion to compare
ARTErrorInfo fields instead: extract the thrownError and publishError (both
ARTErrorInfo) returned by realtimeObjects.createMap and assert their .code,
.statusCode and .message (or equivalent stable properties) are equal to each
other so the test uses value-based equality rather than reference identity;
locate the check around thrownError, publishError and createMap to update the
assertion accordingly.
- Around line 1334-1338: Extract the repeated "transition to synced state" setup
into a small helper function (e.g., transitionToSyncedState or
makeRealtimeObjectsSynced) that calls internalQueue.ably_syncNoDeadlock {
realtimeObjects.nosync_onChannelAttached(hasObjects: false);
realtimeObjects.nosync_setSiteCode("site1") }, then replace the duplicated
blocks in the tests (locations referencing internalQueue.ably_syncNoDeadlock and
realtimeObjects.nosync_onChannelAttached / nosync_setSiteCode) with a single
call to that helper to reduce duplication and clarify intent.
In `@Tests/AblyLiveObjectsTests/JS` Integration
Tests/ObjectsIntegrationTests.swift:
- Around line 4507-4553: After creating an EchoInterceptor instance (symbol:
EchoInterceptor and variable echoInterceptor), add a defer {
echoInterceptor.restore() } immediately so the interceptor is always restored
even if the test throws; remove or keep the later manual restore calls
(restore() at the end) as redundant but ensure restore happens after any
explicit releaseAll() call (method: releaseAll) so behavior is unchanged. Apply
the same change to the other test where EchoInterceptor is created (the second
block referenced) to avoid leaking hooks.
In `@Tests/AblyLiveObjectsTests/JS` Integration Tests/TestProxyTransport.swift:
- Line 407: The assignment to msg.connectionDetails uses an explicit
ARTConnectionDetails(...) constructor; change it to use type-inferred shorthand
by calling .init(...) so it matches the project's style (e.g., replace
ARTConnectionDetails(clientId:..., ...) with .init(clientId:..., ...)) at the
msg.connectionDetails assignment site to keep consistency with other tests using
inferred initialization.
ℹ️ Review info
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Jira integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (26)
AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolvedPackage.resolvedPackage.swiftSources/AblyLiveObjects/Internal/CoreSDK.swiftSources/AblyLiveObjects/Internal/DefaultInternalPlugin.swiftSources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swiftSources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swiftSources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swiftSources/AblyLiveObjects/Internal/ObjectsOperationSource.swiftSources/AblyLiveObjects/Internal/ObjectsPool.swiftSources/AblyLiveObjects/Internal/PublishResult.swiftSources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swiftSources/AblyLiveObjects/Protocol/ObjectMessage.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swiftSources/AblyLiveObjects/Utility/Errors.swiftTests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swiftTests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swiftTests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swiftTests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swiftTests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swiftTests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swiftTests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift
Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift
Show resolved
Hide resolved
Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift
Outdated
Show resolved
Hide resolved
9d87b1f to
a77505b
Compare
a77505b to
cbb49fd
Compare
Instead of waiting for the server to echo back an operation before applying it locally, operations are now applied immediately upon receiving the ACK from Realtime. Implements the behaviours from spec commit 56a0bba and ports the corresponding integration tests from ably-js commit 6b1c2de, plus the test fix in ably-js commit f9fbe8e (from [1]). [1] ably/ably-js#2175 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cbb49fd to
1dc20e7
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift (1)
417-433:⚠️ Potential issue | 🟡 MinorMake the returned Bool reflect whether the counter actually changed.
applyCounterCreateOperationcan discard duplicate creates andapplyCounterIncOperationcan return.noop, but both branches still returntrue. That misreports the new applied/skipped contract toInternalDefaultRealtimeObjects, which now uses this flag for ACK dedupe.Suggested fix
case .known(.counterCreate): // RTLC7d1 let update = applyCounterCreateOperation( operation, logger: logger, ) // RTLC7d1a liveObjectMutableState.emit(update, on: userCallbackQueue) // RTLC7d1b - return true + return !update.isNoop case .known(.counterInc): // RTLC7d2 let update = applyCounterIncOperation(operation.counterOp) // RTLC7d2a liveObjectMutableState.emit(update, on: userCallbackQueue) // RTLC7d2b - return true + return !update.isNoop🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift` around lines 417 - 433, The branch handlers for .known(.counterCreate) and .known(.counterInc) must return whether the counter actually changed instead of always true: inspect the result returned from applyCounterCreateOperation(operation, logger:) and applyCounterIncOperation(operation.counterOp) (the "update" values) and only call liveObjectMutableState.emit(...) and return true when the update indicates a real change (i.e., not a .noop/duplicate); if the update represents no-op, skip emit and return false so InternalDefaultRealtimeObjects receives the correct ACK-dedupe flag.Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift (1)
630-700:⚠️ Potential issue | 🟡 MinorDon't report key-level no-ops as applied.
applyMapCreateOperation,applyMapSetOperation, andapplyMapRemoveOperationall have legitimate.noopdiscard paths, but the wrapper still returnstruefor any recognized action. That breaks the new applied/skipped contract and can mark ACK serials as locally applied even when LWW rejected the key update.Suggested fix
case .known(.mapCreate): // RTLM15d1 let update = applyMapCreateOperation( operation, objectsPool: &objectsPool, @@ // RTLM15d1a liveObjectMutableState.emit(update, on: userCallbackQueue) // RTLM15d1b - return true + return !update.isNoop @@ // RTLM15d2a liveObjectMutableState.emit(update, on: userCallbackQueue) // RTLM15d2b - return true + return !update.isNoop @@ // RTLM15d3a liveObjectMutableState.emit(update, on: userCallbackQueue) // RTLM15d3b - return true + return !update.isNoop🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift` around lines 630 - 700, The code currently treats any recognized map action as "applied" even when the underlying helper returned a noop; update the wrapper around applyMapCreateOperation, applyMapSetOperation, and applyMapRemoveOperation to inspect each helper's return value and only emit to liveObjectMutableState and return true when the helper indicates a real application (non-noop), otherwise do not emit and return false (or the appropriate "skipped" result). Locate the calls to applyMapCreateOperation, applyMapSetOperation, and applyMapRemoveOperation and change the logic that currently always calls liveObjectMutableState.emit(...) and returns true to branch on the helper's result (apply vs noop) so ACK serials are only marked applied when a real key-level change occurred.
♻️ Duplicate comments (1)
Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift (1)
410-421:⚠️ Potential issue | 🟡 MinorUpdate the discard-path comment to match the assertion.
The comment on Line 410 still says this operation "will be applied", but the test now asserts
!appliedand unchanged state. Please describe this as the discard-path case.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift` around lines 410 - 421, The comment above the test block is inaccurate: it currently states the operation "will be applied" while the assertion expects the discard-path (`!applied`) and unchanged state. Update the comment that precedes the call to internalQueue.ably_syncNoDeadlock { counter.nosync_apply(...) } to describe this as the discard-path case (objectMessageSerial "ts1" is lexicographically less than "ts2" and therefore should be discarded), matching the assertion and expected unchanged state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift`:
- Around line 158-171: Update Package.swift to replace the commit pin for the
ably-cocoa dependency with a semantic minimum version (use the `from: "X.Y.Z"`
form) that is new enough to include ConnectionDetailsProtocol.siteCode and
PluginAPIProtocol.nosync_sendObject(...completionWithResult:); verify the chosen
version supports those APIs and update the TODO comment accordingly so future
releases won't pull an older ably-cocoa that would trigger the
preconditionFailure in DefaultInternalPlugin.swift (see the `connectionDetails`
usage and the `realtimeObjects.nosync_setSiteCode` call) and the code paths
relying on `nosync_sendObject`.
In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift`:
- Around line 649-656: The nested enum PublishAndApplySyncWaiterOutcome lacks an
explicit access level; add an explicit access modifier (e.g., mark it as
internal) to the enum declaration inside InternalDefaultRealtimeObjects (change
"enum PublishAndApplySyncWaiterOutcome" to "internal enum
PublishAndApplySyncWaiterOutcome") so it complies with the explicit_acl rule and
update any visibility-sensitive references if necessary.
- Around line 217-232: The code currently treats a
publish-ACK-but-not-locally-applied case as a plain success, which leads callers
like the createMap/createCounter flow (calls to nosync_publishAndApply and
checks against mutableState.objectsPool.entries[creationOperation.objectID]) to
assume the object exists and emit newlyCreatedObjectNotInPool false-negatives;
change nosync_publishAndApply to return a distinct result (e.g., an enum case
such as .ackedButNotApplied or a Result variant) instead of .success when the
publish was ACKed but local apply was skipped, and update the callers (the
createMap/createCounter paths that inspect creationOperation and
mutableState.objectsPool.entries) to detect that specific outcome and fall back
to echo-based completion rather than synthesizing a post-ACK error
(LiveObjectsError.newlyCreatedObjectNotInPool). Ensure continuation.resume uses
the new distinct outcome so callers can decide to wait for echo instead of
treating it as success.
- Around line 543-547: When a waiter is queued during sync (when adding to
publishAndApplySyncWaiters in nosync_publishAndApply), immediately record the
operation serial in appliedOnAckSerials to prevent the echoed OBJECT from being
buffered and replayed later; update the code that appends the waiter to also
insert the serial into mutableState.appliedOnAckSerials, and ensure
corresponding cleanup removes the serial if the waiter is cancelled or the
channel transitions to detached/suspended/failed (mirror the existing removal
logic used when draining waiters in the sync-complete/abort paths so
appliedOnAckSerials stays consistent and dedupe checks in the replay/drain code
(the logic around replaying buffered OBJECTs and draining
publishAndApplySyncWaiters) will correctly skip duplicates).
In `@Tests/AblyLiveObjectsTests/JS` Integration
Tests/ObjectsIntegrationTests.swift:
- Around line 4666-4688: The test removes the transport hook
(echoInterceptor.restore()) before guaranteeing the increment echo has been
captured, causing flakiness; after calling try await counter.increment(amount:
5) poll or await on EchoInterceptor until it reports the echo is buffered (e.g.
wait for echoInterceptor.bufferedCount > 0 or call a new
echoInterceptor.waitForBufferedEcho() helper), only then call
echoInterceptor.restore() and finally await echoInterceptor.releaseAll(); update
or add a small wait/poll loop in the test around EchoInterceptor to ensure the
echo is definitely captured before restoring the interceptor.
In `@Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift`:
- Around line 45-46: The mock currently traps in testsOnly_overridePublish;
replace the protocolRequirementNotImplemented() with a stored closure property
on MockCoreSDK (e.g., testsOnly_publishOverride: (([OutboundObjectMessage])
async throws(ARTErrorInfo) -> PublishResult)?), set that property in
testsOnly_overridePublish(with:), and have MockCoreSDK's publish path call that
stored closure when present (rethrowing/returning its PublishResult) and fall
back to the default mock behavior when nil; remove the trap and ensure the
closure signature exactly matches CoreSDK.testsOnly_overridePublish(with:) so
tests using the CoreSDK abstraction use the injected behavior.
---
Outside diff comments:
In `@Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift`:
- Around line 417-433: The branch handlers for .known(.counterCreate) and
.known(.counterInc) must return whether the counter actually changed instead of
always true: inspect the result returned from
applyCounterCreateOperation(operation, logger:) and
applyCounterIncOperation(operation.counterOp) (the "update" values) and only
call liveObjectMutableState.emit(...) and return true when the update indicates
a real change (i.e., not a .noop/duplicate); if the update represents no-op,
skip emit and return false so InternalDefaultRealtimeObjects receives the
correct ACK-dedupe flag.
In `@Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift`:
- Around line 630-700: The code currently treats any recognized map action as
"applied" even when the underlying helper returned a noop; update the wrapper
around applyMapCreateOperation, applyMapSetOperation, and
applyMapRemoveOperation to inspect each helper's return value and only emit to
liveObjectMutableState and return true when the helper indicates a real
application (non-noop), otherwise do not emit and return false (or the
appropriate "skipped" result). Locate the calls to applyMapCreateOperation,
applyMapSetOperation, and applyMapRemoveOperation and change the logic that
currently always calls liveObjectMutableState.emit(...) and returns true to
branch on the helper's result (apply vs noop) so ACK serials are only marked
applied when a real key-level change occurred.
---
Duplicate comments:
In `@Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift`:
- Around line 410-421: The comment above the test block is inaccurate: it
currently states the operation "will be applied" while the assertion expects the
discard-path (`!applied`) and unchanged state. Update the comment that precedes
the call to internalQueue.ably_syncNoDeadlock { counter.nosync_apply(...) } to
describe this as the discard-path case (objectMessageSerial "ts1" is
lexicographically less than "ts2" and therefore should be discarded), matching
the assertion and expected unchanged state.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 951412e7-5614-44da-9ed6-b0784b19c5c8
📒 Files selected for processing (26)
AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolvedPackage.resolvedPackage.swiftSources/AblyLiveObjects/Internal/CoreSDK.swiftSources/AblyLiveObjects/Internal/DefaultInternalPlugin.swiftSources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swiftSources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swiftSources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swiftSources/AblyLiveObjects/Internal/ObjectsOperationSource.swiftSources/AblyLiveObjects/Internal/ObjectsPool.swiftSources/AblyLiveObjects/Internal/PublishResult.swiftSources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swiftSources/AblyLiveObjects/Protocol/ObjectMessage.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swiftSources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swiftSources/AblyLiveObjects/Utility/Errors.swiftTests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swiftTests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swiftTests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swiftTests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swiftTests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swiftTests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swiftTests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift
🚧 Files skipped from review as they are similar to previous changes (6)
- Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift
- Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift
- Sources/AblyLiveObjects/Internal/PublishResult.swift
- Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift
- Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift
- Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift
| // Push the siteCode from connectionDetails | ||
| let siteCode: String? = { | ||
| guard let connectionDetails else { | ||
| return nil | ||
| } | ||
|
|
||
| // This is a fallback; our ably-cocoa dependency version should ensure that this is never triggered. | ||
| guard (connectionDetails as AnyObject).responds(to: #selector(ConnectionDetailsProtocol.siteCode)) else { | ||
| preconditionFailure("ably-cocoa's connectionDetails does not implement siteCode. Please update ably-cocoa to a version that supports apply-on-ACK.") | ||
| } | ||
|
|
||
| return connectionDetails.siteCode?() | ||
| }() | ||
| realtimeObjects.nosync_setSiteCode(siteCode) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -H 'Package(\.resolved|\.swift|@swift-.*\.swift)$|Podfile$|Cartfile$|.*\.podspec$' -E .git -E .build -E Pods | while read -r file; do
echo "==> $file"
rg -n 'ably-cocoa|Ably' "$file" || true
doneRepository: ably/ably-liveobjects-swift-plugin
Length of output: 1508
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show Package.swift with ably-cocoa dependency declaration
echo "=== Package.swift - ably-cocoa dependency ==="
sed -n '1,40p' Package.swift | cat -nRepository: ably/ably-liveobjects-swift-plugin
Length of output: 1499
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# View the specific lines 186-205 in DefaultInternalPlugin.swift
echo "=== DefaultInternalPlugin.swift lines 186-205 ==="
sed -n '186,205p' Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift | cat -nRepository: ably/ably-liveobjects-swift-plugin
Length of output: 1397
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== DefaultInternalPlugin.swift lines 158-171 ==="
sed -n '158,171p' Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift | cat -nRepository: ably/ably-liveobjects-swift-plugin
Length of output: 925
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Git info for the pinned commit ==="
cd Sources/AblyLiveObjects && git remote -v 2>/dev/null || echo "Not in git repo"
echo ""
echo "=== Check if there are any related CHANGELOG or docs about required versions ==="
find . -iname "CHANGELOG*" -o -iname "README*" | head -5 | while read f; do
if [ -f "$f" ]; then
echo "File: $f"
rg -i 'ably-cocoa.*version|siteCode|nosync_sendObject' "$f" -A 2 -B 2 || true
fi
doneRepository: ably/ably-liveobjects-swift-plugin
Length of output: 355
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Full Package.swift ably-cocoa dependency block ==="
sed -n '20,35p' Package.swift | cat -nRepository: ably/ably-liveobjects-swift-plugin
Length of output: 800
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Check git log for recent Package.swift changes related to ably-cocoa version ==="
git log --oneline -20 -- Package.swift 2>/dev/null | head -10 || echo "No git history available"Repository: ably/ably-liveobjects-swift-plugin
Length of output: 194
Confirm the minimum ably-cocoa version is bumped before release with a semantic version constraint.
The ably-cocoa dependency in Package.swift is currently pinned to commit fe6f8cf1e680276f4475229979595512fdc2b9e5 with a TODO comment "Unpin before release". Once unpinned, the Package.swift must specify a minimum version (using from: "X.Y.Z") that includes both ConnectionDetailsProtocol.siteCode and PluginAPIProtocol.nosync_sendObject(...completionWithResult:).
Without a version floor, builds can pull an older ably-cocoa release lacking these APIs, causing runtime crashes via preconditionFailure instead of build-time failures (lines 158–171 and 186–205).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift` around lines
158 - 171, Update Package.swift to replace the commit pin for the ably-cocoa
dependency with a semantic minimum version (use the `from: "X.Y.Z"` form) that
is new enough to include ConnectionDetailsProtocol.siteCode and
PluginAPIProtocol.nosync_sendObject(...completionWithResult:); verify the chosen
version supports those APIs and update the TODO comment accordingly so future
releases won't pull an older ably-cocoa that would trigger the
preconditionFailure in DefaultInternalPlugin.swift (see the `connectionDetails`
usage and the `realtimeObjects.nosync_setSiteCode` call) and the code paths
relying on `nosync_sendObject`.
| nosync_publishAndApply(objectMessages: [creationOperation.objectMessage], coreSDK: coreSDK) { mutableState, result in | ||
| switch result { | ||
| case let .failure(error): | ||
| continuation.resume(returning: .failure(error)) | ||
| case .success: | ||
| // RTO11h | ||
| let map = mutableStateMutex.withoutSync { mutableState in | ||
| mutableState.objectsPool.nosync_getOrCreateMap( | ||
| creationOperation: creationOperation, | ||
| logger: logger, | ||
| internalQueue: mutableStateMutex.dispatchQueue, | ||
| userCallbackQueue: userCallbackQueue, | ||
| clock: clock, | ||
| ) | ||
|
|
||
| // RTO11h2 | ||
| guard case let .map(existingMap) = mutableState.objectsPool.entries[creationOperation.objectID] else { | ||
| // RTO11h3d: Object should have been created by publishAndApply | ||
| let error = LiveObjectsError.newlyCreatedObjectNotInPool(objectID: creationOperation.objectID).toARTErrorInfo() | ||
| continuation.resume(returning: .failure(error)) | ||
| return | ||
| } | ||
| continuation.resume(returning: .success(map)) | ||
|
|
||
| continuation.resume(returning: .success(existingMap)) |
There was a problem hiding this comment.
Don't collapse “publish ACKed but not locally applied” into plain success.
Lines 488-497 return .success(()) even when local apply is skipped, but Lines 217-232 and 279-294 immediately assume the object now exists in objectsPool. In those branches, createMap / createCounter can fail with newlyCreatedObjectNotInPool after the publish was already ACKed, which is a bad false-negative and can trigger duplicate creates on retry. Please surface a distinct outcome here and let callers fall back to echo-based completion instead of synthesizing a post-ACK error.
Also applies to: 279-294, 488-497
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift` around
lines 217 - 232, The code currently treats a publish-ACK-but-not-locally-applied
case as a plain success, which leads callers like the createMap/createCounter
flow (calls to nosync_publishAndApply and checks against
mutableState.objectsPool.entries[creationOperation.objectID]) to assume the
object exists and emit newlyCreatedObjectNotInPool false-negatives; change
nosync_publishAndApply to return a distinct result (e.g., an enum case such as
.ackedButNotApplied or a Result variant) instead of .success when the publish
was ACKed but local apply was skipped, and update the callers (the
createMap/createCounter paths that inspect creationOperation and
mutableState.objectsPool.entries) to detect that specific outcome and fall back
to echo-based completion rather than synthesizing a post-ACK error
(LiveObjectsError.newlyCreatedObjectNotInPool). Ensure continuation.resume uses
the new distinct outcome so callers can decide to wait for echo instead of
treating it as success.
| if mutableState.state.toObjectsSyncState != .synced { | ||
| // RTO20e, RTO20e1: Store as a waiter; will be invoked when sync completes | ||
| // or when the channel enters detached/suspended/failed. | ||
| logger.log("nosync_publishAndApply: waiting for sync to complete before applying \(syntheticMessages.count) message(s)", level: .debug) | ||
| mutableState.publishAndApplySyncWaiters.append(waiter) |
There was a problem hiding this comment.
ACKed operations can still be applied twice while sync is in flight.
When the ACK arrives during .syncing, Lines 543-547 only enqueue a waiter. The serial is not recorded in appliedOnAckSerials until Lines 963-966, after the waiter eventually runs. If the echoed OBJECT arrives before sync completes, it gets buffered and then replayed in Lines 850-858 before Lines 868-871 drain the waiters, so the dedupe check never triggers. Non-idempotent ops like counterInc will be double-applied here.
Also applies to: 850-858, 868-871, 963-966
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift` around
lines 543 - 547, When a waiter is queued during sync (when adding to
publishAndApplySyncWaiters in nosync_publishAndApply), immediately record the
operation serial in appliedOnAckSerials to prevent the echoed OBJECT from being
buffered and replayed later; update the code that appends the waiter to also
insert the serial into mutableState.appliedOnAckSerials, and ensure
corresponding cleanup removes the serial if the waiter is cancelled or the
channel transitions to detached/suspended/failed (mirror the existing removal
logic used when draining waiters in the sync-complete/abort paths so
appliedOnAckSerials stays consistent and dedupe checks in the replay/drain code
(the logic around replaying buffered OBJECTs and draining
publishAndApplySyncWaiters) will correctly skip duplicates).
| /// The outcome passed to a `nosync_publishAndApply` sync waiter closure. | ||
| enum PublishAndApplySyncWaiterOutcome: Sendable { | ||
| case synced | ||
| case channelStateFailed( | ||
| state: _AblyPluginSupportPrivate.RealtimeChannelState, | ||
| reason: ARTErrorInfo?, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Add an explicit ACL to PublishAndApplySyncWaiterOutcome.
This nested enum is missing an explicit access modifier, so it will violate the repo's explicit_acl rule.
As per coding guidelines, "**/*.swift: Specify an explicit access control level (SwiftLint explicit_acl) for all declarations in Swift code (tests are exempt)`"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift` around
lines 649 - 656, The nested enum PublishAndApplySyncWaiterOutcome lacks an
explicit access level; add an explicit access modifier (e.g., mark it as
internal) to the enum declaration inside InternalDefaultRealtimeObjects (change
"enum PublishAndApplySyncWaiterOutcome" to "internal enum
PublishAndApplySyncWaiterOutcome") so it complies with the explicit_acl rule and
update any visibility-sensitive references if necessary.
| // Set up echo interceptor | ||
| let echoInterceptor = EchoInterceptor(client: client, channel: channel) | ||
|
|
||
| // Perform increment via SDK — applied locally on ACK with echoes held | ||
| try await counter.increment(amount: 5) | ||
| #expect(try counter.value == 5, "Check counter value after local increment") | ||
|
|
||
| // The subscription should have fired immediately from the ACK path | ||
| // Collect the event | ||
| var receivedEvents: [LiveCounterUpdate] = [] | ||
| if let event = await counterUpdates.first(where: { _ in true }) { | ||
| receivedEvents.append(event) | ||
| } | ||
|
|
||
| #expect(receivedEvents.count == 1, "Check 1 subscription event received after local increment") | ||
| #expect(receivedEvents[0].amount == 5, "Check event from local apply has amount 5") | ||
|
|
||
| // Restore echo handling | ||
| echoInterceptor.restore() | ||
|
|
||
| // Release held echoes (shouldn't cause another event since already applied) | ||
| await echoInterceptor.releaseAll() | ||
|
|
There was a problem hiding this comment.
Wait until the echo is definitely buffered before restoring interception.
At the moment the transport hook is removed before the test proves the first increment echo was captured. If that echo arrives after Line 4684, it can go through the normal Realtime path and become the “second” callback, which makes this test flaky and weakens the assertion that only the REST increment produced the second event.
Suggested fix
// Set up echo interceptor
let echoInterceptor = EchoInterceptor(client: client, channel: channel)
+ defer { echoInterceptor.restore() }
@@
- // Restore echo handling
- echoInterceptor.restore()
-
- // Release held echoes (shouldn't cause another event since already applied)
+ // Ensure the first increment echo is actually being held before replaying it.
+ await echoInterceptor.waitForEcho()
await echoInterceptor.releaseAll()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Tests/AblyLiveObjectsTests/JS` Integration
Tests/ObjectsIntegrationTests.swift around lines 4666 - 4688, The test removes
the transport hook (echoInterceptor.restore()) before guaranteeing the increment
echo has been captured, causing flakiness; after calling try await
counter.increment(amount: 5) poll or await on EchoInterceptor until it reports
the echo is buffered (e.g. wait for echoInterceptor.bufferedCount > 0 or call a
new echoInterceptor.waitForBufferedEcho() helper), only then call
echoInterceptor.restore() and finally await echoInterceptor.releaseAll(); update
or add a small wait/poll loop in the test around EchoInterceptor to ensure the
echo is definitely captured before restoring the interceptor.
| func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { | ||
| protocolRequirementNotImplemented() |
There was a problem hiding this comment.
Implement the protocol-level publish override on the mock.
MockCoreSDK now advertises CoreSDK.testsOnly_overridePublish(with:), but this implementation still traps. Any test or helper that configures publish behavior through the CoreSDK abstraction will fail against the mock even though it works against DefaultCoreSDK.
Suggested fix
- func testsOnly_overridePublish(with _: `@escaping` ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) {
- protocolRequirementNotImplemented()
+ func testsOnly_overridePublish(with newImplementation: `@escaping` ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) {
+ setPublishHandler(newImplementation)
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift` around lines 45 - 46, The
mock currently traps in testsOnly_overridePublish; replace the
protocolRequirementNotImplemented() with a stored closure property on
MockCoreSDK (e.g., testsOnly_publishOverride: (([OutboundObjectMessage]) async
throws(ARTErrorInfo) -> PublishResult)?), set that property in
testsOnly_overridePublish(with:), and have MockCoreSDK's publish path call that
stored closure when present (rethrowing/returning its PublishResult) and fall
back to the default mock behavior when nil; remove the trap and ensure the
closure signature exactly matches CoreSDK.testsOnly_overridePublish(with:) so
tests using the CoreSDK abstraction use the injected behavior.
When you call a LiveObjects mutation method (e.g.
map.set()), the SDK now applies the effects of this operation to the local LiveObjects data as soon as it receives the server's acknowledgement of this operation. This is an improvement over earlier versions, in which the SDK did not apply such an operation until receiving the operation's echo.Supporting PRs:
Related PRs:
Summary by CodeRabbit
New Features
Bug Fixes
Tests