Skip to content

[AIT-324] Apply operations on ACK#118

Open
lawrence-forooghian wants to merge 1 commit intomainfrom
AIT-324-apply-on-ACK
Open

[AIT-324] Apply operations on ACK#118
lawrence-forooghian wants to merge 1 commit intomainfrom
AIT-324-apply-on-ACK

Conversation

@lawrence-forooghian
Copy link
Collaborator

@lawrence-forooghian lawrence-forooghian commented Feb 27, 2026

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

    • Apply-on-ACK publish flow for faster, more consistent local object updates.
    • Improved publish-and-apply waiter and site-code handling to better coordinate sync across channels.
  • Bug Fixes

    • More reliable object application during channel state changes and sync edge cases.
    • Corrected site-timeserial handling to avoid incorrect updates for local-origin operations.
  • Tests

    • Expanded unit and integration tests covering publish-and-apply, ACK handling, and channel-state scenarios.

@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects February 27, 2026 14:58 Inactive
@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Walkthrough

Implements 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

Cohort / File(s) Summary
Package & Manifest
AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved, Package.resolved, Package.swift
Switched ably-cocoa and ably-cocoa-plugin-support to git revision pins (.git and specific revisions); removed explicit version fields; added TODO unpin comments in manifest.
Realtime abstraction & publish result
Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift, Sources/AblyLiveObjects/Internal/InternalRealtimeObjectsProtocol (new), Sources/AblyLiveObjects/Internal/PublishResult.swift, Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift
Added InternalRealtimeObjectsProtocol and PublishResult type; introduced nosync_publishAndApply overloads, siteCode storage, appliedOnAckSerials tracking, ObjectsOperationSource enum, and publish-and-apply waiter/drain infrastructure.
Core publish plumbing & plugin
Sources/AblyLiveObjects/Internal/CoreSDK.swift, Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift, Sources/AblyLiveObjects/Internal/PublishResult.swift
Changed nosync_publish callback types to return PublishResult; DefaultInternalPlugin gained nosync_onChannelStateChanged and forwards siteCode; publish callback and send flows updated to propagate PublishResult.
Object apply APIs & pools
Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift, Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift, Sources/AblyLiveObjects/Internal/ObjectsPool.swift
Threaded a new source: ObjectsOperationSource through nosync_apply/apply paths; apply methods now return Bool indicating applied/skipped; set/remove/increment/decrement now accept realtimeObjects and call nosync_publishAndApply; removed some getOrCreate helpers.
Public proxy wiring
Sources/AblyLiveObjects/Public/.../PublicDefaultLiveMap.swift, .../PublicDefaultLiveCounter.swift, .../PublicDefaultRealtimeObjects.swift, .../PublicObjectsStore.swift, .../InternalLiveMapValue+ToPublic.swift
Propagated realtimeObjects dependency into public proxies and creation args; constructors/signatures updated to accept realtimeObjects; testsOnly_overridePublish updated to return PublishResult.
Synthesis & protocol helpers
Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift, Sources/AblyLiveObjects/Protocol/ObjectMessage.swift
Added createSynthetic factory for constructing inbound messages from outbound + ACK serial/siteCode; doc note added to OutboundObjectMessage comment.
Errors & mapping
Sources/AblyLiveObjects/Utility/Errors.swift
Added LiveObjectsError cases (publishAndApplyFailedChannelStateChanged, newlyCreatedObjectNotInPool), exposed cause property, and updated toARTErrorInfo to include underlying cause in userInfo.
Tests: core & map/counter
Tests/.../InternalDefaultLiveCounterTests.swift, .../InternalDefaultLiveMapTests.swift, .../InternalDefaultRealtimeObjectsTests.swift
Updated tests to pass realtimeObjects, added extensive publish-and-apply tests (ACK synth, siteCode handling, appliedOnAckSerials, waiter/sync scenarios), and new tests for LOCAL vs CHANNEL source behavior.
Integration tests & test utilities
Tests/.../JS Integration Tests/ObjectsIntegrationTests.swift, Tests/.../JS Integration Tests/TestProxyTransport.swift
Added EchoInterceptor/AckInterceptor utilities, Apply-on-ACK integration scenarios, and updated ARTConnectionDetails usage to include siteCode in test scaffolding; added ARTProtocolMessage.testsOnly_inboundObjectMessages test extension.
Test mocks & helpers
Tests/.../Mocks/MockCoreSDK.swift, Tests/.../Mocks/MockRealtimeObjects.swift
MockCoreSDK publish handlers now return PublishResult and supports callback-style handler; added setPublishCallbackHandler and testsOnly_overridePublish signature updates. Added MockRealtimeObjects implementing InternalRealtimeObjectsProtocol with setPublishAndApplyHandler and nosync_publishAndApply.

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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I stitched ACKs into a nimble song,
Local applies now hop along,
Site codes noted, serials kept tight,
Waiters wake when sync turns right,
Objects bounce home in morning light.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'AIT-324 Apply operations on ACK' clearly and specifically summarizes the main objective of the PR—implementing early application of operations upon server acknowledgement rather than waiting for echoes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch AIT-324-apply-on-ACK

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lawrence-forooghian lawrence-forooghian changed the base branch from main to make-CoreSDK-use-callbacks March 2, 2026 14:32
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 2, 2026 16:06 Inactive
lawrence-forooghian added a commit that referenced this pull request Mar 2, 2026
- 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>
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 2, 2026 17:53 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 2, 2026 20:18 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 2, 2026 21:10 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 19:15 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 20:06 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 20:08 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 21:29 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 22:05 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 22:46 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 3, 2026 22:49 Inactive
@lawrence-forooghian lawrence-forooghian marked this pull request as ready for review March 3, 2026 23:50
@lawrence-forooghian
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (5)
Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift (1)

