@@ -623,6 +623,231 @@ struct InternalDefaultRealtimeObjectsTests {
623623 let newRoot = realtimeObjects. testsOnly_objectsPool. root
624624 #expect( newRoot. testsOnly_data. isEmpty) // Should be zero-valued (empty)
625625 }
626+
627+ // MARK: - RTO4d Buffered Operations Tests
628+
629+ // @spec RTO4d
630+ @Test
631+ func bufferedObjectOperationsAreDiscardedOnAttached( ) {
632+ let internalQueue = TestFactories . createInternalQueue ( )
633+ let realtimeObjects = InternalDefaultRealtimeObjectsTests . createDefaultRealtimeObjects ( internalQueue: internalQueue)
634+ let sequenceId = " seq1 "
635+
636+ // Start a sync sequence with a cursor (so it doesn't complete immediately)
637+ internalQueue. ably_syncNoDeadlock {
638+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
639+ objectMessages: [
640+ TestFactories . mapObjectMessage ( objectId: " map:sync@100 " ) ,
641+ ] ,
642+ protocolMessageChannelSerial: " \( sequenceId) :cursor1 " ,
643+ )
644+ }
645+
646+ // Inject an OBJECT operation; it will be buffered per RTO8a
647+ internalQueue. ably_syncNoDeadlock {
648+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
649+ TestFactories . mapCreateOperationMessage ( objectId: " map:pre-attached@200 " ) ,
650+ ] )
651+ }
652+
653+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 1 )
654+
655+ // Receive ATTACHED with HAS_OBJECTS — buffer should be cleared per RTO4d
656+ internalQueue. ably_syncNoDeadlock {
657+ realtimeObjects. nosync_onChannelAttached ( hasObjects: true )
658+ }
659+
660+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 0 )
661+
662+ // Inject another OBJECT operation after ATTACHED
663+ internalQueue. ably_syncNoDeadlock {
664+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
665+ TestFactories . mapCreateOperationMessage ( objectId: " map:post-attached@300 " ) ,
666+ ] )
667+ }
668+
669+ // Complete the sync sequence
670+ internalQueue. ably_syncNoDeadlock {
671+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
672+ objectMessages: [ ] ,
673+ protocolMessageChannelSerial: " \( sequenceId) : " ,
674+ )
675+ }
676+
677+ // Only the post-ATTACHED operation should have been applied
678+ let pool = realtimeObjects. testsOnly_objectsPool
679+ #expect( pool. entries [ " map:pre-attached@200 " ] == nil )
680+ #expect( pool. entries [ " map:post-attached@300 " ] != nil )
681+ }
682+
683+ // @spec RTO4d
684+ @Test
685+ func bufferedObjectOperationsAreDiscardedWhenAlreadySyncingChannelReceivesAttached( ) {
686+ let internalQueue = TestFactories . createInternalQueue ( )
687+ let realtimeObjects = InternalDefaultRealtimeObjectsTests . createDefaultRealtimeObjects ( internalQueue: internalQueue)
688+ let sequenceId = " seq1 "
689+
690+ // Transition to SYNCING via a first ATTACHED
691+ internalQueue. ably_syncNoDeadlock {
692+ realtimeObjects. nosync_onChannelAttached ( hasObjects: true )
693+ }
694+
695+ // Start a sync sequence
696+ internalQueue. ably_syncNoDeadlock {
697+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
698+ objectMessages: [
699+ TestFactories . mapObjectMessage ( objectId: " map:sync@100 " ) ,
700+ ] ,
701+ protocolMessageChannelSerial: " \( sequenceId) :cursor1 " ,
702+ )
703+ }
704+
705+ // Inject an OBJECT operation; it will be buffered per RTO8a
706+ internalQueue. ably_syncNoDeadlock {
707+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
708+ TestFactories . mapCreateOperationMessage ( objectId: " map:pre-attached@200 " ) ,
709+ ] )
710+ }
711+
712+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 1 )
713+
714+ // Receive another ATTACHED (e.g. due to RESUMED) — buffer should be cleared per RTO4d
715+ internalQueue. ably_syncNoDeadlock {
716+ realtimeObjects. nosync_onChannelAttached ( hasObjects: true )
717+ }
718+
719+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 0 )
720+
721+ // Inject another OBJECT operation after the second ATTACHED
722+ internalQueue. ably_syncNoDeadlock {
723+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
724+ TestFactories . mapCreateOperationMessage ( objectId: " map:post-attached@300 " ) ,
725+ ] )
726+ }
727+
728+ // Complete the sync sequence
729+ internalQueue. ably_syncNoDeadlock {
730+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
731+ objectMessages: [ ] ,
732+ protocolMessageChannelSerial: " \( sequenceId) : " ,
733+ )
734+ }
735+
736+ // Only the post-ATTACHED operation should have been applied
737+ let pool = realtimeObjects. testsOnly_objectsPool
738+ #expect( pool. entries [ " map:pre-attached@200 " ] == nil )
739+ #expect( pool. entries [ " map:post-attached@300 " ] != nil )
740+ }
741+
742+ // Verifies that RTO5a2b (replaced by RTO4d) no longer clears buffered operations on new OBJECT_SYNC sequence
743+ @Test
744+ func bufferedObjectOperationsAreNotDiscardedOnNewObjectSyncSequence( ) {
745+ let internalQueue = TestFactories . createInternalQueue ( )
746+ let realtimeObjects = InternalDefaultRealtimeObjectsTests . createDefaultRealtimeObjects ( internalQueue: internalQueue)
747+ let firstSequenceId = " seq1 "
748+ let secondSequenceId = " seq2 "
749+
750+ // Start a first sync sequence
751+ internalQueue. ably_syncNoDeadlock {
752+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
753+ objectMessages: [
754+ TestFactories . mapObjectMessage ( objectId: " map:sync1@100 " ) ,
755+ ] ,
756+ protocolMessageChannelSerial: " \( firstSequenceId) :cursor1 " ,
757+ )
758+ }
759+
760+ // Inject an OBJECT operation; it will be buffered per RTO8a
761+ internalQueue. ably_syncNoDeadlock {
762+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
763+ TestFactories . mapCreateOperationMessage ( objectId: " map:buffered1@200 " ) ,
764+ ] )
765+ }
766+
767+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 1 )
768+
769+ // Start a new sync sequence with a different ID — buffer should NOT be cleared
770+ internalQueue. ably_syncNoDeadlock {
771+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
772+ objectMessages: [
773+ TestFactories . mapObjectMessage ( objectId: " map:sync2@300 " ) ,
774+ ] ,
775+ protocolMessageChannelSerial: " \( secondSequenceId) :cursor1 " ,
776+ )
777+ }
778+
779+ // Inject another OBJECT operation
780+ internalQueue. ably_syncNoDeadlock {
781+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
782+ TestFactories . mapCreateOperationMessage ( objectId: " map:buffered2@400 " ) ,
783+ ] )
784+ }
785+
786+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 2 )
787+
788+ // Complete the second sync sequence
789+ internalQueue. ably_syncNoDeadlock {
790+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
791+ objectMessages: [ ] ,
792+ protocolMessageChannelSerial: " \( secondSequenceId) : " ,
793+ )
794+ }
795+
796+ // Both buffered operations should have been applied (buffer was NOT cleared by new sequence)
797+ let pool = realtimeObjects. testsOnly_objectsPool
798+ #expect( pool. entries [ " map:buffered1@200 " ] != nil )
799+ #expect( pool. entries [ " map:buffered2@400 " ] != nil )
800+ }
801+
802+ // Verifies that operations are buffered when OBJECT_SYNC is received after a completed sync without a preceding ATTACHED (RTO5e resync)
803+ @Test
804+ func operationsAreBufferedDuringResyncWithoutPrecedingAttached( ) {
805+ let internalQueue = TestFactories . createInternalQueue ( )
806+ let realtimeObjects = InternalDefaultRealtimeObjectsTests . createDefaultRealtimeObjects ( internalQueue: internalQueue)
807+ let firstSequenceId = " seq1 "
808+ let resyncSequenceId = " seq2 "
809+
810+ // Complete an initial sync to reach SYNCED state
811+ internalQueue. ably_syncNoDeadlock {
812+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
813+ objectMessages: [
814+ TestFactories . mapObjectMessage ( objectId: " map:initial@100 " ) ,
815+ ] ,
816+ protocolMessageChannelSerial: nil , // Complete sync immediately
817+ )
818+ }
819+
820+ // Receive an OBJECT_SYNC without a preceding ATTACHED — triggers RTO5e resync
821+ internalQueue. ably_syncNoDeadlock {
822+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
823+ objectMessages: [
824+ TestFactories . mapObjectMessage ( objectId: " map:resync@200 " ) ,
825+ ] ,
826+ protocolMessageChannelSerial: " \( resyncSequenceId) :cursor1 " ,
827+ )
828+ }
829+
830+ // Inject an OBJECT operation; should be buffered since we're now SYNCING again
831+ internalQueue. ably_syncNoDeadlock {
832+ realtimeObjects. nosync_handleObjectProtocolMessage ( objectMessages: [
833+ TestFactories . mapCreateOperationMessage ( objectId: " map:buffered@300 " ) ,
834+ ] )
835+ }
836+
837+ #expect( realtimeObjects. testsOnly_bufferedObjectOperationsCount == 1 )
838+
839+ // Complete the resync sequence
840+ internalQueue. ably_syncNoDeadlock {
841+ realtimeObjects. nosync_handleObjectSyncProtocolMessage (
842+ objectMessages: [ ] ,
843+ protocolMessageChannelSerial: " \( resyncSequenceId) : " ,
844+ )
845+ }
846+
847+ // Buffered operation should have been applied upon completion
848+ let pool = realtimeObjects. testsOnly_objectsPool
849+ #expect( pool. entries [ " map:buffered@300 " ] != nil )
850+ }
626851 }
627852
628853 /// Tests for `InternalDefaultRealtimeObjects.getRoot`, covering RTO1 specification points
0 commit comments