@@ -360,6 +360,17 @@ private async Task<RealtimeCommand> ProcessCommandInner(RealtimeCommand command)
360360 switch ( command )
361361 {
362362 case ConnectCommand _:
363+
364+ // RTN11d - connect() out of CLOSED or FAILED starts afresh. The channel half,
365+ // back to INITIALIZED with errorReason unset, is done per channel by the command
366+ // queued below; Id and Key are already emptied on entering CLOSED or FAILED.
367+ if ( State . Connection . State == ConnectionState . Closed ||
368+ State . Connection . State == ConnectionState . Failed )
369+ {
370+ State . Connection . ErrorReason = null ;
371+ State . Connection . MessageSerial = 0 ;
372+ }
373+
363374 var nextCommand = ConnectionManager . Connect ( ) ;
364375 var initFailedChannelsOnConnect =
365376 ChannelCommand . CreateForAllChannels ( InitialiseFailedChannelsOnConnect . Create ( ) . TriggeredBy ( command ) ) ;
@@ -949,14 +960,36 @@ private async Task<RealtimeCommand> HandleSetStateCommand(RealtimeCommand comman
949960
950961 case SetFailedStateCommand cmd :
951962
952- ClearAckQueueAndFailMessages ( ErrorInfo . ReasonFailed ) ;
953-
954963 var error = TransformIfTokenErrorAndNotRetryable ( ) ;
955964 var failedState = new ConnectionFailedState ( ConnectionManager , error , Logger ) ;
956- SetState ( failedState ) ;
957- State . Connection . ClearKeyAndId ( ) ; // RTN8c, RTN9c
958965
959- ConnectionManager . DestroyTransport ( ) ;
966+ // RTN7e - the queued messages are failed with "an error representing the
967+ // reason for the state change", taken off the state object so it is this
968+ // transition's reason even if SetState early-returns. In the finally, after
969+ // the transition, so a publisher's callback sees the state it is being told
970+ // about and a throwing transition cannot strand the messages uncalled.
971+ // ably-js orders it the same way: enactStateChange then failQueuedMessages.
972+ //
973+ // RTN7e - the queued messages are failed with "an error representing the
974+ // reason for the state change", taken off the state object so it is this
975+ // transition's reason even if SetState early-returns. In the finally, after
976+ // the transition, so a publisher's callback sees the state it is being told
977+ // about and a throwing transition cannot strand the messages uncalled.
978+ // ably-js orders it the same way: enactStateChange then failQueuedMessages.
979+ //
980+ // RTN8d and RTN9d share that finally: the connection has entered the state
981+ // by the time SetState rethrows, so a throw must not leave it reporting a
982+ // terminal state while still holding a resumable key and a live transport.
983+ try
984+ {
985+ SetState ( failedState ) ;
986+ }
987+ finally
988+ {
989+ ClearAckQueueAndFailMessages ( failedState . Error ) ;
990+ State . Connection . ClearKeyAndId ( ) ; // RTN8d, RTN9d
991+ ConnectionManager . DestroyTransport ( ) ;
992+ }
960993
961994 ErrorInfo TransformIfTokenErrorAndNotRetryable ( )
962995 {
@@ -1108,7 +1141,7 @@ async Task<bool> CheckInstantRetryFlag()
11081141
11091142 var closingState = new ConnectionClosingState ( ConnectionManager , connectedTransport , Logger ) ;
11101143 SetState ( closingState ) ;
1111- State . Connection . ClearKeyAndId ( ) ; // RTN8c, RTN9c
1144+ State . Connection . ClearKeyAndId ( ) ; // RTN8d, RTN9d
11121145
11131146 if ( connectedTransport )
11141147 {
@@ -1126,27 +1159,49 @@ async Task<bool> CheckInstantRetryFlag()
11261159 State . Connection . ClearKey ( ) ;
11271160 }
11281161
1129- ClearAckQueueAndFailMessages ( ErrorInfo . ReasonSuspended ) ;
1130-
11311162 var suspendedState = new ConnectionSuspendedState ( ConnectionManager , cmd . Error , Logger ) ;
1132- SetState ( suspendedState ) ;
1133- State . Connection . ClearKeyAndId ( ) ; // RTN8c, RTN9c
1163+
1164+ // RTN7e and the teardown - see the note on the FAILED case.
1165+ try
1166+ {
1167+ SetState ( suspendedState ) ;
1168+ }
1169+ finally
1170+ {
1171+ ClearAckQueueAndFailMessages ( suspendedState . Error ) ;
1172+
1173+ // Deliberately NOT RTN8d/RTN9d, which name only CLOSED, CLOSING and
1174+ // FAILED. Clearing here is this library's pre-6.1.0 behaviour, kept
1175+ // because it is coupled to the connectionStateTtl freshness check that
1176+ // also predates 6.1.0.
1177+ State . Connection . ClearKeyAndId ( ) ;
1178+
1179+ // Needed here as well as in the DISCONNECTED handler, which diverts to
1180+ // this case before reaching its own DestroyTransport. A surviving
1181+ // transport keeps its listener for up to suspendedRetryTimeout.
1182+ ConnectionManager . DestroyTransport ( ) ;
1183+ }
11341184
11351185 break ;
11361186
11371187 case SetClosedStateCommand cmd :
11381188
1139- ClearAckQueueAndFailMessages ( ErrorInfo . ReasonClosed ) ;
1140-
11411189 var closedState = new ConnectionClosedState ( ConnectionManager , cmd . Error , Logger )
11421190 {
11431191 Exception = cmd . Exception ,
11441192 } ;
11451193
1146- SetState ( closedState ) ;
1147- State . Connection . ClearKeyAndId ( ) ; // RTN8c, RTN9c
1148-
1149- ConnectionManager . DestroyTransport ( ) ;
1194+ // RTN7e and the teardown - see the note on the FAILED case.
1195+ try
1196+ {
1197+ SetState ( closedState ) ;
1198+ }
1199+ finally
1200+ {
1201+ ClearAckQueueAndFailMessages ( closedState . Error ) ;
1202+ State . Connection . ClearKeyAndId ( ) ; // RTN8d, RTN9d
1203+ ConnectionManager . DestroyTransport ( ) ;
1204+ }
11501205
11511206 break ;
11521207 }
@@ -1177,6 +1232,8 @@ public void SetState(ConnectionStateBase newState, bool skipTimer = false)
11771232 Logger . Debug ( message ) ;
11781233 }
11791234
1235+ var notified = false ;
1236+
11801237 try
11811238 {
11821239 if ( newState . IsUpdate == false )
@@ -1204,13 +1261,28 @@ public void SetState(ConnectionStateBase newState, bool skipTimer = false)
12041261 Logger . Debug ( $ "xx { newState . State } : Skipping attaching.") ;
12051262 }
12061263
1264+ notified = true ;
12071265 UpdateStateAndNotifyConnection ( newState ) ;
12081266 }
1209- catch ( AblyException ex )
1267+ catch ( Exception ex )
12101268 {
1211- Logger . Error ( "Error attaching to context" , ex ) ;
1212-
1213- UpdateStateAndNotifyConnection ( newState ) ;
1269+ // Everything, not just AblyException: anything else thrown by StartTimer or the
1270+ // state object would reach the command loop, which logs and drops it, leaving the
1271+ // connection with no transport, no timer and no state change emitted. The transition
1272+ // is still completed below and the exception still rethrown.
1273+ Logger . Error ( $ "Error attaching to context while changing state to { newState . State } ", ex ) ;
1274+
1275+ // Only if the notify has not already happened. A throw during the transition lands
1276+ // here after the state change has been emitted - StartTimer is one source, and a
1277+ // negative retry timeout reaches System.Threading.Timer. Not a channel's
1278+ // ConnectionStateChanged, which RealtimeChannels guards per channel. Re-emitting is harmless for an ordinary transition, which the
1279+ // same-state check swallows, but an RTN24 update has no such check and was emitted
1280+ // twice. The flag is set before the call so a throw from inside it does not trigger
1281+ // a second attempt either.
1282+ if ( notified == false )
1283+ {
1284+ UpdateStateAndNotifyConnection ( newState ) ;
1285+ }
12141286
12151287 newState . AbortTimer ( ) ;
12161288
0 commit comments