407-407: Use implicit .init for inferred ARTConnectionDetails construction.

Type inference is available for the connectionDetails property 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: Use defer for 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_publishAndApply should 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 35850d9 and bf41108.

📒 Files selected for processing (26)
  • AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved
  • Package.resolved
  • Package.swift
  • Sources/AblyLiveObjects/Internal/CoreSDK.swift
  • Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift
  • Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift
  • Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift
  • Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift
  • Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift
  • Sources/AblyLiveObjects/Internal/ObjectsPool.swift
  • Sources/AblyLiveObjects/Internal/PublishResult.swift
  • Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift
  • Sources/AblyLiveObjects/Protocol/ObjectMessage.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift
  • Sources/AblyLiveObjects/Utility/Errors.swift
  • Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift
  • Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift
  • Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift
  • Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift
  • Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift
  • Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift
  • Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift

@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 4, 2026 00:26 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 4, 2026 01:01 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 4, 2026 01:11 Inactive
@github-actions github-actions bot temporarily deployed to staging/pull/118/AblyLiveObjects March 4, 2026 01:25 Inactive
@lawrence-forooghian lawrence-forooghian changed the title [WIP, AIT-324] Apply operations on ACK [AIT-324] Apply operations on ACK Mar 4, 2026
Base automatically changed from make-CoreSDK-use-callbacks to main March 5, 2026 13:26
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>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Make the returned Bool reflect whether the counter actually changed.

applyCounterCreateOperation can discard duplicate creates and applyCounterIncOperation can return .noop, but both branches still return true. That misreports the new applied/skipped contract to InternalDefaultRealtimeObjects, 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 | 🟡 Minor

Don't report key-level no-ops as applied.

applyMapCreateOperation, applyMapSetOperation, and applyMapRemoveOperation all have legitimate .noop discard paths, but the wrapper still returns true for 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 | 🟡 Minor

Update 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 !applied and 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

📥 Commits

Reviewing files that changed from the base of the PR and between bf41108 and 1dc20e7.

📒 Files selected for processing (26)
  • AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved
  • Package.resolved
  • Package.swift
  • Sources/AblyLiveObjects/Internal/CoreSDK.swift
  • Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift
  • Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift
  • Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift
  • Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift
  • Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift
  • Sources/AblyLiveObjects/Internal/ObjectsPool.swift
  • Sources/AblyLiveObjects/Internal/PublishResult.swift
  • Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift
  • Sources/AblyLiveObjects/Protocol/ObjectMessage.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift
  • Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift
  • Sources/AblyLiveObjects/Utility/Errors.swift
  • Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift
  • Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift
  • Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift
  • Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift
  • Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift
  • Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift
  • Tests/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

Comment on lines +158 to +171
// 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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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
done

Repository: 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 -n

Repository: 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 -n

Repository: 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 -n

Repository: 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
done

Repository: 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 -n

Repository: 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`.

Comment on lines +217 to +232
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))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +543 to +547
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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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).

Comment on lines +649 to +656
/// The outcome passed to a `nosync_publishAndApply` sync waiter closure.
enum PublishAndApplySyncWaiterOutcome: Sendable {
case synced
case channelStateFailed(
state: _AblyPluginSupportPrivate.RealtimeChannelState,
reason: ARTErrorInfo?,
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +4666 to +4688
// 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()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +45 to 46
func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) {
protocolRequirementNotImplemented()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant